[fix] prevent stale device online status on iOS

This commit is contained in:
dijunkun
2026-09-07 20:57:30 +08:00
parent af729144fe
commit 19218a57f9
4 changed files with 261 additions and 57 deletions
@@ -61,7 +61,8 @@ typedef NS_ENUM(NSInteger, CrossDeskVideoAdaptationPolicy) {
displayNames:(NSArray<NSString *> *)displayNames
displaySizes:(NSArray<NSValue *> *)displaySizes;
- (void)rtcBridge:(CrossDeskRTCBridge *)bridge
didReceivePresence:(NSDictionary<NSString *, NSNumber *> *)presence;
didReceivePresence:(NSDictionary<NSString *, NSNumber *> *)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<NSString *> *)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;
@@ -8,6 +8,7 @@
#include <cmath>
#include <cstdio>
#include <cstring>
#include <deque>
#include <limits>
#include <memory>
#include <mutex>
@@ -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<PresenceRequest> 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<CrossDeskSignalState>(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<CallbackContext *>(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<uint64_t> _receivedVideoFrames;
std::atomic<uint64_t> _controllerGenerationCounter;
std::atomic<uint64_t> _activeControllerGeneration;
std::atomic<uint64_t> _identityGeneration;
std::atomic<uint64_t> _signalGeneration;
std::atomic<uint64_t> _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<NSString *> *)remoteIDs {
- (void)invalidatePresence {
_presenceGeneration.fetch_add(1);
}
- (void)requestPresenceForRemoteIDs:(NSArray<NSString *> *)remoteIDs
subscribe:(BOOL)subscribe {
NSArray<NSString *> *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<const char *>(payload.bytes),
payload.length);
if (SendSignalMessage(self->_state->identity_peer,
static_cast<const char *>(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(&params);
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<CrossDeskRTCBridgeDelegate> 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<NSString *, NSNumber *> *result = [presence copy];
DispatchMain(^{
id<CrossDeskRTCBridgeDelegate> 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<CrossDeskRTCBridgeDelegate> 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<CrossDeskRTCBridgeDelegate> delegate = self.delegate;
if ([delegate respondsToSelector:@selector(rtcBridge:didProvisionIdentity:)]) {
[delegate rtcBridge:self didProvisionIdentity:value];
@@ -28,6 +28,8 @@ struct ContentView: View {
.onChange(of: scenePhase) { phase in
if phase == .active {
session.refreshRecentConnectionPresenceAfterForeground()
} else if phase == .background {
session.suspendPresenceMonitoring()
}
}
}
+156 -32
View File
@@ -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