AccountPushCoordinator.swift (27682B)
1 import CloudKit 2 import CoreData 3 import CryptoKit 4 import Foundation 5 import Observation 6 7 /// Owns the account-scoped push credentials and worker registration that used 8 /// to live in `AppServices`: minting, caching, and rotating the account push 9 /// secret (the HMAC key per-game addresses derive from) and the account push 10 /// address, publishing both as `Decision` records so the account's devices 11 /// converge, adopting a sibling's inbound copies under version gating, and 12 /// keeping this device registered with the push worker for every shared game. 13 @MainActor 14 final class AccountPushCoordinator { 15 private static let accountPushAddressDefaultsPrefix = "push.accountAddress." 16 private static let accountPushSecretDefaultsPrefix = "push.accountSecret." 17 nonisolated private static let friendEncryptionKeyDefaultsPrefix = "push.friendEncryptionKey." 18 private static let publishedFriendEncryptionKeyDefaultsPrefix = 19 "push.friendEncryptionKeyPublished." 20 /// Generation of the locally-held push secret. Bumped on a deliberate 21 /// rotation and tracked so a stale inbound copy can't supersede the current 22 /// value (see `RecordSerializer.decisionVersion`). 23 private static let accountPushSecretVersionDefaultsPrefix = "push.accountSecretVersion." 24 static let accountJoinedPushKind = "accountJoined" 25 static let accountSeenPushKind = "accountSeen" 26 private static let accountSeenCoalesceWindow: TimeInterval = 30 27 28 private let identity: AuthorIdentity 29 private let preferences: PlayerPreferences 30 private let persistence: PersistenceController 31 private let store: GameStore 32 private let syncEngine: SyncEngine 33 private let syncMonitor: SyncMonitor 34 private let pushClient: PushClient? 35 36 private var preferenceObservationTask: Task<Void, Never>? 37 private var preferenceDebounceTask: Task<Void, Never>? 38 private var lastAccountSeenPresenceUntil: [UUID: Date] = [:] 39 /// Monotonic stamp for in-flight registration reconciles. A reconcile 40 /// snapshots store state, then suspends (player republish, friend-key 41 /// fetch) before handing the address set to the push client; overlapping 42 /// reconciles can therefore resume out of order, and an older snapshot 43 /// must not overwrite the fresher set a newer reconcile already pushed. 44 private var pushRegistrationGeneration = 0 45 46 init( 47 identity: AuthorIdentity, 48 preferences: PlayerPreferences, 49 persistence: PersistenceController, 50 store: GameStore, 51 syncEngine: SyncEngine, 52 syncMonitor: SyncMonitor, 53 pushClient: PushClient? 54 ) { 55 self.identity = identity 56 self.preferences = preferences 57 self.persistence = persistence 58 self.store = store 59 self.syncEngine = syncEngine 60 self.syncMonitor = syncMonitor 61 self.pushClient = pushClient 62 // Seed the worker denylist before the first registration (no-op until 63 // an APNs token arrives), then keep it mirrored as settings change. 64 pushClient?.setMutedKinds(currentMutedPushKinds()) 65 startObservingNotificationPreferences() 66 } 67 68 /// Maps the notification toggles to the worker `kind` denylist. The 69 /// "Completions" toggle covers both completion outcomes. 70 nonisolated static func mutedPushKinds( 71 nudges: Bool, 72 joins: Bool, 73 pauses: Bool, 74 completions: Bool, 75 invitations: Bool 76 ) -> Set<String> { 77 var muted: Set<String> = [] 78 if !nudges { muted.insert("nudge") } 79 if !joins { muted.insert("join") } 80 if !pauses { muted.insert("pause") } 81 if !completions { muted.formUnion(["win", "resign"]) } 82 if !invitations { muted.insert(PingKind.invite.rawValue) } 83 return muted 84 } 85 86 private func currentMutedPushKinds() -> Set<String> { 87 Self.mutedPushKinds( 88 nudges: preferences.notifiesNudges, 89 joins: preferences.notifiesJoins, 90 pauses: preferences.notifiesPauses, 91 completions: preferences.notifiesCompletions, 92 invitations: preferences.notifiesInvitations 93 ) 94 } 95 96 /// Re-registers with the worker when a notification toggle changes, so a 97 /// muted kind takes effect without waiting for the next launch. Debounced 98 /// like `PlayerNamePublisher` so a burst of toggle flips lands as one 99 /// registration; `PushClient` dedups unchanged sets anyway. 100 private func startObservingNotificationPreferences() { 101 preferenceObservationTask = Task { [weak self] in 102 guard let self else { return } 103 while !Task.isCancelled { 104 await withCheckedContinuation { (cont: CheckedContinuation<Void, Never>) in 105 withObservationTracking { 106 _ = self.preferences.notifiesNudges 107 _ = self.preferences.notifiesJoins 108 _ = self.preferences.notifiesPauses 109 _ = self.preferences.notifiesCompletions 110 _ = self.preferences.notifiesInvitations 111 } onChange: { 112 cont.resume() 113 } 114 } 115 guard !Task.isCancelled else { break } 116 self.preferenceDebounceTask?.cancel() 117 self.preferenceDebounceTask = Task { [weak self] in 118 do { 119 try await Task.sleep(for: .milliseconds(250)) 120 } catch { 121 return 122 } 123 guard let self, !Task.isCancelled else { return } 124 self.pushClient?.setMutedKinds(self.currentMutedPushKinds()) 125 } 126 } 127 } 128 } 129 130 /// Adopts an account push address learned from a sibling device's 131 /// `Decision` record, then re-reconciles so this device registers under 132 /// the converged address. 133 func adoptInboundPushAddress(_ address: String) async { 134 guard let authorID = identity.currentID, !authorID.isEmpty else { return } 135 cacheAccountPushAddress(address, authorID: authorID) 136 await reconcilePushRegistration() 137 } 138 139 /// Adopts an account push secret learned from a sibling device's 140 /// `Decision` record. Gate adoption on the generation: take a strictly 141 /// newer secret (a rotation), or an equal-generation one that differs 142 /// (the mint-race loser converging on the server's value). Ignore an 143 /// older copy so a late-arriving stale Decision can't undo a rotation. 144 /// Adopting changes every derived address, so reconcile re-derives, 145 /// rewrites the local Player rows, and re-registers the new set with the 146 /// worker. 147 func adoptInboundPushSecret(_ secret: String, version: Int64) async { 148 guard let authorID = identity.currentID, !authorID.isEmpty else { return } 149 let local = accountPushSecretVersion(authorID: authorID) 150 let current = UserDefaults.standard.string( 151 forKey: accountPushSecretDefaultsKey(authorID: authorID) 152 ) 153 guard version > local || (version == local && secret != current) else { return } 154 cacheAccountPushSecret(secret, version: version, authorID: authorID) 155 await reconcilePushRegistration() 156 } 157 158 /// Full reconciliation for lifecycle events that can add/remove games, 159 /// change the account credential, or require peer-visible address repair. 160 /// This stamps stale local Player rows with the derived per-game address, 161 /// then refreshes the worker registration. Friend-invite encryption keys 162 /// are minted only from friendship or invite-send paths. 163 func reconcilePushRegistration() async { 164 await updatePushRegistration(republishPlayerRows: true) 165 } 166 167 /// Refreshes the worker's address set without repairing every Player row or 168 /// minting friend-invite keys. Use this after a narrower caller has 169 /// already made the specific local mutation it needs, such as puzzle open 170 /// stamping the current game's Player row inside its send burst. 171 func refreshPushRegistration() async { 172 await updatePushRegistration(republishPlayerRows: false) 173 } 174 175 private func updatePushRegistration(republishPlayerRows: Bool) async { 176 guard let pushClient, preferences.isICloudSyncEnabled else { return } 177 guard let authorID = identity.currentID, !authorID.isEmpty else { return } 178 pushRegistrationGeneration += 1 179 let generation = pushRegistrationGeneration 180 let accountAddress = ensureAccountPushAddress(authorID: authorID) 181 let secret = ensureAccountPushSecret(authorID: authorID) 182 let result = store.reconcileLocalPushAddresses( 183 authorID: authorID, 184 secret: secret, 185 republishPlayerRows: republishPlayerRows 186 ) 187 for gameID in result.republishGameIDs { 188 await syncEngine.enqueuePlayer( 189 gameID: gameID, 190 authorID: authorID, 191 reason: "pushAddress" 192 ) 193 } 194 // The account-scoped sibling address carries no game credential. 195 var bindings = Set(result.bindings) 196 bindings.insert(PushAddressBinding(address: accountAddress)) 197 bindings.formUnion(await friendEncryptionKeyBindings(localAuthorID: authorID)) 198 // A newer reconcile started while this one was suspended; its snapshot 199 // supersedes this one, so let its setAddresses stand. The Player-row 200 // stamping and republish above still hold — they mutate real state 201 // rather than racing on the registration snapshot. 202 guard generation == pushRegistrationGeneration else { return } 203 pushClient.setAddresses(bindings) 204 } 205 206 private func friendEncryptionKeyBindings(localAuthorID: String) async -> Set<PushAddressBinding> { 207 let friends = await friendEncryptionKeyTargets(localAuthorID: localAuthorID) 208 var bindings = Set<PushAddressBinding>() 209 for friend in friends { 210 guard let payload = existingFriendEncryptionKey(pairKey: friend.pairKey) else { continue } 211 bindings.insert(PushAddressBinding(address: payload.address)) 212 } 213 return bindings 214 } 215 216 func ensureFriendInvitationKeyPublished( 217 pairKey: String, 218 friendZoneID: CKRecordZone.ID, 219 friendZoneScope: DatabaseScope 220 ) async { 221 guard preferences.isICloudSyncEnabled else { return } 222 guard let authorID = identity.currentID, !authorID.isEmpty else { return } 223 let payload = ensureFriendEncryptionKey(pairKey: pairKey) 224 guard let encoded = payload.encodedString() else { return } 225 if shouldPublishFriendEncryptionKey(encoded, pairKey: pairKey) { 226 // Only record the publish if the decision was actually enqueued; a 227 // drop (engine not up yet) must stay unmarked so a later call 228 // retries instead of treating the key as already published. 229 let enqueued = await syncEngine.enqueueFriendDecision( 230 kind: RecordSerializer.encryptionKeyDecisionKind, 231 key: authorID, 232 payload: encoded, 233 friendZoneID: friendZoneID, 234 friendZoneScope: friendZoneScope 235 ) 236 if enqueued { 237 markFriendEncryptionKeyPublished(encoded, pairKey: pairKey) 238 } 239 } 240 await refreshPushRegistration() 241 } 242 243 private func friendEncryptionKeyTargets( 244 localAuthorID: String 245 ) async -> [(pairKey: String, zoneID: CKRecordZone.ID, scope: DatabaseScope)] { 246 let ctx = persistence.container.newBackgroundContext() 247 return await ctx.perform { 248 let req = NSFetchRequest<FriendEntity>(entityName: "FriendEntity") 249 req.predicate = NSPredicate(format: "isBlocked == NO") 250 return ((try? ctx.fetch(req)) ?? []).compactMap { friend in 251 guard let friendAuthorID = friend.authorID, 252 !friendAuthorID.isEmpty 253 else { return nil } 254 let pairKey = friend.pairKey ?? FriendZone.pairKey(localAuthorID, friendAuthorID) 255 guard FriendZone.outboxAccepted(pairKey: pairKey) else { return nil } 256 // The friend decrypts our invite pushes by reading our key from 257 // their inbox — our outbox (shared DB). 258 return ( 259 pairKey, 260 FriendZone.outboxZoneID(pairKey: pairKey, friendAuthorID: friendAuthorID), 261 .shared 262 ) 263 } 264 } 265 } 266 267 private func friendEncryptionKeyTarget( 268 pairKey: String, 269 localAuthorID: String 270 ) async -> (zoneID: CKRecordZone.ID, scope: DatabaseScope)? { 271 await friendEncryptionKeyTargets(localAuthorID: localAuthorID) 272 .first { $0.pairKey == pairKey } 273 .map { ($0.zoneID, $0.scope) } 274 } 275 276 private func ensureFriendEncryptionKey(pairKey: String) -> FriendEncryptionKeyPayload { 277 let key = Self.friendEncryptionKeyDefaultsPrefix + pairKey 278 if let existing = FriendEncryptionKeyPayload.decode(UserDefaults.standard.string(forKey: key)) { 279 return existing 280 } 281 guard let fresh = FriendEncryptionKeyPayload.fresh() else { 282 preconditionFailure("Unable to mint friend encryption key") 283 } 284 UserDefaults.standard.set(fresh.encodedString(), forKey: key) 285 return fresh 286 } 287 288 private func existingFriendEncryptionKey(pairKey: String) -> FriendEncryptionKeyPayload? { 289 FriendEncryptionKeyPayload.decode( 290 UserDefaults.standard.string(forKey: Self.friendEncryptionKeyDefaultsPrefix + pairKey) 291 ) 292 } 293 294 /// This device's own friend-channel key for `pairKey` — the key it publishes 295 /// to that friend (mirrored into the friend's `FriendEncryptionKeyDirectory` 296 /// under this account's authorID). The counterpart a recipient verifies a 297 /// live engagement frame against, so the sender signs with the same value. 298 /// Nil until the key has been minted for the pair. 299 nonisolated static func localFriendChannelKey(pairKey: String) -> SymmetricKey? { 300 FriendEncryptionKeyPayload.decode( 301 UserDefaults.standard.string(forKey: friendEncryptionKeyDefaultsPrefix + pairKey) 302 )?.symmetricKey 303 } 304 305 private func shouldPublishFriendEncryptionKey(_ encoded: String, pairKey: String) -> Bool { 306 UserDefaults.standard.string( 307 forKey: Self.publishedFriendEncryptionKeyDefaultsPrefix + pairKey 308 ) != encoded 309 } 310 311 private func markFriendEncryptionKeyPublished(_ encoded: String, pairKey: String) { 312 UserDefaults.standard.set( 313 encoded, 314 forKey: Self.publishedFriendEncryptionKeyDefaultsPrefix + pairKey 315 ) 316 } 317 318 /// Stamps `gameID`'s local Player row with the derived push address inside 319 /// the puzzle-open send burst, so it ships on the same Player-record write 320 /// as the read-cursor lease and display name. 321 @discardableResult 322 func setDerivedPushAddress(gameID: UUID, authorID: String) -> String? { 323 let secret = ensureAccountPushSecret(authorID: authorID) 324 return store.setPushAddress(gameID: gameID, authorID: authorID, secret: secret) 325 } 326 327 private func ensureAccountPushAddress(authorID: String) -> String { 328 let key = accountPushAddressDefaultsKey(authorID: authorID) 329 if let existing = UserDefaults.standard.string(forKey: key), !existing.isEmpty { 330 // Already minted and published (or learned from a sibling). The 331 // Decision write is durable across launches via CKSyncEngine's 332 // pending-change queue and convergence rides the LWW conflict 333 // callback, so there's nothing to re-assert here — re-publishing 334 // would stamp a fresh `createdAt` and re-upload the record on every 335 // `accountSeen`/reconcile for a value that never changes. 336 return existing 337 } 338 let address = "acct-\(UUID().uuidString)" 339 UserDefaults.standard.set(address, forKey: key) 340 publishAccountPushAddressDecision(address) 341 return address 342 } 343 344 private func cacheAccountPushAddress(_ address: String, authorID: String) { 345 guard !address.isEmpty else { return } 346 UserDefaults.standard.set(address, forKey: accountPushAddressDefaultsKey(authorID: authorID)) 347 } 348 349 private func accountPushAddressDefaultsKey(authorID: String) -> String { 350 Self.accountPushAddressDefaultsPrefix + authorID 351 } 352 353 /// Mints (if needed) the account-wide push secret — the HMAC key every 354 /// per-game push address is derived from. Converges across the account's own 355 /// devices through a `Decision` exactly like the account address; never sent 356 /// to peers or the worker, so only the account's devices can derive. A fresh 357 /// mint starts at `decisionBaseVersion`; a deliberate replacement goes 358 /// through `rotateAccountPushSecret`, which bumps the generation so it 359 /// supersedes the converged value. 360 /// 361 /// This secret derives the per-game push *addresses*; participation is now 362 /// enforced separately by the per-game push credential in the Game record 363 /// (`GamePushCredentials`), which the worker verifies before delivering a 364 /// game push. The account secret still matters — it stays inside the 365 /// account's private CloudKit database so only the account's own devices can 366 /// derive its addresses, and it backs the account-scoped sibling pushes 367 /// (accountJoined/accountSeen), which carry no game and remain gated by App 368 /// Attest alone. 369 private func ensureAccountPushSecret(authorID: String) -> String { 370 let key = accountPushSecretDefaultsKey(authorID: authorID) 371 if let existing = UserDefaults.standard.string(forKey: key), !existing.isEmpty { 372 return existing 373 } 374 let secret = Self.generatePushSecret() 375 let version = RecordSerializer.decisionBaseVersion 376 cacheAccountPushSecret(secret, version: version, authorID: authorID) 377 publishAccountPushSecretDecision(secret, version: version) 378 return secret 379 } 380 381 /// Rotates the account-wide push secret to a fresh value at the next 382 /// generation. The bumped version lets the new secret overwrite the existing 383 /// `Decision` (otherwise an equal-generation write converges back onto the 384 /// server's value); every one of the account's devices adopts it inbound and 385 /// re-derives its per-game addresses. Safe to call when nothing is minted yet 386 /// — it simply mints the first generation. 387 func rotateAccountPushSecret() { 388 guard let authorID = identity.currentID, !authorID.isEmpty else { return } 389 let secret = Self.generatePushSecret() 390 let version = accountPushSecretVersion(authorID: authorID) + 1 391 cacheAccountPushSecret(secret, version: version, authorID: authorID) 392 publishAccountPushSecretDecision(secret, version: version) 393 Task { @MainActor [weak self] in 394 await self?.reconcilePushRegistration() 395 } 396 } 397 398 private static func generatePushSecret() -> String { 399 var bytes = [UInt8](repeating: 0, count: 32) 400 let status = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes) 401 // A failed mint would otherwise produce the base64 of 32 zero bytes — 402 // a predictable HMAC key that converges durably across the account's 403 // devices. Crashing is preferable to publishing that. 404 precondition(status == errSecSuccess, "SecRandomCopyBytes failed: \(status)") 405 return Data(bytes).base64URLEncodedString() 406 } 407 408 private func cacheAccountPushSecret(_ secret: String, version: Int64, authorID: String) { 409 guard !secret.isEmpty else { return } 410 UserDefaults.standard.set(secret, forKey: accountPushSecretDefaultsKey(authorID: authorID)) 411 UserDefaults.standard.set(version, forKey: accountPushSecretVersionDefaultsKey(authorID: authorID)) 412 } 413 414 private func accountPushSecretDefaultsKey(authorID: String) -> String { 415 Self.accountPushSecretDefaultsPrefix + authorID 416 } 417 418 private func accountPushSecretVersionDefaultsKey(authorID: String) -> String { 419 Self.accountPushSecretVersionDefaultsPrefix + authorID 420 } 421 422 /// Generation of the locally-held push secret. Defaults to 423 /// `decisionBaseVersion` when a secret exists without a stored version (a 424 /// value cached by the pre-rotation build) and to one below that when no 425 /// secret is cached at all, so a first mint or any inbound copy supersedes it. 426 private func accountPushSecretVersion(authorID: String) -> Int64 { 427 let defaults = UserDefaults.standard 428 if let stored = defaults.object( 429 forKey: accountPushSecretVersionDefaultsKey(authorID: authorID) 430 ) as? NSNumber { 431 return stored.int64Value 432 } 433 let secret = defaults.string(forKey: accountPushSecretDefaultsKey(authorID: authorID)) 434 return (secret ?? "").isEmpty 435 ? RecordSerializer.decisionBaseVersion - 1 436 : RecordSerializer.decisionBaseVersion 437 } 438 439 /// True once both the account push secret and address are cached for this 440 /// account — i.e. `ensureAccountPushSecret`/`ensureAccountPushAddress` would 441 /// hit their early returns rather than mint. Used at startup to decide 442 /// whether a pre-reconcile fetch is needed to adopt a sibling's value first. 443 func hasCachedAccountPushCredentials(authorID: String) -> Bool { 444 let defaults = UserDefaults.standard 445 let secret = defaults.string(forKey: accountPushSecretDefaultsKey(authorID: authorID)) 446 let address = defaults.string(forKey: accountPushAddressDefaultsKey(authorID: authorID)) 447 return !(secret ?? "").isEmpty && !(address ?? "").isEmpty 448 } 449 450 private func publishAccountPushSecretDecision(_ secret: String, version: Int64) { 451 Task.detached { [syncEngine] in 452 await syncEngine.enqueueDecision( 453 kind: RecordSerializer.accountDecisionKind, 454 key: RecordSerializer.accountPushSecretDecisionKey, 455 payload: secret, 456 version: version 457 ) 458 } 459 } 460 461 private func publishAccountPushAddressDecision(_ address: String) { 462 // This can be reached from callbacks that SyncEngine invokes while a 463 // CKSyncEngine delegate method is still unwinding. Match the existing 464 // friend-accept pattern: do not use plain `Task {}`, which can inherit 465 // the current actor and re-enter before CloudKit's delegate guard clears. 466 Task.detached { [syncEngine] in 467 await syncEngine.enqueueDecision( 468 kind: RecordSerializer.accountDecisionKind, 469 key: RecordSerializer.accountPushAddressDecisionKey, 470 payload: address 471 ) 472 } 473 } 474 475 func publishAccountJoinedPush(gameID: UUID) async { 476 await publishAccountEvent(kind: Self.accountJoinedPushKind, gameID: gameID) 477 } 478 479 func publishInvitePush( 480 to friendAuthorID: String, 481 gameID: UUID, 482 puzzleTitle: String, 483 inviterName: String 484 ) async { 485 guard let pushClient else { 486 syncMonitor.note("push(invite): skipped (no pushClient)") 487 return 488 } 489 guard let authorID = identity.currentID, !authorID.isEmpty else { 490 syncMonitor.note("push(invite): skipped (no local identity)") 491 return 492 } 493 // The friend's directory entry only supplies their registered push 494 // address. The payload must be sealed with *our own* per-pair key: the 495 // friend's Notification Service Extension opens an invite push with the 496 // sender's key, looked up by `fromAuthorID` — i.e. the key we minted and 497 // mirrored into their directory under our authorID, not their own key. 498 guard let remote = FriendEncryptionKeyDirectory.payload(for: friendAuthorID) else { 499 syncMonitor.note("push(invite): skipped (no encryption key for \(friendAuthorID))") 500 return 501 } 502 let pairKey = FriendZone.pairKey(authorID, friendAuthorID) 503 if let target = await friendEncryptionKeyTarget(pairKey: pairKey, localAuthorID: authorID) { 504 await ensureFriendInvitationKeyPublished( 505 pairKey: pairKey, 506 friendZoneID: target.zoneID, 507 friendZoneScope: target.scope 508 ) 509 } 510 guard let key = ensureFriendEncryptionKey( 511 pairKey: pairKey 512 ).symmetricKey else { 513 syncMonitor.note("push(invite): skipped (no local encryption key for \(friendAuthorID))") 514 return 515 } 516 await pushClient.publish( 517 kind: PingKind.invite.rawValue, 518 gameID: gameID, 519 addressees: [ 520 PushClient.Addressee( 521 address: remote.address, 522 payload: PushPayload( 523 event: .invite, 524 puzzleTitle: puzzleTitle, 525 playerName: inviterName 526 ) 527 ) 528 ], 529 title: "Crossmate", 530 puzzleTitle: puzzleTitle, 531 gameCredentialed: false, 532 payloadKey: key, 533 collapseID: PushClient.gameCollapseID(gameID), 534 body: PushClient.genericAlertBody 535 ) 536 } 537 538 func publishAccountSeenPush(gameID: UUID, presenceUntil: Date) async { 539 guard let pushClient else { 540 syncMonitor.note("push(\(Self.accountSeenPushKind)): skipped (no pushClient)") 541 return 542 } 543 guard let authorID = identity.currentID, !authorID.isEmpty else { 544 syncMonitor.note("push(\(Self.accountSeenPushKind)): skipped (no authorID)") 545 return 546 } 547 if shouldCoalesceAccountSeen(gameID: gameID, presenceUntil: presenceUntil) { 548 syncMonitor.note( 549 "push(accountSeen): coalesced \(gameID.uuidString.prefix(8)) " + 550 "presenceUntil=\(presenceUntil.ISO8601Format())" 551 ) 552 return 553 } 554 let address = ensureAccountPushAddress(authorID: authorID) 555 lastAccountSeenPresenceUntil[gameID] = presenceUntil 556 await pushClient.publishAccountEvent( 557 kind: Self.accountSeenPushKind, 558 gameID: gameID, 559 address: address, 560 presenceUntil: presenceUntil 561 ) 562 } 563 564 private func shouldCoalesceAccountSeen(gameID: UUID, presenceUntil: Date) -> Bool { 565 guard let previous = lastAccountSeenPresenceUntil[gameID] else { return false } 566 return abs(presenceUntil.timeIntervalSince(previous)) <= Self.accountSeenCoalesceWindow 567 } 568 569 @discardableResult 570 private func publishAccountEvent(kind: String, gameID: UUID, presenceUntil: Date? = nil) async -> Bool { 571 guard let pushClient else { 572 syncMonitor.note("push(\(kind)): skipped (no pushClient)") 573 return false 574 } 575 guard let authorID = identity.currentID, !authorID.isEmpty else { 576 syncMonitor.note("push(\(kind)): skipped (no authorID)") 577 return false 578 } 579 let address = ensureAccountPushAddress(authorID: authorID) 580 await pushClient.publishAccountEvent( 581 kind: kind, 582 gameID: gameID, 583 address: address, 584 presenceUntil: presenceUntil 585 ) 586 return true 587 } 588 }