crossmate

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

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 }