#import "CrossDeskRTCBridge.h" #import #import #import #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include "minirtc.h" namespace { using crossdesk::ControlType; using crossdesk::FileTransferAck; using crossdesk::KeyFlag; using crossdesk::MouseFlag; using crossdesk::RemoteAction; using crossdesk::ServiceCommandFlag; using crossdesk::kFileAckMagic; using crossdesk::kAudioStream; using crossdesk::kClipboardStream; using crossdesk::kControlStream; using crossdesk::kDataStream; using crossdesk::kFileChunkSize; using crossdesk::kFileFeedbackStream; using crossdesk::kFileStream; using crossdesk::kKeyboardStream; using crossdesk::kMaxClipboardBytes; using crossdesk::kMouseStream; struct IncomingFile { std::string name; std::string path; uint64_t total_size = 0; uint64_t received = 0; FILE *handle = nullptr; }; struct OutgoingFile { std::string name; uint64_t total_size = 0; }; enum class PeerRole { Identity, Controller }; struct CallbackContext { __weak CrossDeskRTCBridge *owner = nil; PeerRole role = PeerRole::Identity; uint64_t generation = 0; }; struct PresenceRequest { uint64_t generation; bool subscription; }; struct RTCState { PeerPtr *identity_peer = nullptr; PeerPtr *controller_peer = nullptr; CallbackContext identity_context; CallbackContext controller_context; std::string signal_host; int signal_port = 0; VideoDegradationPreference video_adaptation_policy = VideoDegradationPreference::MaintainResolution; bool identity_ready = false; std::deque presence_requests; bool identity_recovery_attempted = false; std::string identity_with_password; std::string identity_base; std::string controller_login; std::string pending_remote_id; uint64_t pending_controller_generation = 0; std::string pending_password; std::string transmission_id; std::string log_path; std::unordered_map incoming_files; std::unordered_map outgoing_files; }; std::atomic g_next_file_id{1}; char kRTCQueueSpecificKey; bool IsReusableIdentity(const std::string &identity) { const size_t separator = identity.find('@'); return separator != std::string::npos && separator > 0 && separator + 1 < identity.size(); } std::string ProvisionedIdentityCredential(const std::string &received, const std::string ¤t) { // A successful re-login can acknowledge the ID without echoing its password. // Retain the credential used to authenticate that same ID. if (!IsReusableIdentity(received) && IsReusableIdentity(current) && current.substr(0, current.find('@')) == received) return current; return received; } NSString *IdentityCredentialAccount(NSString *host, NSInteger port) { // Preserve the old server-scoped key so upgrades retain the same identity. return [NSString stringWithFormat:@"CrossDeskIdentity.%@.%ld", host, (long)port]; } NSMutableDictionary *IdentityCredentialQuery(NSString *account) { return [@{ (__bridge id)kSecClass: (__bridge id)kSecClassGenericPassword, (__bridge id)kSecAttrService: @"cn.crossdesk.mobile.signaling-identity", (__bridge id)kSecAttrAccount: account, (__bridge id)kSecAttrSynchronizable: @NO, } mutableCopy]; } bool IsReusableIdentityValue(NSString *value) { return [value isKindOfClass:NSString.class] && value.UTF8String && IsReusableIdentity(std::string(value.UTF8String)); } OSStatus SaveIdentityCredential(NSString *account, NSString *value) { if (!IsReusableIdentityValue(value)) return errSecParam; NSMutableDictionary *query = IdentityCredentialQuery(account); NSDictionary *attributes = @{ (__bridge id)kSecValueData: [value dataUsingEncoding:NSUTF8StringEncoding], (__bridge id)kSecAttrAccessible: (__bridge id)kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly, }; // Update in place: deleting first could lose a valid credential on failure. OSStatus status = SecItemUpdate((__bridge CFDictionaryRef)query, (__bridge CFDictionaryRef)attributes); if (status == errSecItemNotFound) { NSMutableDictionary *item = [query mutableCopy]; [item addEntriesFromDictionary:attributes]; status = SecItemAdd((__bridge CFDictionaryRef)item, nullptr); if (status == errSecDuplicateItem) { status = SecItemUpdate((__bridge CFDictionaryRef)query, (__bridge CFDictionaryRef)attributes); } } if (status == errSecSuccess) { [[NSUserDefaults standardUserDefaults] removeObjectForKey:account]; } return status; } OSStatus LoadIdentityCredential(NSString *account, NSString **value) { *value = nil; NSMutableDictionary *query = IdentityCredentialQuery(account); query[(__bridge id)kSecReturnData] = @YES; query[(__bridge id)kSecMatchLimit] = (__bridge id)kSecMatchLimitOne; CFTypeRef result = nullptr; OSStatus status = SecItemCopyMatching((__bridge CFDictionaryRef)query, &result); id object = CFBridgingRelease(result); NSUserDefaults *defaults = NSUserDefaults.standardUserDefaults; if (status == errSecSuccess) { NSString *stored = [object isKindOfClass:NSData.class] ? [[NSString alloc] initWithData:object encoding:NSUTF8StringEncoding] : nil; if (!IsReusableIdentityValue(stored)) return errSecDecode; *value = stored; // A Keychain value wins over a stale copy left by an interrupted migration. [defaults removeObjectForKey:account]; return errSecSuccess; } if (status != errSecItemNotFound) return status; NSString *legacy = [defaults objectForKey:account]; if (!legacy) return errSecItemNotFound; if (!IsReusableIdentityValue(legacy)) { // Older passwordless IDs cannot authenticate on a subsequent app launch. [defaults removeObjectForKey:account]; return errSecItemNotFound; } status = SaveIdentityCredential(account, legacy); if (status == errSecSuccess) *value = legacy; return status; } OSStatus MigrateLegacyIdentityCredentials() { OSStatus first_error = errSecSuccess; for (NSString *key in NSUserDefaults.standardUserDefaults.dictionaryRepresentation) { if (![key hasPrefix:@"CrossDeskIdentity."]) continue; NSString *unused = nil; OSStatus status = LoadIdentityCredential(key, &unused); if (status != errSecSuccess && status != errSecItemNotFound && first_error == errSecSuccess) first_error = status; } return first_error; } OSStatus RemoveIdentityCredential(NSString *account) { OSStatus status = SecItemDelete((__bridge CFDictionaryRef)IdentityCredentialQuery(account)); if (status == errSecSuccess || status == errSecItemNotFound) { [[NSUserDefaults standardUserDefaults] removeObjectForKey:account]; return errSecSuccess; } return status; } void CopyCString(char *destination, size_t capacity, const std::string &source) { if (!destination || capacity == 0) return; std::strncpy(destination, source.c_str(), capacity - 1); destination[capacity - 1] = '\0'; } std::string BaseIdentity(const std::string &identity) { const size_t separator = identity.find('@'); return separator == std::string::npos ? identity : identity.substr(0, separator); } std::string MouseJSON(float x, float y, int wheel, int flag) { RemoteAction action{}; action.type = ControlType::mouse; action.m = {std::clamp(x, 0.0f, 1.0f), std::clamp(y, 0.0f, 1.0f), wheel, static_cast(flag)}; return action.to_json(); } std::string KeyJSON(NSUInteger key_code, bool is_down) { RemoteAction action{}; action.type = ControlType::keyboard; action.k = {static_cast(key_code), 0, false, is_down ? KeyFlag::key_down : KeyFlag::key_up}; return action.to_json(); } struct KeyMapping { NSUInteger key_code = 0; bool shift = false; bool valid = false; }; KeyMapping MapASCII(unsigned char character) { if (character >= 'a' && character <= 'z') { return {static_cast(character - 'a' + 'A'), false, true}; } if (character >= 'A' && character <= 'Z') { return {static_cast(character), true, true}; } if (character >= '0' && character <= '9') { return {static_cast(character), false, true}; } switch (character) { case ' ': return {0x20, false, true}; case '\n': return {0x0D, false, true}; case '\t': return {0x09, false, true}; case '-': return {0xBD, false, true}; case '_': return {0xBD, true, true}; case '=': return {0xBB, false, true}; case '+': return {0xBB, true, true}; case '[': return {0xDB, false, true}; case '{': return {0xDB, true, true}; case ']': return {0xDD, false, true}; case '}': return {0xDD, true, true}; case '\\': return {0xDC, false, true}; case '|': return {0xDC, true, true}; case ';': return {0xBA, false, true}; case ':': return {0xBA, true, true}; case '\'': return {0xDE, false, true}; case '"': return {0xDE, true, true}; case ',': return {0xBC, false, true}; case '<': return {0xBC, true, true}; case '.': return {0xBE, false, true}; case '>': return {0xBE, true, true}; case '/': return {0xBF, false, true}; case '?': return {0xBF, true, true}; case '`': return {0xC0, false, true}; case '~': return {0xC0, true, true}; case '!': return {'1', true, true}; case '@': return {'2', true, true}; case '#': return {'3', true, true}; case '$': return {'4', true, true}; case '%': return {'5', true, true}; case '^': return {'6', true, true}; case '&': return {'7', true, true}; case '*': return {'8', true, true}; case '(': return {'9', true, true}; case ')': return {'0', true, true}; default: return {}; } } void DispatchMain(dispatch_block_t block) { if ([NSThread isMainThread]) { block(); } else { dispatch_async(dispatch_get_main_queue(), block); } } } // namespace @interface CrossDeskRTCBridge () - (void)handleSignalState:(CrossDeskSignalState)state role:(PeerRole)role generation:(uint64_t)generation; - (void)handleSignalMessage:(const char *)message size:(size_t)size role:(PeerRole)role generation:(uint64_t)generation; - (void)handleProvisionedIdentity:(const std::string &)identity generation:(uint64_t)generation; - (void)handleConnectionState:(CrossDeskConnectionState)state remoteID:(const std::string &)remoteID generation:(uint64_t)generation; - (BOOL)isControllerGenerationActive:(uint64_t)generation; - (BOOL)isControllerGenerationCurrent:(uint64_t)generation; - (void)handleVideoFrame:(const MiniRtcVideoFrame *)frame sourceID:(const char *)sourceID sourceIDSize:(size_t)sourceIDSize; - (void)enqueueVideoPixelBuffer:(CVPixelBufferRef)pixelBuffer width:(NSInteger)width height:(NSInteger)height captureUptime:(NSTimeInterval)captureUptime; - (void)resetVideoDelivery; - (void)notifyVideoSettings:(crossdesk::VideoSettings)settings generation:(uint64_t)generation; - (void)handleAudio:(const char *)data size:(size_t)size; - (void)handleData:(const char *)data size:(size_t)size sourceID:(const char *)sourceID sourceIDSize:(size_t)sourceIDSize generation:(uint64_t)generation; - (void)handleStats:(const MiniRtcNetTrafficStats *)stats mode:(TraversalMode)mode generation:(uint64_t)generation; - (void)sendMessage:(const std::string &)message reliable:(BOOL)reliable stream:(const char *)stream; - (void)createIdentityPeer; - (void)createControllerPeer; - (void)destroyIdentityPeer; - (void)reportIdentityStorageStatus:(OSStatus)status; - (void)destroyControllerPeer; @end namespace { void OnVideoFrame(const MiniRtcVideoFrame *frame, const char *, size_t, const char *source_id, size_t source_id_size, void *user_data) { auto *context = static_cast(user_data); CrossDeskRTCBridge *owner = context ? context->owner : nil; if (owner && context->role == PeerRole::Controller && frame && [owner isControllerGenerationActive:context->generation]) { [owner handleVideoFrame:frame sourceID:source_id sourceIDSize:source_id_size]; } } void OnAudioBuffer(const char *data, size_t size, const char *, size_t, const char *, size_t, void *user_data) { auto *context = static_cast(user_data); CrossDeskRTCBridge *owner = context ? context->owner : nil; if (owner && context->role == PeerRole::Controller && data && size > 0 && [owner isControllerGenerationActive:context->generation]) { [owner handleAudio:data size:size]; } } void OnDataBuffer(const char *data, size_t size, const char *, size_t, const char *source_id, size_t source_id_size, void *user_data) { auto *context = static_cast(user_data); CrossDeskRTCBridge *owner = context ? context->owner : nil; if (owner && context->role == PeerRole::Controller && [owner isControllerGenerationActive:context->generation]) { [owner handleData:data size:size sourceID:source_id sourceIDSize:source_id_size generation:context->generation]; } } void OnSignalState(SignalStatus status, const char *, size_t, void *user_data) { auto *context = static_cast(user_data); CrossDeskRTCBridge *owner = context ? context->owner : nil; if (owner && (context->role == PeerRole::Identity || [owner isControllerGenerationActive:context->generation])) { [owner handleSignalState:static_cast(status) role:context->role generation:context->generation]; } } void OnSignalMessage(const char *message, size_t size, void *user_data) { auto *context = static_cast(user_data); CrossDeskRTCBridge *owner = context ? context->owner : nil; if (owner && message && size > 0) { [owner handleSignalMessage:message size:size role:context->role generation:context->generation]; } } void OnConnectionState(ConnectionStatus status, const char *remote_id, size_t remote_id_size, void *user_data) { auto *context = static_cast(user_data); CrossDeskRTCBridge *owner = context ? context->owner : nil; if (!owner || context->role != PeerRole::Controller || ![owner isControllerGenerationActive:context->generation]) { return; } const std::string identifier(remote_id ? remote_id : "", remote_id_size); [owner handleConnectionState:static_cast(status) remoteID:identifier generation:context->generation]; } void OnNetworkStats(const char *peer_id, size_t peer_id_size, TraversalMode mode, const MiniRtcNetTrafficStats *stats, const char *, size_t, void *user_data) { auto *context = static_cast(user_data); CrossDeskRTCBridge *owner = context ? context->owner : nil; if (!owner) return; if (context->role == PeerRole::Identity && mode == TraversalMode::UnknownMode && peer_id && peer_id_size > 0) { [owner handleProvisionedIdentity:std::string(peer_id, peer_id_size) generation:context->generation]; } else if (context->role == PeerRole::Controller && stats && [owner isControllerGenerationActive:context->generation]) { [owner handleStats:stats mode:mode generation:context->generation]; } } Params MakeParams(const RTCState &state, const std::string &user_id, CallbackContext *context) { Params params{}; params.use_cfg_file = false; CopyCString(params.signal_server_ip, sizeof(params.signal_server_ip), state.signal_host); params.signal_server_port = state.signal_port; CopyCString(params.log_path, sizeof(params.log_path), state.log_path); // iOS H.264 is provided exclusively by VideoToolbox. params.hardware_acceleration = true; params.native_video_output = true; params.av1_encoding = false; params.turn_mode = TurnMode::TurnAutoUdpTcp; params.enable_srtp = true; params.video_content_type = VideoContentType::ScreenContent; params.video_quality = VideoQuality::QualityHigh; params.video_frame_rate = 60; params.video_degradation_preference = state.video_adaptation_policy; params.on_receive_video_buffer = nullptr; params.on_receive_audio_buffer = OnAudioBuffer; params.on_receive_data_buffer = OnDataBuffer; params.on_receive_video_frame = OnVideoFrame; params.on_signal_status = OnSignalState; params.on_signal_message = OnSignalMessage; params.on_connection_status = OnConnectionState; params.on_net_status_report = OnNetworkStats; params.user_id = user_id.c_str(); params.user_data = context; return params; } } // namespace @implementation CrossDeskRTCBridge { std::unique_ptr _state; dispatch_queue_t _rtcQueue; CVPixelBufferPoolRef _videoPool; size_t _videoPoolWidth; size_t _videoPoolHeight; std::mutex _videoPoolMutex; std::mutex _pendingVideoMutex; CVPixelBufferRef _pendingVideoBuffer; NSInteger _pendingVideoWidth; NSInteger _pendingVideoHeight; NSTimeInterval _pendingVideoCaptureUptime; bool _videoDeliveryScheduled; uint64_t _videoDeliveryGeneration; std::mutex _fileMutex; std::atomic _selectedDisplay; std::atomic _receivedVideoFrames; std::atomic _controllerGenerationCounter; std::atomic _activeControllerGeneration; std::atomic _identityGeneration; std::atomic _signalGeneration; std::atomic _presenceGeneration; std::atomic _networkingGeneration; } + (nullable NSString *)availableUpdateForAppVersion:(NSString *)appVersion releaseJSON:(NSData *)releaseJSON { const auto latest = crossdesk::ParseVersionInfoJSON(std::string_view( static_cast(releaseJSON.bytes), releaseJSON.length)); if (!latest) return nil; const auto update = crossdesk::AvailableAppUpdate(appVersion.UTF8String ?: "", *latest); return update ? [NSString stringWithUTF8String:update->c_str()] : nil; } + (nullable NSString *)checkMobileUpdateForAppVersion:(NSString *)appVersion releaseJSON:(NSData *)releaseJSON { if (releaseJSON.length == 0 || releaseJSON.length > 256 * 1024) return nil; const auto result = crossdesk::CheckPlatformAppUpdate( appVersion.UTF8String ?: "", std::string_view( static_cast(releaseJSON.bytes), releaseJSON.length), "ios-arm64"); return result ? [NSString stringWithUTF8String:result->c_str()] : nil; } - (instancetype)init { self = [super init]; if (self) { _state = std::make_unique(); _state->identity_context.owner = self; _state->identity_context.role = PeerRole::Identity; _state->controller_context.owner = self; _state->controller_context.role = PeerRole::Controller; _rtcQueue = dispatch_queue_create("cn.crossdesk.mobile.minirtc", DISPATCH_QUEUE_SERIAL); dispatch_queue_set_specific(_rtcQueue, &kRTCQueueSpecificKey, _state.get(), nullptr); _videoPool = nullptr; _videoPoolWidth = 0; _videoPoolHeight = 0; _pendingVideoBuffer = nullptr; _pendingVideoWidth = 0; _pendingVideoHeight = 0; _pendingVideoCaptureUptime = 0; _videoDeliveryScheduled = false; _videoDeliveryGeneration = 0; _selectedDisplay.store(0); _receivedVideoFrames.store(0); _controllerGenerationCounter.store(0); _activeControllerGeneration.store(0); _identityGeneration.store(0); _signalGeneration.store(0); _presenceGeneration.store(0); _networkingGeneration.store(0); } return self; } - (void)dealloc { _activeControllerGeneration.store(0); _state->identity_context.owner = nil; _state->controller_context.owner = nil; if (dispatch_get_specific(&kRTCQueueSpecificKey) == _state.get()) { [self destroyControllerPeer]; [self destroyIdentityPeer]; } else { dispatch_sync(_rtcQueue, ^{ [self destroyControllerPeer]; [self destroyIdentityPeer]; }); } [self resetVideoDelivery]; std::lock_guard lock(_videoPoolMutex); if (_videoPool) { CVPixelBufferPoolRelease(_videoPool); _videoPool = nullptr; } } - (void)configureWithSignalHost:(NSString *)host signalPort:(NSInteger)signalPort { NSString *trimmed = [host stringByTrimmingCharactersInSet: NSCharacterSet.whitespaceAndNewlineCharacterSet]; const char *host_c_string = trimmed.UTF8String; const std::string host_value = host_c_string ? host_c_string : ""; if (host_value.empty() || signalPort <= 0) return; NSString *cache = NSSearchPathForDirectoriesInDomains( NSCachesDirectory, NSUserDomainMask, YES) .firstObject; NSString *logDirectory = [cache stringByAppendingPathComponent: @"CrossDeskMobile/MiniRTC"]; [[NSFileManager defaultManager] createDirectoryAtPath:logDirectory withIntermediateDirectories:YES attributes:nil error:nil]; const char *log_c_string = logDirectory.UTF8String; const std::string log_path = log_c_string ? log_c_string : "logs"; const uint64_t networking_generation = _networkingGeneration.load(); dispatch_async(_rtcQueue, ^{ if (networking_generation != self->_networkingGeneration.load()) return; const bool unchanged = self->_state->signal_host == host_value && self->_state->signal_port == signalPort && self->_state->identity_peer != nullptr; const OSStatus migration_status = MigrateLegacyIdentityCredentials(); [self reportIdentityStorageStatus:migration_status]; if (unchanged) { if (IsReusableIdentity(self->_state->identity_with_password)) { NSString *value = [NSString stringWithUTF8String:self->_state->identity_with_password.c_str()]; [self reportIdentityStorageStatus:SaveIdentityCredential( IdentityCredentialAccount(trimmed, signalPort), value)]; } return; } [self destroyControllerPeer]; [self destroyIdentityPeer]; self->_state->signal_host = host_value; self->_state->signal_port = static_cast(signalPort); self->_state->log_path = log_path; self->_state->identity_ready = false; self->_state->identity_recovery_attempted = false; self->_state->identity_with_password.clear(); self->_state->identity_base.clear(); NSString *cached = nil; OSStatus load_status = LoadIdentityCredential( IdentityCredentialAccount(trimmed, signalPort), &cached); if (load_status == errSecSuccess) { self->_state->identity_with_password = cached.UTF8String; self->_state->identity_base = BaseIdentity(self->_state->identity_with_password); } else if (load_status != errSecItemNotFound) { [self reportIdentityStorageStatus:load_status]; // Locked/unavailable Keychain is not the same as a missing identity. [self handleSignalState:CrossDeskSignalStateCredentialUnavailable role:PeerRole::Identity generation:self->_identityGeneration.load()]; return; } [self createIdentityPeer]; if (load_status == errSecSuccess) { NSString *identifier = [NSString stringWithUTF8String:self->_state->identity_base.c_str()]; const uint64_t identity_generation = self->_identityGeneration.load(); DispatchMain(^{ if (networking_generation != self->_networkingGeneration.load() || identity_generation != self->_identityGeneration.load()) return; id delegate = self.delegate; if ([delegate respondsToSelector:@selector(rtcBridge:didProvisionIdentity:)]) { [delegate rtcBridge:self didProvisionIdentity:identifier]; } }); } }); } - (void)setVideoAdaptationPolicy:(CrossDeskVideoAdaptationPolicy)policy { dispatch_async(_rtcQueue, ^{ switch (policy) { case CrossDeskVideoAdaptationPolicyFrameRatePriority: self->_state->video_adaptation_policy = VideoDegradationPreference::MaintainFrameRate; break; case CrossDeskVideoAdaptationPolicyBalanced: self->_state->video_adaptation_policy = VideoDegradationPreference::Balanced; break; case CrossDeskVideoAdaptationPolicyQualityPriority: default: self->_state->video_adaptation_policy = VideoDegradationPreference::MaintainResolution; break; } }); } - (void)invalidatePresence { _presenceGeneration.fetch_add(1); } - (void)sendAnnouncementRequest:(NSData *)request requestID:(NSString *)requestID { NSData *payload = [request copy]; NSString *identifier = [requestID copy]; const uint64_t generation = _identityGeneration.load(); const uint64_t signal_generation = _signalGeneration.load(); const uint64_t networking_generation = _networkingGeneration.load(); dispatch_async(_rtcQueue, ^{ if (generation != self->_identityGeneration.load() || signal_generation != self->_signalGeneration.load() || networking_generation != self->_networkingGeneration.load()) return; if (self->_state->identity_peer && self->_state->identity_ready && !self->_state->identity_base.empty() && SendSignalMessage(self->_state->identity_peer, static_cast(payload.bytes), payload.length) == 0) return; DispatchMain(^{ if (generation != self->_identityGeneration.load() || signal_generation != self->_signalGeneration.load() || networking_generation != self->_networkingGeneration.load()) return; id delegate = self.delegate; if ([delegate respondsToSelector:@selector(rtcBridge:didFailAnnouncementRequest:)]) { [delegate rtcBridge:self didFailAnnouncementRequest:identifier]; } }); }); } - (void)sendVideoSettingsWithQuality:(NSInteger)quality frameRate:(NSInteger)frameRate preference:(NSInteger)preference requestID:(uint32_t)requestID { if (quality < 0 || quality > 2 || (frameRate != 30 && frameRate != 60) || preference < 0 || preference > 2) return; const uint64_t generation = _activeControllerGeneration.load(); dispatch_async(_rtcQueue, ^{ if (![self isControllerGenerationActive:generation] || !self->_state->controller_peer) return; RemoteAction action{}; action.type = ControlType::video_settings; action.vs = {static_cast(quality), static_cast(frameRate), static_cast(preference), requestID, false}; const auto message = action.to_json(); if (SendReliableDataFrame(self->_state->controller_peer, message.data(), message.size(), kControlStream) != 0) { [self notifyVideoSettings:action.vs generation:generation]; } }); } - (void)notifyVideoSettings:(crossdesk::VideoSettings)settings generation:(uint64_t)generation { DispatchMain(^{ if (![self isControllerGenerationActive:generation]) return; id delegate = self.delegate; if ([delegate respondsToSelector: @selector(rtcBridge:didReceiveVideoSettingsQuality:frameRate: preference:requestID:accepted:)]) { [delegate rtcBridge:self didReceiveVideoSettingsQuality:settings.quality frameRate:settings.frame_rate preference:settings.preference requestID:settings.request_id accepted:settings.accepted]; } }); } - (void)requestPresenceForRemoteIDs:(NSArray *)remoteIDs subscribe:(BOOL)subscribe { NSArray *owned_ids = [remoteIDs copy]; const uint64_t generation = _presenceGeneration.load(); dispatch_async(_rtcQueue, ^{ if (generation != self->_presenceGeneration.load() || !self->_state->identity_peer || !self->_state->identity_ready || self->_state->identity_base.empty()) { return; } NSMutableArray *devices = [NSMutableArray arrayWithCapacity:owned_ids.count]; for (NSString *remote_id in owned_ids) { if (![remote_id isKindOfClass:NSString.class]) continue; NSString *trimmed = [remote_id stringByTrimmingCharactersInSet: NSCharacterSet.whitespaceAndNewlineCharacterSet]; if (trimmed.length > 0) [devices addObject:trimmed]; } NSString *user_id = [NSString stringWithUTF8String:self->_state->identity_base.c_str()]; NSDictionary *request = @{ @"type" : @"recent_connections_presence", @"user_id" : user_id ?: @"", @"devices" : devices, @"subscribe" : @(subscribe), }; NSData *payload = [NSJSONSerialization dataWithJSONObject:request options:0 error:nil]; if (!payload) return; if (SendSignalMessage(self->_state->identity_peer, static_cast(payload.bytes), payload.length) == 0) { // Both old and new servers return snapshots in request order. Retain the // epoch so a pre-foreground response cannot satisfy a fresh query. self->_state->presence_requests.push_back({generation, bool(subscribe)}); } }); } - (void)connectToRemoteID:(NSString *)remoteID password:(NSString *)password { NSString *cleanID = [remoteID stringByTrimmingCharactersInSet:NSCharacterSet.whitespaceAndNewlineCharacterSet]; NSString *cleanPassword = [password stringByTrimmingCharactersInSet:NSCharacterSet.whitespaceAndNewlineCharacterSet]; const char *identifier_c_string = cleanID.UTF8String; const char *password_c_string = cleanPassword.UTF8String; const std::string identifier = identifier_c_string ? identifier_c_string : ""; const std::string password_value = password_c_string ? password_c_string : ""; if (identifier.empty()) return; // Invalidate queued callbacks immediately, before asynchronous teardown. A // canceled attempt for the same remote must not close a newly started one. const uint64_t generation = _controllerGenerationCounter.fetch_add(1) + 1; _activeControllerGeneration.store(0); dispatch_async(_rtcQueue, ^{ if (generation != self->_controllerGenerationCounter.load()) return; self->_state->pending_remote_id = identifier; self->_state->pending_controller_generation = generation; self->_state->pending_password = password_value; [self destroyControllerPeer]; if (self->_state->identity_ready && !self->_state->identity_base.empty()) { [self createControllerPeer]; } }); } - (void)disconnect { _controllerGenerationCounter.fetch_add(1); _activeControllerGeneration.store(0); dispatch_async(_rtcQueue, ^{ self->_state->pending_remote_id.clear(); self->_state->pending_password.clear(); [self destroyControllerPeer]; }); } - (void)stopNetworking { _networkingGeneration.fetch_add(1); _controllerGenerationCounter.fetch_add(1); _activeControllerGeneration.store(0); _identityGeneration.fetch_add(1); _signalGeneration.fetch_add(1); _presenceGeneration.fetch_add(1); dispatch_async(_rtcQueue, ^{ self->_state->pending_remote_id.clear(); self->_state->pending_password.clear(); [self destroyControllerPeer]; [self destroyIdentityPeer]; self->_state->signal_host.clear(); self->_state->identity_with_password.clear(); self->_state->identity_base.clear(); }); } - (void)requestKeyFrame { dispatch_async(_rtcQueue, ^{ if (self->_state->controller_peer) { const std::string stream = crossdesk::MakeDisplayStreamId( static_cast(self->_selectedDisplay.load())); RequestVideoKeyFrame(self->_state->controller_peer, stream.c_str()); } }); } - (void)sendPointerAtX:(float)x y:(float)y action:(CrossDeskPointerAction)action { const std::string message = MouseJSON(x, y, 0, static_cast(action)); // Keep movement, button transitions, and scrolling ordered on the reliable // mouse stream. #if DEBUG if (action != CrossDeskPointerActionMove) { NSLog(@"CrossDesk pointer action=%ld normalized=(%.5f, %.5f)", static_cast(action), x, y); } #endif [self sendMessage:message reliable:YES stream:kMouseStream]; } - (void)sendScrollX:(float)x y:(float)y deltaX:(NSInteger)deltaX deltaY:(NSInteger)deltaY { const bool vertical = std::abs(deltaY) >= std::abs(deltaX); const NSInteger raw_delta = vertical ? deltaY : deltaX; if (raw_delta == 0) return; const int flag = vertical ? 7 : 8; const int wheel = raw_delta > 0 ? 1 : -1; const std::string message = MouseJSON(x, y, wheel, flag); [self sendMessage:message reliable:YES stream:kMouseStream]; } - (void)sendWindowsKeyCode:(NSUInteger)keyCode isDown:(BOOL)isDown { [self sendMessage:KeyJSON(keyCode, isDown) reliable:YES stream:kKeyboardStream]; } - (void)sendText:(NSString *)text { NSData *utf8 = [text dataUsingEncoding:NSUTF8StringEncoding]; if (!utf8.length) return; const std::string bytes(static_cast(utf8.bytes), utf8.length); dispatch_async(_rtcQueue, ^{ if (!self->_state->controller_peer) return; for (unsigned char character : bytes) { KeyMapping mapping = MapASCII(character); if (!mapping.valid) continue; if (mapping.shift) { const std::string shift_down = KeyJSON(0x10, true); SendReliableDataFrame(self->_state->controller_peer, shift_down.data(), shift_down.size(), kKeyboardStream); } const std::string key_down = KeyJSON(mapping.key_code, true); const std::string key_up = KeyJSON(mapping.key_code, false); SendReliableDataFrame(self->_state->controller_peer, key_down.data(), key_down.size(), kKeyboardStream); SendReliableDataFrame(self->_state->controller_peer, key_up.data(), key_up.size(), kKeyboardStream); if (mapping.shift) { const std::string shift_up = KeyJSON(0x10, false); SendReliableDataFrame(self->_state->controller_peer, shift_up.data(), shift_up.size(), kKeyboardStream); } } }); } - (void)switchToDisplay:(NSInteger)displayIndex { if (displayIndex < 0 || displayIndex >= 8) return; [self resetVideoDelivery]; _selectedDisplay.store(static_cast(displayIndex)); _receivedVideoFrames.store(0); RemoteAction action{}; action.type = ControlType::display_id; action.d = static_cast(displayIndex); [self sendMessage:action.to_json() reliable:YES stream:kControlStream]; [self requestKeyFrame]; } - (void)setAudioEnabled:(BOOL)enabled { RemoteAction action{}; action.type = ControlType::audio_capture; action.a = enabled; [self sendMessage:action.to_json() reliable:YES stream:kControlStream]; } - (void)sendClipboardText:(NSString *)text { NSData *utf8 = [text dataUsingEncoding:NSUTF8StringEncoding]; if (!utf8.length || utf8.length > kMaxClipboardBytes) return; const std::string bytes(static_cast(utf8.bytes), utf8.length); [self sendMessage:bytes reliable:YES stream:kClipboardStream]; } - (void)sendFileAtURL:(NSURL *)fileURL { if (!fileURL.isFileURL) return; NSURL *url = [fileURL copy]; // Acquire the document picker's sandbox extension before its completion // callback returns; the actual reads happen on the serialized RTC queue. const BOOL scoped = [url startAccessingSecurityScopedResource]; dispatch_async(_rtcQueue, ^{ if (!self->_state->controller_peer) { if (scoped) [url stopAccessingSecurityScopedResource]; return; } const char *path_bytes = url.path.fileSystemRepresentation; FILE *input = path_bytes ? std::fopen(path_bytes, "rb") : nullptr; if (!input) { if (scoped) [url stopAccessingSecurityScopedResource]; return; } if (fseeko(input, 0, SEEK_END) != 0) { std::fclose(input); if (scoped) [url stopAccessingSecurityScopedResource]; return; } const off_t end = ftello(input); rewind(input); if (end < 0) { std::fclose(input); if (scoped) [url stopAccessingSecurityScopedResource]; return; } NSString *last_component = url.lastPathComponent.length > 0 ? url.lastPathComponent : @"file"; NSData *name_data = [last_component dataUsingEncoding:NSUTF8StringEncoding]; if (name_data.length == 0 || name_data.length > std::numeric_limits::max()) { std::fclose(input); if (scoped) [url stopAccessingSecurityScopedResource]; return; } const uint32_t file_id = g_next_file_id.fetch_add(1); const uint64_t total_size = static_cast(end); { std::lock_guard lock(self->_fileMutex); self->_state->outgoing_files[file_id] = { std::string(last_component.UTF8String ?: "file"), total_size}; } DispatchMain(^{ id delegate = self.delegate; if ([delegate respondsToSelector: @selector(rtcBridge:didUpdateFileTransfer:progress:sending:)]) { [delegate rtcBridge:self didUpdateFileTransfer:last_component progress:0 sending:YES]; } }); std::vector payload(kFileChunkSize); uint64_t offset = 0; bool first = true; int send_result = 0; do { const size_t to_read = static_cast( std::min(kFileChunkSize, total_size - offset)); const size_t bytes_read = to_read > 0 ? std::fread(payload.data(), 1, to_read, input) : 0; if (to_read > 0 && bytes_read == 0) { send_result = -1; break; } const bool last = offset + bytes_read >= total_size; const std::string file_name(last_component.UTF8String ?: "file"); const std::string* file_name_pointer = first ? &file_name : nullptr; std::vector chunk = crossdesk::EncodeFileChunk( file_id, offset, total_size, payload.data(), static_cast(bytes_read), file_name_pointer, first, last); if (chunk.empty()) { send_result = -1; break; } send_result = SendReliableDataFrame(self->_state->controller_peer, chunk.data(), chunk.size(), kFileStream); offset += bytes_read; first = false; if (send_result != 0) break; } while (offset < total_size); std::fclose(input); if (scoped) [url stopAccessingSecurityScopedResource]; if (send_result != 0) { { std::lock_guard lock(self->_fileMutex); self->_state->outgoing_files.erase(file_id); } DispatchMain(^{ id delegate = self.delegate; if ([delegate respondsToSelector: @selector(rtcBridge:didUpdateFileTransfer:progress:sending:)]) { [delegate rtcBridge:self didUpdateFileTransfer:last_component progress:-1 sending:YES]; } }); } }); } - (void)sendSecureAttentionSequence { RemoteAction action{}; action.type = ControlType::service_command; action.c.flag = ServiceCommandFlag::send_sas; [self sendMessage:action.to_json() reliable:YES stream:kControlStream]; } - (void)sendMessage:(const std::string &)message reliable:(BOOL)reliable stream:(const char *)stream { // This method always hops to the RTC queue. Own both values before the // caller's stack frame disappears; several callers pass local/temporary // std::strings. const std::string owned_message(message); const std::string owned_stream(stream ? stream : ""); dispatch_async(_rtcQueue, ^{ if (!self->_state->controller_peer) return; if (reliable) { SendReliableDataFrame(self->_state->controller_peer, owned_message.data(), owned_message.size(), owned_stream.c_str()); } else { SendDataFrame(self->_state->controller_peer, owned_message.data(), owned_message.size(), owned_stream.c_str()); } }); } - (void)reportIdentityStorageStatus:(OSStatus)status { const BOOL has_error = status != errSecSuccess; if (has_error) { // Log only the OSStatus, never an account, credential or Keychain payload. NSLog(@"CrossDesk identity Keychain operation failed (%d)", (int)status); } DispatchMain(^{ id delegate = self.delegate; if ([delegate respondsToSelector:@selector(rtcBridge:didChangeIdentityStorageError:)]) { [delegate rtcBridge:self didChangeIdentityStorageError:has_error]; } }); } - (void)createIdentityPeer { if (_state->identity_peer || _state->signal_host.empty()) return; _state->identity_context.generation = _identityGeneration.fetch_add(1) + 1; Params params = MakeParams(*_state, _state->identity_with_password, &_state->identity_context); _state->identity_peer = CreatePeer(¶ms); if (!_state->identity_peer || Init(_state->identity_peer) != 0) { [self destroyIdentityPeer]; [self handleSignalState:CrossDeskSignalStateFailed role:PeerRole::Identity generation:_identityGeneration.load()]; } } - (void)createControllerPeer { if (_state->controller_peer || _state->pending_remote_id.empty() || _state->identity_base.empty()) { return; } const uint64_t generation = _state->pending_controller_generation; if (generation == 0 || generation != _controllerGenerationCounter.load()) return; _state->controller_context.generation = generation; _activeControllerGeneration.store(generation); _state->controller_login = "C-" + _state->identity_base; Params params = MakeParams(*_state, _state->controller_login, &_state->controller_context); _state->controller_peer = CreatePeer(¶ms); if (!_state->controller_peer) { _activeControllerGeneration.store(0); [self handleConnectionState:CrossDeskConnectionStateFailed remoteID:_state->pending_remote_id generation:generation]; return; } // This native iOS peer is controller-only and does not publish a local // display. Remote display receivers are created from the host's SDP. AddAudioStream(_state->controller_peer, kAudioStream); AddDataStream(_state->controller_peer, kDataStream, false); AddDataStream(_state->controller_peer, kMouseStream, true); AddDataStream(_state->controller_peer, kKeyboardStream, true); AddDataStream(_state->controller_peer, kControlStream, true); AddDataStream(_state->controller_peer, kFileStream, true); AddDataStream(_state->controller_peer, kFileFeedbackStream, true); AddDataStream(_state->controller_peer, kClipboardStream, true); if (Init(_state->controller_peer) != 0) { [self destroyControllerPeer]; [self handleConnectionState:CrossDeskConnectionStateFailed remoteID:_state->pending_remote_id generation:generation]; return; } const std::string join_id = _state->pending_remote_id + "@" + _state->pending_password; JoinConnection(_state->controller_peer, join_id.c_str()); // Joining authenticates with "remote-id@password", while leaving is routed // by the remote peer ID alone (the same contract used by the desktop app). _state->transmission_id = _state->pending_remote_id; } - (void)destroyIdentityPeer { _identityGeneration.fetch_add(1); _presenceGeneration.fetch_add(1); _state->presence_requests.clear(); if (_state->identity_peer) { DestroyPeer(&_state->identity_peer); } _state->identity_ready = false; } - (void)destroyControllerPeer { _activeControllerGeneration.store(0); if (_state->controller_peer) { if (!_state->transmission_id.empty()) { LeaveConnection(_state->controller_peer, _state->transmission_id.c_str()); } DestroyPeer(&_state->controller_peer); } _state->controller_login.clear(); _state->transmission_id.clear(); { std::lock_guard lock(_fileMutex); for (auto &entry : _state->incoming_files) { if (entry.second.handle) std::fclose(entry.second.handle); } _state->incoming_files.clear(); _state->outgoing_files.clear(); } _selectedDisplay.store(0); _receivedVideoFrames.store(0); [self resetVideoDelivery]; } - (void)handleSignalState:(CrossDeskSignalState)state role:(PeerRole)role generation:(uint64_t)generation { // Only the long-lived identity peer drives presence and the home indicator. if (role != PeerRole::Identity || generation != _identityGeneration.load()) { return; } const uint64_t signal_generation = _signalGeneration.fetch_add(1) + 1; _presenceGeneration.fetch_add(1); dispatch_async(_rtcQueue, ^{ if (generation != self->_identityGeneration.load() || signal_generation != self->_signalGeneration.load()) return; self->_state->presence_requests.clear(); if (role == PeerRole::Identity) { self->_state->identity_ready = state == CrossDeskSignalStateConnected; if (self->_state->identity_ready && self->_state->identity_peer) { // The server records client_info on the persistent identity peer, // before any remote session starts and again after reconnecting. constexpr char client_info[] = "{\"type\":\"client_info\",\"version\":\"ios-native\"," "\"platform\":\"ios\"}"; SendSignalMessage(self->_state->identity_peer, client_info, sizeof(client_info) - 1); } if (state == CrossDeskSignalStateFailed && !self->_state->identity_with_password.empty() && !self->_state->identity_recovery_attempted) { // The saved credential can become invalid after a server-side reset or // an interrupted password change. Retry once with an empty identity so // the signaling server provisions a new credential. self->_state->identity_recovery_attempted = true; NSString *host = [NSString stringWithUTF8String: self->_state->signal_host.c_str()]; OSStatus status = RemoveIdentityCredential( IdentityCredentialAccount(host, self->_state->signal_port)); [self reportIdentityStorageStatus:status]; if (status != errSecSuccess) { [self destroyIdentityPeer]; [self handleSignalState:CrossDeskSignalStateCredentialUnavailable role:PeerRole::Identity generation:self->_identityGeneration.load()]; return; } [self destroyIdentityPeer]; self->_state->identity_with_password.clear(); self->_state->identity_base.clear(); [self createIdentityPeer]; } if (self->_state->identity_ready && !self->_state->identity_base.empty() && !self->_state->pending_remote_id.empty() && !self->_state->controller_peer) { [self createControllerPeer]; } } // The home-page signal indicator represents the long-lived identity peer. // A controller peer is created and destroyed for each session; forwarding // its shutdown would incorrectly make the server look disconnected. if (role == PeerRole::Identity) { DispatchMain(^{ if (generation != self->_identityGeneration.load() || signal_generation != self->_signalGeneration.load()) return; id delegate = self.delegate; if ([delegate respondsToSelector: @selector(rtcBridge:didChangeSignalState:)]) { [delegate rtcBridge:self didChangeSignalState:state]; } }); } }); } - (void)handleSignalMessage:(const char *)message size:(size_t)size role:(PeerRole)role generation:(uint64_t)generation { // Twenty 8 KiB bodies may expand substantially when JSON-escaped. constexpr size_t kMaxSignalMessageSize = 1024 * 1024; if (role != PeerRole::Identity || generation != _identityGeneration.load() || !message || size == 0 || size > kMaxSignalMessageSize) { return; } const uint64_t signal_generation = _signalGeneration.load(); const uint64_t presence_generation = _presenceGeneration.load(); NSData *payload = [NSData dataWithBytes:message length:size]; id decoded = [NSJSONSerialization JSONObjectWithData:payload options:0 error:nil]; if (![decoded isKindOfClass:NSDictionary.class]) return; NSDictionary *object = static_cast(decoded); NSString *type = object[@"type"]; if (![type isKindOfClass:NSString.class]) return; if ([type isEqualToString:@"announcements"] || [type isEqualToString:@"announcements_changed"]) { DispatchMain(^{ if (generation != self->_identityGeneration.load() || signal_generation != self->_signalGeneration.load()) return; id delegate = self.delegate; if ([delegate respondsToSelector:@selector(rtcBridge:didReceiveAnnouncementMessage:)]) { [delegate rtcBridge:self didReceiveAnnouncementMessage:payload]; } }); return; } NSMutableDictionary *presence = [NSMutableDictionary dictionary]; if ([type isEqualToString:@"presence"]) { NSArray *devices = object[@"devices"]; if (![devices isKindOfClass:NSArray.class]) return; for (id value in devices) { if (![value isKindOfClass:NSDictionary.class]) continue; NSDictionary *device = static_cast(value); NSString *remote_id = device[@"id"]; NSNumber *online = device[@"online"]; if ([remote_id isKindOfClass:NSString.class] && remote_id.length > 0 && [online isKindOfClass:NSNumber.class]) { presence[remote_id] = @([online boolValue]); } } } else if ([type isEqualToString:@"presence_update"]) { NSString *remote_id = object[@"id"]; NSNumber *online = object[@"online"]; if ([remote_id isKindOfClass:NSString.class] && remote_id.length > 0 && [online isKindOfClass:NSNumber.class]) { presence[remote_id] = @([online boolValue]); } } else { return; } NSDictionary *result = [presence copy]; const bool response = [type isEqualToString:@"presence"]; dispatch_async(_rtcQueue, ^{ if (generation != self->_identityGeneration.load() || signal_generation != self->_signalGeneration.load() || !self->_state->identity_ready) return; uint64_t delivery_generation = presence_generation; BOOL snapshot = NO; if (response) { if (self->_state->presence_requests.empty()) return; const auto request = self->_state->presence_requests.front(); self->_state->presence_requests.pop_front(); delivery_generation = request.generation; snapshot = request.subscription; } if (delivery_generation != self->_presenceGeneration.load()) return; DispatchMain(^{ if (generation != self->_identityGeneration.load() || signal_generation != self->_signalGeneration.load() || delivery_generation != self->_presenceGeneration.load()) return; id delegate = self.delegate; if ([delegate respondsToSelector: @selector(rtcBridge:didReceivePresence:snapshot:)]) { [delegate rtcBridge:self didReceivePresence:result snapshot:snapshot]; } }); }); } - (void)handleProvisionedIdentity:(const std::string &)identity generation:(uint64_t)generation { if (identity.empty()) return; const std::string identity_copy = identity; dispatch_async(_rtcQueue, ^{ if (generation != self->_identityGeneration.load()) return; const std::string credential = ProvisionedIdentityCredential( identity_copy, self->_state->identity_with_password); self->_state->identity_with_password = credential; self->_state->identity_base = BaseIdentity(credential); NSString *host = [NSString stringWithUTF8String: self->_state->signal_host.c_str()]; NSString *account = IdentityCredentialAccount(host, self->_state->signal_port); NSString *value = [NSString stringWithUTF8String:credential.c_str()]; OSStatus status = IsReusableIdentity(credential) ? SaveIdentityCredential(account, value) : RemoveIdentityCredential(account); [self reportIdentityStorageStatus:status]; // Only the public device ID crosses into UI state; keep its password here. NSString *identifier = [NSString stringWithUTF8String:self->_state->identity_base.c_str()]; DispatchMain(^{ if (generation != self->_identityGeneration.load()) return; id delegate = self.delegate; if ([delegate respondsToSelector:@selector(rtcBridge:didProvisionIdentity:)]) { [delegate rtcBridge:self didProvisionIdentity:identifier]; } }); }); } - (void)handleConnectionState:(CrossDeskConnectionState)state remoteID:(const std::string &)remoteID generation:(uint64_t)generation { if (![self isControllerGenerationCurrent:generation]) return; if (state != CrossDeskConnectionStateFailed && ![self isControllerGenerationActive:generation]) return; const std::string remote_copy = remoteID; if (state == CrossDeskConnectionStateConnected) { dispatch_async(_rtcQueue, ^{ if (![self isControllerGenerationActive:generation] || !self->_state->controller_peer) { return; } const std::string controller_name = self->_state->identity_base.empty() ? "iPhone" : "iPhone-" + self->_state->identity_base; RemoteAction action{}; action.type = ControlType::host_infomation; action.i.platform = crossdesk::HostPlatform::IOS; CopyCString(action.i.host_name, sizeof(action.i.host_name), controller_name); action.i.host_name_size = std::strlen(action.i.host_name); NSString *appVersion = [[NSBundle mainBundle] objectForInfoDictionaryKey:@"CFBundleShortVersionString"]; CopyCString(action.i.app_version, sizeof(action.i.app_version), appVersion.UTF8String ?: ""); action.i.display_list = nullptr; action.i.display_num = 0; action.i.left = nullptr; action.i.top = nullptr; action.i.right = nullptr; action.i.bottom = nullptr; const std::string host_info = action.to_json(); SendReliableDataFrame(self->_state->controller_peer, host_info.data(), host_info.size(), kControlStream); const std::string stream = crossdesk::MakeDisplayStreamId( static_cast(self->_selectedDisplay.load())); RequestVideoKeyFrame(self->_state->controller_peer, stream.c_str()); }); } DispatchMain(^{ if (![self isControllerGenerationCurrent:generation]) return; if (state != CrossDeskConnectionStateFailed && ![self isControllerGenerationActive:generation]) { return; } NSString *identifier = [NSString stringWithUTF8String:remote_copy.c_str()]; id delegate = self.delegate; if ([delegate respondsToSelector: @selector(rtcBridge:didChangeConnectionState:remoteID:)]) { [delegate rtcBridge:self didChangeConnectionState:state remoteID:identifier]; } }); } - (BOOL)isControllerGenerationActive:(uint64_t)generation { return generation != 0 && _controllerGenerationCounter.load() == generation && _activeControllerGeneration.load() == generation; } - (BOOL)isControllerGenerationCurrent:(uint64_t)generation { if (generation == 0 || _controllerGenerationCounter.load() != generation) { return NO; } const uint64_t active = _activeControllerGeneration.load(); return active == 0 || active == generation; } - (void)handleVideoFrame:(const MiniRtcVideoFrame *)frame sourceID:(const char *)sourceID sourceIDSize:(size_t)sourceIDSize { if (!frame || frame->width == 0 || frame->height == 0) return; if (sourceID && sourceIDSize > 0) { const std::string source(sourceID, sourceIDSize); const std::string expected = crossdesk::MakeDisplayStreamId( static_cast(_selectedDisplay.load())); if (source != expected) return; } // Translate the transport's calibrated capture time once, while the peer is // alive. Carry it with the exact pixels through coalescing and UI submission. const NSTimeInterval now = NSProcessInfo.processInfo.systemUptime; const int64_t peer_now = GetSystemTimeMicros(_state->controller_peer); NSTimeInterval capture_uptime = 0; if (frame->captured_timestamp > 0 && peer_now > 0 && frame->captured_timestamp <= static_cast(peer_now) && static_cast(peer_now) - frame->captured_timestamp <= 5'000'000) { capture_uptime = now - (static_cast(peer_now) - frame->captured_timestamp) / 1'000'000.0; } const MiniRtcNativeVideoFrame *native_frame = frame->native_frame; const bool has_native_pixel_buffer = native_frame && native_frame->struct_size >= static_cast(sizeof(MiniRtcNativeVideoFrame)) && native_frame->type == MiniRtcNativeVideoFrameCVPixelBuffer && native_frame->payload.cv_pixel_buffer; if (has_native_pixel_buffer) { CVPixelBufferRef pixel_buffer = static_cast( native_frame->payload.cv_pixel_buffer); [self enqueueVideoPixelBuffer:pixel_buffer width:frame->width height:frame->height captureUptime:capture_uptime]; return; } if (!frame->data) return; const size_t y_size = static_cast(frame->width) * frame->height; const size_t required_size = y_size + y_size / 2; if (frame->size < required_size) return; CVPixelBufferRef pixel_buffer = nullptr; CVReturn result = kCVReturnError; { std::lock_guard lock(_videoPoolMutex); if (!_videoPool || _videoPoolWidth != frame->width || _videoPoolHeight != frame->height) { if (_videoPool) CVPixelBufferPoolRelease(_videoPool); _videoPool = nullptr; _videoPoolWidth = frame->width; _videoPoolHeight = frame->height; NSDictionary *poolAttributes = @{ (NSString *)kCVPixelBufferPoolMinimumBufferCountKey : @3, }; NSDictionary *pixelAttributes = @{ (NSString *)kCVPixelBufferWidthKey : @(frame->width), (NSString *)kCVPixelBufferHeightKey : @(frame->height), (NSString *)kCVPixelBufferPixelFormatTypeKey : @(kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange), (NSString *)kCVPixelBufferIOSurfacePropertiesKey : @{}, (NSString *)kCVPixelBufferMetalCompatibilityKey : @YES, }; CVPixelBufferPoolCreate(kCFAllocatorDefault, (__bridge CFDictionaryRef)poolAttributes, (__bridge CFDictionaryRef)pixelAttributes, &_videoPool); } if (_videoPool) { result = CVPixelBufferPoolCreatePixelBuffer(kCFAllocatorDefault, _videoPool, &pixel_buffer); } } if (result != kCVReturnSuccess || !pixel_buffer) return; CVPixelBufferLockBaseAddress(pixel_buffer, 0); const uint8_t *source_y = reinterpret_cast(frame->data); const uint8_t *source_uv = source_y + y_size; uint8_t *destination_y = static_cast( CVPixelBufferGetBaseAddressOfPlane(pixel_buffer, 0)); uint8_t *destination_uv = static_cast( CVPixelBufferGetBaseAddressOfPlane(pixel_buffer, 1)); const size_t y_stride = CVPixelBufferGetBytesPerRowOfPlane(pixel_buffer, 0); const size_t uv_stride = CVPixelBufferGetBytesPerRowOfPlane(pixel_buffer, 1); for (size_t row = 0; row < frame->height; ++row) { std::memcpy(destination_y + row * y_stride, source_y + row * frame->width, frame->width); } for (size_t row = 0; row < frame->height / 2; ++row) { std::memcpy(destination_uv + row * uv_stride, source_uv + row * frame->width, frame->width); } CVPixelBufferUnlockBaseAddress(pixel_buffer, 0); CVBufferSetAttachment(pixel_buffer, kCVImageBufferYCbCrMatrixKey, kCVImageBufferYCbCrMatrix_ITU_R_601_4, kCVAttachmentMode_ShouldPropagate); const NSInteger frame_width = frame->width; const NSInteger frame_height = frame->height; if (_receivedVideoFrames.load() == 0) { uint8_t minimum_luma = std::numeric_limits::max(); uint8_t maximum_luma = std::numeric_limits::min(); uint64_t luma_total = 0; size_t luma_samples = 0; const size_t row_step = std::max(1, frame->height / 96); const size_t column_step = std::max(1, frame->width / 96); for (size_t row = 0; row < frame->height; row += row_step) { for (size_t column = 0; column < frame->width; column += column_step) { const uint8_t luma = source_y[row * frame->width + column]; minimum_luma = std::min(minimum_luma, luma); maximum_luma = std::max(maximum_luma, luma); luma_total += luma; ++luma_samples; } } const double average_luma = luma_samples ? static_cast(luma_total) / static_cast(luma_samples) : 0.0; NSLog(@"CrossDesk first-frame luma min=%u max=%u average=%.1f", minimum_luma, maximum_luma, average_luma); } [self enqueueVideoPixelBuffer:pixel_buffer width:frame_width height:frame_height captureUptime:capture_uptime]; CVPixelBufferRelease(pixel_buffer); } - (void)enqueueVideoPixelBuffer:(CVPixelBufferRef)pixelBuffer width:(NSInteger)width height:(NSInteger)height captureUptime:(NSTimeInterval)captureUptime { if (!pixelBuffer) return; CVPixelBufferRetain(pixelBuffer); bool should_schedule = false; uint64_t generation = 0; { std::lock_guard lock(_pendingVideoMutex); if (_pendingVideoBuffer) { CVPixelBufferRelease(_pendingVideoBuffer); } _pendingVideoBuffer = pixelBuffer; _pendingVideoWidth = width; _pendingVideoHeight = height; _pendingVideoCaptureUptime = captureUptime; generation = _videoDeliveryGeneration; if (!_videoDeliveryScheduled) { _videoDeliveryScheduled = true; should_schedule = true; } } if (!should_schedule) return; dispatch_async(dispatch_get_main_queue(), ^{ CVPixelBufferRef latest_buffer = nullptr; NSInteger latest_width = 0; NSInteger latest_height = 0; NSTimeInterval latest_capture_uptime = 0; { std::lock_guard lock(self->_pendingVideoMutex); if (generation != self->_videoDeliveryGeneration) return; latest_buffer = self->_pendingVideoBuffer; latest_width = self->_pendingVideoWidth; latest_height = self->_pendingVideoHeight; latest_capture_uptime = self->_pendingVideoCaptureUptime; self->_pendingVideoBuffer = nullptr; self->_pendingVideoWidth = 0; self->_pendingVideoHeight = 0; self->_pendingVideoCaptureUptime = 0; self->_videoDeliveryScheduled = false; } if (!latest_buffer) return; const uint64_t frame_count = ++self->_receivedVideoFrames; if (frame_count == 1 || frame_count % 300 == 0) { NSLog(@"CrossDesk delivered latest frame %llu (%ld x %ld)", static_cast(frame_count), (long)latest_width, (long)latest_height); } id delegate = self.delegate; if ([delegate respondsToSelector: @selector(rtcBridge:didReceivePixelBuffer:width:height:captureUptime:)]) { [delegate rtcBridge:self didReceivePixelBuffer:latest_buffer width:latest_width height:latest_height captureUptime:latest_capture_uptime]; } CVPixelBufferRelease(latest_buffer); }); } - (void)resetVideoDelivery { CVPixelBufferRef pending_buffer = nullptr; { std::lock_guard lock(_pendingVideoMutex); ++_videoDeliveryGeneration; pending_buffer = _pendingVideoBuffer; _pendingVideoBuffer = nullptr; _pendingVideoWidth = 0; _pendingVideoHeight = 0; _pendingVideoCaptureUptime = 0; _videoDeliveryScheduled = false; } if (pending_buffer) CVPixelBufferRelease(pending_buffer); } - (void)handleAudio:(const char *)data size:(size_t)size { if (!data || size < sizeof(int16_t) || size % sizeof(int16_t) != 0) return; NSData *pcm = [NSData dataWithBytes:data length:size]; DispatchMain(^{ id delegate = self.delegate; if ([delegate respondsToSelector: @selector(rtcBridge:didReceiveAudioPCM:)]) { [delegate rtcBridge:self didReceiveAudioPCM:pcm]; } }); } - (void)handleData:(const char *)data size:(size_t)size sourceID:(const char *)sourceID sourceIDSize:(size_t)sourceIDSize generation:(uint64_t)generation { if (!data || size == 0 || !sourceID) return; const std::string source(sourceID, sourceIDSize); if (source == kControlStream) { RemoteAction action{}; if (!action.from_json(std::string(data, size))) return; if (action.type == ControlType::video_settings_status) { [self notifyVideoSettings:action.vs generation:generation]; return; } if (action.type != ControlType::host_infomation) return; const BOOL supports_video_settings = action.i.supports_video_settings; NSString *platform = [NSString stringWithUTF8String: crossdesk::HostPlatformName(action.i.platform)]; NSString *host_name = [[NSString alloc] initWithBytes:action.i.host_name length:action.i.host_name_size encoding:NSUTF8StringEncoding]; NSString *app_version = crossdesk::IsValidAppVersion(action.i.app_version) ? [NSString stringWithUTF8String:action.i.app_version] : nil; NSMutableArray *display_names = [NSMutableArray arrayWithCapacity:action.i.display_num]; NSMutableArray *display_sizes = [NSMutableArray arrayWithCapacity:action.i.display_num]; for (std::size_t index = 0; index < action.i.display_num; ++index) { NSString *name = action.i.display_list && action.i.display_list[index] ? [NSString stringWithUTF8String:action.i.display_list[index]] : nil; [display_names addObject:name ?: @""]; const int width = action.i.left && action.i.right ? std::max(action.i.right[index] - action.i.left[index], 0) : 0; const int height = action.i.top && action.i.bottom ? std::max(action.i.bottom[index] - action.i.top[index], 0) : 0; [display_sizes addObject:[NSValue valueWithCGSize:CGSizeMake(width, height)]]; } crossdesk::FreeRemoteAction(action); DispatchMain(^{ if (![self isControllerGenerationActive:generation]) return; id delegate = self.delegate; if ([delegate respondsToSelector: @selector(rtcBridge:didReceiveHostName:appVersion:platform:displayNames: displaySizes:supportsVideoSettings:)]) { [delegate rtcBridge:self didReceiveHostName:host_name ?: @"" appVersion:app_version ?: @"" platform:platform displayNames:display_names displaySizes:display_sizes supportsVideoSettings:supports_video_settings]; } }); return; } if (source == kMouseStream) { RemoteAction action{}; if (!action.from_json(std::string(data, size)) || action.type != ControlType::cursor_state) { return; } const BOOL visible = action.cs.visible; const NSInteger shape = static_cast(action.cs.shape); const BOOL position_update = action.cs.position_update; const BOOL position_valid = action.cs.position_valid; const float position_x = action.cs.x; const float position_y = action.cs.y; const float visual_offset_x = action.cs.visual_offset_x; const float visual_offset_y = action.cs.visual_offset_y; const NSInteger display_index = action.cs.display_id; const uint32_t sequence = action.cs.seq; DispatchMain(^{ id delegate = self.delegate; if ([delegate respondsToSelector: @selector(rtcBridge:didReceiveCursorVisible:shape: positionUpdate:positionValid:x:y: visualOffsetX:visualOffsetY:displayIndex: sequence:)]) { [delegate rtcBridge:self didReceiveCursorVisible:visible shape:shape positionUpdate:position_update positionValid:position_valid x:position_x y:position_y visualOffsetX:visual_offset_x visualOffsetY:visual_offset_y displayIndex:display_index sequence:sequence]; } }); return; } if (source == kClipboardStream) { if (size > kMaxClipboardBytes || std::memchr(data, '\0', size)) return; NSString *text = [[NSString alloc] initWithBytes:data length:size encoding:NSUTF8StringEncoding]; if (!text) return; DispatchMain(^{ id delegate = self.delegate; if ([delegate respondsToSelector: @selector(rtcBridge:didReceiveClipboardText:)]) { [delegate rtcBridge:self didReceiveClipboardText:text]; } }); return; } if (source == kFileFeedbackStream) { FileTransferAck ack{}; if (!crossdesk::DecodeFileTransferAck(data, size, &ack)) return; NSString *name = nil; double progress = 0; { std::lock_guard lock(_fileMutex); auto it = _state->outgoing_files.find(ack.file_id); if (it == _state->outgoing_files.end()) return; name = [NSString stringWithUTF8String:it->second.name.c_str()]; progress = ack.total_size == 0 ? 1 : std::clamp(static_cast(ack.acked_offset) / static_cast(ack.total_size), 0.0, 1.0); if ((ack.flags & 0x03) != 0) { if ((ack.flags & 0x02) != 0) progress = -1; _state->outgoing_files.erase(it); } } DispatchMain(^{ id delegate = self.delegate; if ([delegate respondsToSelector: @selector(rtcBridge:didUpdateFileTransfer:progress:sending:)]) { [delegate rtcBridge:self didUpdateFileTransfer:name ?: @"file" progress:progress sending:YES]; } }); return; } if (source != kFileStream) return; crossdesk::FileChunkView chunk; if (!crossdesk::DecodeFileChunk(data, size, &chunk)) return; const crossdesk::FileChunkHeader& header = chunk.header; NSURL *completed_url = nil; NSString *progress_name = nil; double progress = 0; FileTransferAck ack{}; ack.magic = kFileAckMagic; ack.file_id = header.file_id; ack.acked_offset = header.offset; ack.total_size = header.total_size; { std::lock_guard lock(_fileMutex); auto it = _state->incoming_files.find(header.file_id); if (it == _state->incoming_files.end()) { if ((header.flags & 0x01) == 0) return; NSString *raw_name = !chunk.file_name.empty() ? [[NSString alloc] initWithBytes:chunk.file_name.data() length:chunk.file_name.size() encoding:NSUTF8StringEncoding] : nil; NSString *safe_name = raw_name.lastPathComponent; safe_name = [safe_name stringByReplacingOccurrencesOfString:@"\\" withString:@"_"]; safe_name = [safe_name stringByReplacingOccurrencesOfString:@":" withString:@"_"]; if (safe_name.length == 0 || [safe_name isEqualToString:@"."] || [safe_name isEqualToString:@".."]) { safe_name = [NSString stringWithFormat:@"received_%u", header.file_id]; } NSURL *documents = [[NSFileManager defaultManager] URLsForDirectory:NSDocumentDirectory inDomains:NSUserDomainMask].firstObject; NSURL *directory = [documents URLByAppendingPathComponent:@"Received" isDirectory:YES]; [[NSFileManager defaultManager] createDirectoryAtURL:directory withIntermediateDirectories:YES attributes:nil error:nil]; NSURL *target = [directory URLByAppendingPathComponent:safe_name]; if ([[NSFileManager defaultManager] fileExistsAtPath:target.path]) { NSString *stem = safe_name.stringByDeletingPathExtension; NSString *extension = safe_name.pathExtension; NSString *unique = extension.length > 0 ? [NSString stringWithFormat:@"%@_%u.%@", stem, header.file_id, extension] : [NSString stringWithFormat:@"%@_%u", stem, header.file_id]; target = [directory URLByAppendingPathComponent:unique]; safe_name = unique; } FILE *output = std::fopen(target.path.fileSystemRepresentation, "wb+"); if (!output) return; IncomingFile incoming; incoming.name = safe_name.UTF8String ?: "file"; incoming.path = target.path.fileSystemRepresentation ?: ""; incoming.total_size = header.total_size; incoming.handle = output; _state->incoming_files.emplace(header.file_id, std::move(incoming)); it = _state->incoming_files.find(header.file_id); } IncomingFile &incoming = it->second; bool write_ok = incoming.total_size == header.total_size && fseeko(incoming.handle, static_cast(header.offset), SEEK_SET) == 0; if (write_ok && chunk.payload_size > 0) { write_ok = std::fwrite(chunk.payload, 1, chunk.payload_size, incoming.handle) == chunk.payload_size; } if (!write_ok) { ack.flags |= 0x02; std::fclose(incoming.handle); _state->incoming_files.erase(it); } else { ack.acked_offset = header.offset + chunk.payload_size; incoming.received = std::max(incoming.received, ack.acked_offset); progress_name = [NSString stringWithUTF8String:incoming.name.c_str()]; progress = incoming.total_size == 0 ? 1 : std::clamp(static_cast(incoming.received) / static_cast(incoming.total_size), 0.0, 1.0); const bool completed = (header.flags & 0x02) != 0 || incoming.received >= incoming.total_size; if (completed) { ack.flags |= 0x01; std::fflush(incoming.handle); std::fclose(incoming.handle); completed_url = [NSURL fileURLWithPath: [NSString stringWithUTF8String:incoming.path.c_str()]]; _state->incoming_files.erase(it); } } } dispatch_async(_rtcQueue, ^{ if (self->_state->controller_peer) { const auto encoded_ack = crossdesk::EncodeFileTransferAck(ack); SendReliableDataFrame(self->_state->controller_peer, encoded_ack.data(), encoded_ack.size(), kFileFeedbackStream); } }); if (progress_name) { DispatchMain(^{ id delegate = self.delegate; if ([delegate respondsToSelector: @selector(rtcBridge:didUpdateFileTransfer:progress:sending:)]) { [delegate rtcBridge:self didUpdateFileTransfer:progress_name progress:progress sending:NO]; } if (completed_url && [delegate respondsToSelector: @selector(rtcBridge:didReceiveFileAtURL:)]) { [delegate rtcBridge:self didReceiveFileAtURL:completed_url]; } }); } } - (void)handleStats:(const MiniRtcNetTrafficStats *)stats mode:(TraversalMode)mode generation:(uint64_t)generation { if (!stats) return; const auto traffic = [](const MiniRtcInboundStats &inbound, const MiniRtcOutboundStats &outbound) { return CrossDeskTrafficStats{inbound.bitrate, outbound.bitrate, inbound.loss_rate}; }; const CrossDeskNetworkStats snapshot{ traffic(stats->video_inbound_stats, stats->video_outbound_stats), traffic(stats->audio_inbound_stats, stats->audio_outbound_stats), traffic(stats->data_inbound_stats, stats->data_outbound_stats), traffic(stats->total_inbound_stats, stats->total_outbound_stats), stats->rtt_ms, static_cast(stats->srtp_active), mode == TraversalMode::P2P ? CrossDeskTraversalModeDirect : mode == TraversalMode::Relay ? CrossDeskTraversalModeRelay : CrossDeskTraversalModeUnknown}; DispatchMain(^{ // Disconnect/reconnect can happen while this report waits on the UI queue. if (![self isControllerGenerationActive:generation]) return; id delegate = self.delegate; if ([delegate respondsToSelector: @selector(rtcBridge:didUpdateNetworkStats:)]) { [delegate rtcBridge:self didUpdateNetworkStats:snapshot]; } }); } @end