diff --git a/apps/ios/CrossDeskMobile/Bridge/CrossDeskRTCBridge.h b/apps/ios/CrossDeskMobile/Bridge/CrossDeskRTCBridge.h index 39cb84eb..b0a2ffd8 100644 --- a/apps/ios/CrossDeskMobile/Bridge/CrossDeskRTCBridge.h +++ b/apps/ios/CrossDeskMobile/Bridge/CrossDeskRTCBridge.h @@ -61,7 +61,8 @@ typedef NS_ENUM(NSInteger, CrossDeskVideoAdaptationPolicy) { displayNames:(NSArray *)displayNames displaySizes:(NSArray *)displaySizes; - (void)rtcBridge:(CrossDeskRTCBridge *)bridge - didReceivePresence:(NSDictionary *)presence; + didReceivePresence:(NSDictionary *)presence + snapshot:(BOOL)snapshot; - (void)rtcBridge:(CrossDeskRTCBridge *)bridge didReceiveCursorVisible:(BOOL)visible shape:(NSInteger)shape @@ -104,7 +105,9 @@ typedef NS_ENUM(NSInteger, CrossDeskVideoAdaptationPolicy) { - (void)setHardwareAccelerationEnabled:(BOOL)enabled; - (void)setVideoAdaptationPolicy:(CrossDeskVideoAdaptationPolicy)policy; - (void)requestPresenceForRemoteIDs:(NSArray *)remoteIDs - NS_SWIFT_NAME(requestPresence(remoteIDs:)); + subscribe:(BOOL)subscribe + NS_SWIFT_NAME(requestPresence(remoteIDs:subscribe:)); +- (void)invalidatePresence; - (void)connectToRemoteID:(NSString *)remoteID password:(NSString *)password; - (void)disconnect; diff --git a/apps/ios/CrossDeskMobile/Bridge/CrossDeskRTCBridge.mm b/apps/ios/CrossDeskMobile/Bridge/CrossDeskRTCBridge.mm index 26caacdb..12c86546 100644 --- a/apps/ios/CrossDeskMobile/Bridge/CrossDeskRTCBridge.mm +++ b/apps/ios/CrossDeskMobile/Bridge/CrossDeskRTCBridge.mm @@ -8,6 +8,7 @@ #include #include #include +#include #include #include #include @@ -62,6 +63,11 @@ struct CallbackContext { uint64_t generation = 0; }; +struct PresenceRequest { + uint64_t generation; + bool subscription; +}; + struct RTCState { PeerPtr *identity_peer = nullptr; PeerPtr *controller_peer = nullptr; @@ -75,6 +81,7 @@ struct RTCState { 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; @@ -192,11 +199,15 @@ void DispatchMain(dispatch_block_t block) { } // namespace @interface CrossDeskRTCBridge () -- (void)handleSignalState:(CrossDeskSignalState)state role:(PeerRole)role; +- (void)handleSignalState:(CrossDeskSignalState)state + role:(PeerRole)role + generation:(uint64_t)generation; - (void)handleSignalMessage:(const char *)message size:(size_t)size - role:(PeerRole)role; -- (void)handleProvisionedIdentity:(const std::string &)identity; + 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; @@ -271,7 +282,8 @@ void OnSignalState(SignalStatus status, const char *, size_t, void *user_data) { (context->role == PeerRole::Identity || [owner isControllerGenerationActive:context->generation])) { [owner handleSignalState:static_cast(status) - role:context->role]; + role:context->role + generation:context->generation]; } } @@ -279,7 +291,8 @@ 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]; + [owner handleSignalMessage:message size:size role:context->role + generation:context->generation]; } } @@ -305,7 +318,8 @@ void OnNetworkStats(const char *peer_id, size_t peer_id_size, 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)]; + [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]; @@ -370,6 +384,9 @@ Params MakeParams(const RTCState &state, const std::string &user_id, std::atomic _receivedVideoFrames; std::atomic _controllerGenerationCounter; std::atomic _activeControllerGeneration; + std::atomic _identityGeneration; + std::atomic _signalGeneration; + std::atomic _presenceGeneration; } - (instancetype)init { @@ -396,6 +413,9 @@ Params MakeParams(const RTCState &state, const std::string &user_id, _receivedVideoFrames.store(0); _controllerGenerationCounter.store(0); _activeControllerGeneration.store(0); + _identityGeneration.store(0); + _signalGeneration.store(0); + _presenceGeneration.store(0); } return self; } @@ -517,10 +537,17 @@ Params MakeParams(const RTCState &state, const std::string &user_id, }); } -- (void)requestPresenceForRemoteIDs:(NSArray *)remoteIDs { +- (void)invalidatePresence { + _presenceGeneration.fetch_add(1); +} + +- (void)requestPresenceForRemoteIDs:(NSArray *)remoteIDs + subscribe:(BOOL)subscribe { NSArray *owned_ids = [remoteIDs copy]; + const uint64_t generation = _presenceGeneration.load(); dispatch_async(_rtcQueue, ^{ - if (!self->_state->identity_peer || !self->_state->identity_ready || + if (generation != self->_presenceGeneration.load() || + !self->_state->identity_peer || !self->_state->identity_ready || self->_state->identity_base.empty()) { return; } @@ -540,14 +567,19 @@ Params MakeParams(const RTCState &state, const std::string &user_id, @"type" : @"recent_connections_presence", @"user_id" : user_id ?: @"", @"devices" : devices, + @"subscribe" : @(subscribe), }; NSData *payload = [NSJSONSerialization dataWithJSONObject:request options:0 error:nil]; if (!payload) return; - SendSignalMessage(self->_state->identity_peer, - static_cast(payload.bytes), - payload.length); + 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)}); + } }); } @@ -832,13 +864,15 @@ Params MakeParams(const RTCState &state, const std::string &user_id, - (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]; + role:PeerRole::Identity + generation:_identityGeneration.load()]; } } @@ -890,6 +924,9 @@ Params MakeParams(const RTCState &state, const std::string &user_id, } - (void)destroyIdentityPeer { + _identityGeneration.fetch_add(1); + _presenceGeneration.fetch_add(1); + _state->presence_requests.clear(); if (_state->identity_peer) { DestroyPeer(&_state->identity_peer); } @@ -920,8 +957,19 @@ Params MakeParams(const RTCState &state, const std::string &user_id, [self resetVideoDelivery]; } -- (void)handleSignalState:(CrossDeskSignalState)state role:(PeerRole)role { +- (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 (state == CrossDeskSignalStateFailed && @@ -954,6 +1002,8 @@ Params MakeParams(const RTCState &state, const std::string &user_id, // 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:)]) { @@ -966,13 +1016,18 @@ Params MakeParams(const RTCState &state, const std::string &user_id, - (void)handleSignalMessage:(const char *)message size:(size_t)size - role:(PeerRole)role { + role:(PeerRole)role + generation:(uint64_t)generation { constexpr size_t kMaxPresenceMessageSize = 256 * 1024; - if (role != PeerRole::Identity || !message || size == 0 || + if (role != PeerRole::Identity || generation != _identityGeneration.load() || + !message || size == 0 || size > kMaxPresenceMessageSize) { 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 @@ -1007,22 +1062,41 @@ Params MakeParams(const RTCState &state, const std::string &user_id, } else { return; } - if (presence.count == 0) return; - NSDictionary *result = [presence copy]; - DispatchMain(^{ - id delegate = self.delegate; - if ([delegate respondsToSelector: - @selector(rtcBridge:didReceivePresence:)]) { - [delegate rtcBridge:self didReceivePresence:result]; + 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 { +- (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; self->_state->identity_with_password = identity_copy; self->_state->identity_base = BaseIdentity(identity_copy); NSString *host = [NSString stringWithUTF8String: @@ -1038,6 +1112,7 @@ Params MakeParams(const RTCState &state, const std::string &user_id, [[NSUserDefaults standardUserDefaults] removeObjectForKey:key]; } DispatchMain(^{ + if (generation != self->_identityGeneration.load()) return; id delegate = self.delegate; if ([delegate respondsToSelector:@selector(rtcBridge:didProvisionIdentity:)]) { [delegate rtcBridge:self didProvisionIdentity:value]; diff --git a/apps/ios/CrossDeskMobile/ContentView.swift b/apps/ios/CrossDeskMobile/ContentView.swift index 38f470eb..87328e30 100644 --- a/apps/ios/CrossDeskMobile/ContentView.swift +++ b/apps/ios/CrossDeskMobile/ContentView.swift @@ -28,6 +28,8 @@ struct ContentView: View { .onChange(of: scenePhase) { phase in if phase == .active { session.refreshRecentConnectionPresenceAfterForeground() + } else if phase == .background { + session.suspendPresenceMonitoring() } } } diff --git a/apps/ios/CrossDeskMobile/RemoteSessionModel.swift b/apps/ios/CrossDeskMobile/RemoteSessionModel.swift index 609f8646..ba2542ae 100644 --- a/apps/ios/CrossDeskMobile/RemoteSessionModel.swift +++ b/apps/ios/CrossDeskMobile/RemoteSessionModel.swift @@ -409,6 +409,23 @@ final class RemoteSessionModel: NSObject, ObservableObject, CrossDeskRTCBridgeDe private var remoteCursorSequence: UInt32? private var pendingPresenceRemoteID: String? private var presenceProbeGeneration: UInt64 = 0 + private var signalConnected = false + private var presenceIsForeground = false + private var awaitingPresenceSnapshot = true + private var presenceUpdatedAt: [String: ContinuousClock.Instant] = [:] + private var lastPresenceRefreshAt: ContinuousClock.Instant? + private var presenceTimer: Timer? + private let presenceClock = ContinuousClock() + private static let presenceRefreshInterval: Duration = .seconds(30) + private static let presenceMaxAge: Duration = .seconds(60) + + private struct BridgeConfiguration: Equatable { + let host: String + let signalPort: Int + let turnPort: Int + let enableSRTP: Bool + } + private var bridgeConfiguration: BridgeConfiguration? var pixelBuffer: CVPixelBuffer? { videoFrame?.pixelBuffer } var frameSize: CGSize { videoFrame?.encodedSize ?? .zero } @@ -426,18 +443,38 @@ final class RemoteSessionModel: NSObject, ObservableObject, CrossDeskRTCBridgeDe configureBridge() } - func configureBridge() { + deinit { + presenceTimer?.invalidate() + } + + @discardableResult + func configureBridge() -> Bool { + let host = signalHost.trimmingCharacters(in: .whitespacesAndNewlines) guard let signal = Int(signalPort), let turn = Int(turnPort), - !signalHost.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + (1...65535).contains(signal), (1...65535).contains(turn), + !host.isEmpty else { signalStatus = "服务器配置无效" - return + return false + } + let configuration = BridgeConfiguration(host: host, signalPort: signal, + turnPort: turn, enableSRTP: enableSRTP) + if bridgeConfiguration != configuration { + bridgeConfiguration = configuration + signalConnected = false + signalStatus = "正在连接信令服务" + stopPresenceMonitoring() + invalidatePresence() + cancelPendingPresenceConnection() + } else if signalConnected { + signalStatus = "已连接服务器" } bridge.setHardwareAccelerationEnabled(videoCodecMode == .hardware) bridge.setVideoAdaptationPolicy(videoAdaptationPolicy.bridgeValue) - bridge.configure(withSignalHost: signalHost, + bridge.configure(withSignalHost: host, signalPort: signal, turnPort: turn, enableSRTP: enableSRTP) + return true } func connect(password: String, rememberPassword: Bool) { @@ -453,8 +490,11 @@ final class RemoteSessionModel: NSObject, ObservableObject, CrossDeskRTCBridgeDe return } remoteID = identifier - configureBridge() - if recentConnectionPresence[identifier] == true { + guard configureBridge() else { + connectionStatus = signalStatus + return + } + if isDeviceOnline(identifier) { beginRemoteConnection(identifier) } else { beginPresenceProbe(identifier) @@ -477,7 +517,7 @@ final class RemoteSessionModel: NSObject, ObservableObject, CrossDeskRTCBridgeDe } private func beginPresenceProbe(_ identifier: String) { - guard signalStatus == "已连接服务器" else { + guard signalConnected, presenceIsForeground else { showDeviceOffline() return } @@ -489,7 +529,7 @@ final class RemoteSessionModel: NSObject, ObservableObject, CrossDeskRTCBridgeDe isConnected = false sessionVisible = false connectionStatus = "正在确认设备状态…" - bridge.requestPresence(remoteIDs: [identifier]) + requestPresenceProbe(identifier) DispatchQueue.main.asyncAfter(deadline: .now() + 5) { [weak self] in guard let self, @@ -508,6 +548,21 @@ final class RemoteSessionModel: NSObject, ObservableObject, CrossDeskRTCBridgeDe pendingPresenceRemoteID = nil } + private func cancelPendingPresenceConnection() { + guard pendingPresenceRemoteID != nil else { return } + cancelPresenceProbe() + isConnecting = false + connectionStatus = "未连接" + } + + private func requestPresenceProbe(_ identifier: String) { + guard signalConnected, presenceIsForeground else { return } + // Older servers ignore subscribe=false and replace the watched list. + var identifiers = recentConnections.map(\.remoteID) + if !identifiers.contains(identifier) { identifiers.append(identifier) } + bridge.requestPresence(remoteIDs: identifiers, subscribe: false) + } + private func showDeviceOffline() { isConnecting = false isConnected = false @@ -569,29 +624,85 @@ final class RemoteSessionModel: NSObject, ObservableObject, CrossDeskRTCBridgeDe } func isRecentConnectionOnline(_ connection: RecentConnection) -> Bool { - recentConnectionPresence[connection.remoteID] == true + isDeviceOnline(connection.remoteID) + } + + private func isDeviceOnline(_ identifier: String) -> Bool { + guard signalConnected, presenceIsForeground, !awaitingPresenceSnapshot, + recentConnectionPresence[identifier] == true, + let updatedAt = presenceUpdatedAt[identifier] else { return false } + return updatedAt.duration(to: presenceClock.now) < Self.presenceMaxAge } private func refreshRecentConnectionPresence() { - bridge.requestPresence(remoteIDs: recentConnections.map(\.remoteID)) + guard signalConnected, presenceIsForeground else { return } + lastPresenceRefreshAt = presenceClock.now + bridge.requestPresence(remoteIDs: recentConnections.map(\.remoteID), + subscribe: true) + } + + private func invalidatePresence() { + bridge.invalidatePresence() + recentConnectionPresence = [:] + presenceUpdatedAt = [:] + awaitingPresenceSnapshot = true + lastPresenceRefreshAt = nil + } + + private func startPresenceMonitoring() { + guard signalConnected, presenceIsForeground, presenceTimer == nil else { return } + let timer = Timer(timeInterval: 1, repeats: true) { [weak self] _ in + self?.maintainPresence() + } + RunLoop.main.add(timer, forMode: .common) + presenceTimer = timer + } + + private func stopPresenceMonitoring() { + presenceTimer?.invalidate() + presenceTimer = nil + } + + private func maintainPresence() { + guard signalConnected, presenceIsForeground else { return } + let now = presenceClock.now + let expired = presenceUpdatedAt.compactMap { identifier, updatedAt in + updatedAt.duration(to: now) >= Self.presenceMaxAge ? identifier : nil + } + if !expired.isEmpty { + var updated = recentConnectionPresence + for identifier in expired { + updated.removeValue(forKey: identifier) + presenceUpdatedAt.removeValue(forKey: identifier) + } + recentConnectionPresence = updated + } + if lastPresenceRefreshAt.map({ $0.duration(to: now) >= Self.presenceRefreshInterval }) ?? true { + refreshRecentConnectionPresence() + } + } + + func suspendPresenceMonitoring() { + presenceIsForeground = false + stopPresenceMonitoring() + invalidatePresence() + cancelPendingPresenceConnection() } func refreshRecentConnectionPresenceAfterForeground() { - // iOS can suspend the process while it is in the background, so presence - // updates received during that time are missed. Treat cached values as - // unknown (and therefore offline, like the desktop client) until the - // signaling server returns a fresh snapshot. - recentConnectionPresence = [:] + presenceIsForeground = true + invalidatePresence() + startPresenceMonitoring() + refreshRecentConnectionPresence() if let pendingPresenceRemoteID { - bridge.requestPresence(remoteIDs: [pendingPresenceRemoteID]) - } else { - refreshRecentConnectionPresence() + requestPresenceProbe(pendingPresenceRemoteID) } } func removeRecentConnection(_ connection: RecentConnection) { recentConnections.removeAll { $0.remoteID == connection.remoteID } recentConnectionPresence.removeValue(forKey: connection.remoteID) + presenceUpdatedAt.removeValue(forKey: connection.remoteID) RecentConnectionStore.save(recentConnections) RecentConnectionStore.removeThumbnail(fileName: connection.thumbnailFileName) ConnectionCredentialStore.removePassword(for: connection.remoteID) @@ -621,7 +732,6 @@ final class RemoteSessionModel: NSObject, ObservableObject, CrossDeskRTCBridgeDe ) recentConnections.removeAll { $0.remoteID == activeRemoteID } recentConnections.insert(connection, at: 0) - recentConnectionPresence[activeRemoteID] = true RecentConnectionStore.save(recentConnections) refreshRecentConnectionPresence() } @@ -706,6 +816,8 @@ final class RemoteSessionModel: NSObject, ObservableObject, CrossDeskRTCBridgeDe func rtcBridge(_ bridge: CrossDeskRTCBridge, didChange state: CrossDeskSignalState) { + signalConnected = state.rawValue == 1 + invalidatePresence() switch state.rawValue { case 0: signalStatus = "正在连接信令服务" case 1: signalStatus = "已连接服务器" @@ -716,12 +828,15 @@ final class RemoteSessionModel: NSObject, ObservableObject, CrossDeskRTCBridgeDe case 6: signalStatus = "TLS 证书校验失败" default: signalStatus = "未知信令状态" } - if state.rawValue == 1 { + if signalConnected { + startPresenceMonitoring() + refreshRecentConnectionPresence() if let pendingPresenceRemoteID { - bridge.requestPresence(remoteIDs: [pendingPresenceRemoteID]) - } else { - refreshRecentConnectionPresence() + requestPresenceProbe(pendingPresenceRemoteID) } + } else { + stopPresenceMonitoring() + cancelPendingPresenceConnection() } } @@ -734,9 +849,6 @@ final class RemoteSessionModel: NSObject, ObservableObject, CrossDeskRTCBridgeDe case 0: connectionStatus = "正在建立远程连接" case 1: connectionStatus = "已连接" - if !remoteID.isEmpty { - recentConnectionPresence[remoteID] = true - } sessionVisible = true recordSuccessfulConnectionIfNeeded() audioPlayer.setEnabled(audioEnabled) @@ -760,6 +872,7 @@ final class RemoteSessionModel: NSObject, ObservableObject, CrossDeskRTCBridgeDe connectionStatus = "远程设备 ID 不存在" if !remoteID.isEmpty { recentConnectionPresence[remoteID] = false + presenceUpdatedAt.removeValue(forKey: remoteID) } sessionVisible = false AppOrientation.update(to: .portrait) @@ -767,6 +880,7 @@ final class RemoteSessionModel: NSObject, ObservableObject, CrossDeskRTCBridgeDe connectionStatus = "远程设备当前不可用" if !remoteID.isEmpty { recentConnectionPresence[remoteID] = false + presenceUpdatedAt.removeValue(forKey: remoteID) } sessionVisible = false AppOrientation.update(to: .portrait) @@ -776,18 +890,28 @@ final class RemoteSessionModel: NSObject, ObservableObject, CrossDeskRTCBridgeDe func rtcBridge(_ bridge: CrossDeskRTCBridge, didProvisionIdentity identity: String) { localIdentity = identity.split(separator: "@").first.map(String.init) ?? identity + refreshRecentConnectionPresence() if let pendingPresenceRemoteID { - bridge.requestPresence(remoteIDs: [pendingPresenceRemoteID]) - } else { - refreshRecentConnectionPresence() + requestPresenceProbe(pendingPresenceRemoteID) } } func rtcBridge(_ bridge: CrossDeskRTCBridge, - didReceivePresence presence: [String: NSNumber]) { - var updated = recentConnectionPresence - for (remoteID, online) in presence { + didReceivePresence presence: [String: NSNumber], + snapshot: Bool) { + guard signalConnected, presenceIsForeground, + snapshot || !awaitingPresenceSnapshot else { return } + var updated = snapshot ? [:] : recentConnectionPresence + if snapshot { + presenceUpdatedAt = [:] + awaitingPresenceSnapshot = false + } + let now = presenceClock.now + let watched = Set(recentConnections.map(\.remoteID)) + for (remoteID, online) in presence + where watched.contains(remoteID) || pendingPresenceRemoteID == remoteID { updated[remoteID] = online.boolValue + presenceUpdatedAt[remoteID] = now } recentConnectionPresence = updated