AccountPushCoordinator.swift (17537B)
1 import Foundation 2 import Observation 3 4 /// Owns the account-scoped push credentials and worker registration that used 5 /// to live in `AppServices`: minting, caching, and rotating the account push 6 /// secret (the HMAC key per-game addresses derive from) and the account push 7 /// address, publishing both as `Decision` records so the account's devices 8 /// converge, adopting a sibling's inbound copies under version gating, and 9 /// keeping this device registered with the push worker for every shared game. 10 @MainActor 11 final class AccountPushCoordinator { 12 private static let accountPushAddressDefaultsPrefix = "push.accountAddress." 13 private static let accountPushSecretDefaultsPrefix = "push.accountSecret." 14 /// Generation of the locally-held push secret. Bumped on a deliberate 15 /// rotation and tracked so a stale inbound copy can't supersede the current 16 /// value (see `RecordSerializer.decisionVersion`). 17 private static let accountPushSecretVersionDefaultsPrefix = "push.accountSecretVersion." 18 static let accountJoinedPushKind = "accountJoined" 19 static let accountSeenPushKind = "accountSeen" 20 private static let accountSeenCoalesceWindow: TimeInterval = 30 21 22 private let identity: AuthorIdentity 23 private let preferences: PlayerPreferences 24 private let store: GameStore 25 private let syncEngine: SyncEngine 26 private let syncMonitor: SyncMonitor 27 private let pushClient: PushClient? 28 29 private var preferenceObservationTask: Task<Void, Never>? 30 private var preferenceDebounceTask: Task<Void, Never>? 31 private var lastAccountSeenReadAt: [UUID: Date] = [:] 32 33 init( 34 identity: AuthorIdentity, 35 preferences: PlayerPreferences, 36 store: GameStore, 37 syncEngine: SyncEngine, 38 syncMonitor: SyncMonitor, 39 pushClient: PushClient? 40 ) { 41 self.identity = identity 42 self.preferences = preferences 43 self.store = store 44 self.syncEngine = syncEngine 45 self.syncMonitor = syncMonitor 46 self.pushClient = pushClient 47 // Seed the worker denylist before the first registration (no-op until 48 // an APNs token arrives), then keep it mirrored as settings change. 49 pushClient?.setMutedKinds(currentMutedPushKinds()) 50 startObservingNotificationPreferences() 51 } 52 53 /// Maps the notification toggles to the worker `kind` denylist. The 54 /// "Completions" toggle covers both completion outcomes. 55 nonisolated static func mutedPushKinds( 56 nudges: Bool, 57 joins: Bool, 58 pauses: Bool, 59 completions: Bool 60 ) -> Set<String> { 61 var muted: Set<String> = [] 62 if !nudges { muted.insert("nudge") } 63 if !joins { muted.insert("join") } 64 if !pauses { muted.insert("pause") } 65 if !completions { muted.formUnion(["win", "resign"]) } 66 return muted 67 } 68 69 private func currentMutedPushKinds() -> Set<String> { 70 Self.mutedPushKinds( 71 nudges: preferences.notifiesNudges, 72 joins: preferences.notifiesJoins, 73 pauses: preferences.notifiesPauses, 74 completions: preferences.notifiesCompletions 75 ) 76 } 77 78 /// Re-registers with the worker when a notification toggle changes, so a 79 /// muted kind takes effect without waiting for the next launch. Debounced 80 /// like `PlayerNamePublisher` so a burst of toggle flips lands as one 81 /// registration; `PushClient` dedups unchanged sets anyway. 82 private func startObservingNotificationPreferences() { 83 preferenceObservationTask = Task { [weak self] in 84 guard let self else { return } 85 while !Task.isCancelled { 86 await withCheckedContinuation { (cont: CheckedContinuation<Void, Never>) in 87 withObservationTracking { 88 _ = self.preferences.notifiesNudges 89 _ = self.preferences.notifiesJoins 90 _ = self.preferences.notifiesPauses 91 _ = self.preferences.notifiesCompletions 92 } onChange: { 93 cont.resume() 94 } 95 } 96 guard !Task.isCancelled else { break } 97 self.preferenceDebounceTask?.cancel() 98 self.preferenceDebounceTask = Task { [weak self] in 99 do { 100 try await Task.sleep(for: .milliseconds(250)) 101 } catch { 102 return 103 } 104 guard let self, !Task.isCancelled else { return } 105 self.pushClient?.setMutedKinds(self.currentMutedPushKinds()) 106 } 107 } 108 } 109 } 110 111 /// Adopts an account push address learned from a sibling device's 112 /// `Decision` record, then re-reconciles so this device registers under 113 /// the converged address. 114 func adoptInboundPushAddress(_ address: String) async { 115 guard let authorID = identity.currentID, !authorID.isEmpty else { return } 116 cacheAccountPushAddress(address, authorID: authorID) 117 await reconcilePushRegistration() 118 } 119 120 /// Adopts an account push secret learned from a sibling device's 121 /// `Decision` record. Gate adoption on the generation: take a strictly 122 /// newer secret (a rotation), or an equal-generation one that differs 123 /// (the mint-race loser converging on the server's value). Ignore an 124 /// older copy so a late-arriving stale Decision can't undo a rotation. 125 /// Adopting changes every derived address, so reconcile re-derives, 126 /// rewrites the local Player rows, and re-registers the new set with the 127 /// worker. 128 func adoptInboundPushSecret(_ secret: String, version: Int64) async { 129 guard let authorID = identity.currentID, !authorID.isEmpty else { return } 130 let local = accountPushSecretVersion(authorID: authorID) 131 let current = UserDefaults.standard.string( 132 forKey: accountPushSecretDefaultsKey(authorID: authorID) 133 ) 134 guard version > local || (version == local && secret != current) else { return } 135 cacheAccountPushSecret(secret, version: version, authorID: authorID) 136 await reconcilePushRegistration() 137 } 138 139 /// Ensures this device is registered with the push worker under the 140 /// account's per-game push address for every shared game, minting and 141 /// publishing any address that doesn't exist yet so peers learn where to 142 /// reach this account. Deduped inside `PushClient`, so it's cheap to call 143 /// repeatedly — on launch (sync ready), when a shared game appears 144 /// (inbound Player records), and on account switch. This is what makes a 145 /// push reach *all* of the account's devices, not just the one a game was 146 /// opened on, and survive an APNs token rotation. 147 func reconcilePushRegistration() async { 148 guard let pushClient, preferences.isICloudSyncEnabled else { return } 149 guard let authorID = identity.currentID, !authorID.isEmpty else { return } 150 let accountAddress = ensureAccountPushAddress(authorID: authorID) 151 let secret = ensureAccountPushSecret(authorID: authorID) 152 let result = store.reconcileLocalPushAddresses(authorID: authorID, secret: secret) 153 for gameID in result.republishGameIDs { 154 await syncEngine.enqueuePlayer( 155 gameID: gameID, 156 authorID: authorID, 157 reason: "pushAddress" 158 ) 159 } 160 // The account-scoped sibling address carries no game credential. 161 var bindings = Set(result.bindings) 162 bindings.insert(PushAddressBinding(address: accountAddress)) 163 pushClient.setAddresses(bindings) 164 } 165 166 /// Stamps `gameID`'s local Player row with the derived push address inside 167 /// the puzzle-open send burst, so it ships on the same Player-record write 168 /// as the read-cursor lease and display name. 169 @discardableResult 170 func setDerivedPushAddress(gameID: UUID, authorID: String) -> String? { 171 let secret = ensureAccountPushSecret(authorID: authorID) 172 return store.setPushAddress(gameID: gameID, authorID: authorID, secret: secret) 173 } 174 175 private func ensureAccountPushAddress(authorID: String) -> String { 176 let key = accountPushAddressDefaultsKey(authorID: authorID) 177 if let existing = UserDefaults.standard.string(forKey: key), !existing.isEmpty { 178 // Already minted and published (or learned from a sibling). The 179 // Decision write is durable across launches via CKSyncEngine's 180 // pending-change queue and convergence rides the LWW conflict 181 // callback, so there's nothing to re-assert here — re-publishing 182 // would stamp a fresh `createdAt` and re-upload the record on every 183 // `accountSeen`/reconcile for a value that never changes. 184 return existing 185 } 186 let address = "acct-\(UUID().uuidString)" 187 UserDefaults.standard.set(address, forKey: key) 188 publishAccountPushAddressDecision(address) 189 return address 190 } 191 192 private func cacheAccountPushAddress(_ address: String, authorID: String) { 193 guard !address.isEmpty else { return } 194 UserDefaults.standard.set(address, forKey: accountPushAddressDefaultsKey(authorID: authorID)) 195 } 196 197 private func accountPushAddressDefaultsKey(authorID: String) -> String { 198 Self.accountPushAddressDefaultsPrefix + authorID 199 } 200 201 /// Mints (if needed) the account-wide push secret — the HMAC key every 202 /// per-game push address is derived from. Converges across the account's own 203 /// devices through a `Decision` exactly like the account address; never sent 204 /// to peers or the worker, so only the account's devices can derive. A fresh 205 /// mint starts at `decisionBaseVersion`; a deliberate replacement goes 206 /// through `rotateAccountPushSecret`, which bumps the generation so it 207 /// supersedes the converged value. 208 /// 209 /// This secret derives the per-game push *addresses*; participation is now 210 /// enforced separately by the per-game push credential in the Game record 211 /// (`GamePushCredentials`), which the worker verifies before delivering a 212 /// game push. The account secret still matters — it stays inside the 213 /// account's private CloudKit database so only the account's own devices can 214 /// derive its addresses, and it backs the account-scoped sibling pushes 215 /// (accountJoined/accountSeen), which carry no game and remain gated by App 216 /// Attest alone. 217 private func ensureAccountPushSecret(authorID: String) -> String { 218 let key = accountPushSecretDefaultsKey(authorID: authorID) 219 if let existing = UserDefaults.standard.string(forKey: key), !existing.isEmpty { 220 return existing 221 } 222 let secret = Self.generatePushSecret() 223 let version = RecordSerializer.decisionBaseVersion 224 cacheAccountPushSecret(secret, version: version, authorID: authorID) 225 publishAccountPushSecretDecision(secret, version: version) 226 return secret 227 } 228 229 /// Rotates the account-wide push secret to a fresh value at the next 230 /// generation. The bumped version lets the new secret overwrite the existing 231 /// `Decision` (otherwise an equal-generation write converges back onto the 232 /// server's value); every one of the account's devices adopts it inbound and 233 /// re-derives its per-game addresses. Safe to call when nothing is minted yet 234 /// — it simply mints the first generation. 235 func rotateAccountPushSecret() { 236 guard let authorID = identity.currentID, !authorID.isEmpty else { return } 237 let secret = Self.generatePushSecret() 238 let version = accountPushSecretVersion(authorID: authorID) + 1 239 cacheAccountPushSecret(secret, version: version, authorID: authorID) 240 publishAccountPushSecretDecision(secret, version: version) 241 Task { @MainActor [weak self] in 242 await self?.reconcilePushRegistration() 243 } 244 } 245 246 private static func generatePushSecret() -> String { 247 var bytes = [UInt8](repeating: 0, count: 32) 248 let status = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes) 249 // A failed mint would otherwise produce the base64 of 32 zero bytes — 250 // a predictable HMAC key that converges durably across the account's 251 // devices. Crashing is preferable to publishing that. 252 precondition(status == errSecSuccess, "SecRandomCopyBytes failed: \(status)") 253 return Data(bytes).base64URLEncodedString() 254 } 255 256 private func cacheAccountPushSecret(_ secret: String, version: Int64, authorID: String) { 257 guard !secret.isEmpty else { return } 258 UserDefaults.standard.set(secret, forKey: accountPushSecretDefaultsKey(authorID: authorID)) 259 UserDefaults.standard.set(version, forKey: accountPushSecretVersionDefaultsKey(authorID: authorID)) 260 } 261 262 private func accountPushSecretDefaultsKey(authorID: String) -> String { 263 Self.accountPushSecretDefaultsPrefix + authorID 264 } 265 266 private func accountPushSecretVersionDefaultsKey(authorID: String) -> String { 267 Self.accountPushSecretVersionDefaultsPrefix + authorID 268 } 269 270 /// Generation of the locally-held push secret. Defaults to 271 /// `decisionBaseVersion` when a secret exists without a stored version (a 272 /// value cached by the pre-rotation build) and to one below that when no 273 /// secret is cached at all, so a first mint or any inbound copy supersedes it. 274 private func accountPushSecretVersion(authorID: String) -> Int64 { 275 let defaults = UserDefaults.standard 276 if let stored = defaults.object( 277 forKey: accountPushSecretVersionDefaultsKey(authorID: authorID) 278 ) as? NSNumber { 279 return stored.int64Value 280 } 281 let secret = defaults.string(forKey: accountPushSecretDefaultsKey(authorID: authorID)) 282 return (secret ?? "").isEmpty 283 ? RecordSerializer.decisionBaseVersion - 1 284 : RecordSerializer.decisionBaseVersion 285 } 286 287 /// True once both the account push secret and address are cached for this 288 /// account — i.e. `ensureAccountPushSecret`/`ensureAccountPushAddress` would 289 /// hit their early returns rather than mint. Used at startup to decide 290 /// whether a pre-reconcile fetch is needed to adopt a sibling's value first. 291 func hasCachedAccountPushCredentials(authorID: String) -> Bool { 292 let defaults = UserDefaults.standard 293 let secret = defaults.string(forKey: accountPushSecretDefaultsKey(authorID: authorID)) 294 let address = defaults.string(forKey: accountPushAddressDefaultsKey(authorID: authorID)) 295 return !(secret ?? "").isEmpty && !(address ?? "").isEmpty 296 } 297 298 private func publishAccountPushSecretDecision(_ secret: String, version: Int64) { 299 Task.detached { [syncEngine] in 300 await syncEngine.enqueueDecision( 301 kind: RecordSerializer.accountDecisionKind, 302 key: RecordSerializer.accountPushSecretDecisionKey, 303 payload: secret, 304 version: version 305 ) 306 } 307 } 308 309 private func publishAccountPushAddressDecision(_ address: String) { 310 // This can be reached from callbacks that SyncEngine invokes while a 311 // CKSyncEngine delegate method is still unwinding. Match the existing 312 // friend-accept pattern: do not use plain `Task {}`, which can inherit 313 // the current actor and re-enter before CloudKit's delegate guard clears. 314 Task.detached { [syncEngine] in 315 await syncEngine.enqueueDecision( 316 kind: RecordSerializer.accountDecisionKind, 317 key: RecordSerializer.accountPushAddressDecisionKey, 318 payload: address 319 ) 320 } 321 } 322 323 func publishAccountJoinedPush(gameID: UUID) async { 324 await publishAccountEvent(kind: Self.accountJoinedPushKind, gameID: gameID) 325 } 326 327 func publishAccountSeenPush(gameID: UUID, readAt: Date) async { 328 if shouldCoalesceAccountSeen(gameID: gameID, readAt: readAt) { 329 syncMonitor.note( 330 "push(accountSeen): coalesced \(gameID.uuidString.prefix(8)) " + 331 "readAt=\(readAt.ISO8601Format())" 332 ) 333 return 334 } 335 if await publishAccountEvent(kind: Self.accountSeenPushKind, gameID: gameID, readAt: readAt) { 336 lastAccountSeenReadAt[gameID] = readAt 337 } 338 } 339 340 private func shouldCoalesceAccountSeen(gameID: UUID, readAt: Date) -> Bool { 341 guard let previous = lastAccountSeenReadAt[gameID] else { return false } 342 return abs(readAt.timeIntervalSince(previous)) <= Self.accountSeenCoalesceWindow 343 } 344 345 @discardableResult 346 private func publishAccountEvent(kind: String, gameID: UUID, readAt: Date? = nil) async -> Bool { 347 guard let pushClient else { 348 syncMonitor.note("push(\(kind)): skipped (no pushClient)") 349 return false 350 } 351 guard let authorID = identity.currentID, !authorID.isEmpty else { 352 syncMonitor.note("push(\(kind)): skipped (no authorID)") 353 return false 354 } 355 let address = ensureAccountPushAddress(authorID: authorID) 356 await pushClient.publishAccountEvent( 357 kind: kind, 358 gameID: gameID, 359 address: address, 360 readAt: readAt 361 ) 362 return true 363 } 364 }