crossmate

A collaborative crossword app for iOS
Log | Files | Refs | LICENSE

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 }