crossmate

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

AppServices.swift (104632B)


      1 import CloudKit
      2 import CoreData
      3 import Foundation
      4 import UIKit
      5 import UserNotifications
      6 
      7 /// Fills a throwaway in-memory store with a handful of shared, in-progress
      8 /// games and crossmates so the Game List colour strips and the friends list can
      9 /// be inspected in the Simulator without an iCloud account or a real opponent.
     10 /// Driven solely by the `--crossmate-seed-demo` launch argument; a normal
     11 /// launch never reaches this. The same crossmate authorIDs are reused across
     12 /// both games on purpose, so one friend visibly takes a *different* colour in
     13 /// each game — the per-game colour derivation made visible.
     14 enum DemoSeed {
     15     private static let puzzleSource = """
     16     Title: Demo Puzzle
     17     Author: Crossmate
     18 
     19 
     20     ABC
     21     D#E
     22     FGH
     23 
     24 
     25     A1. Across 1 ~ ABC
     26     A4. Across 4 ~ DE
     27     A5. Across 5 ~ FGH
     28     D1. Down 1 ~ ADF
     29     D2. Down 2 ~ BG
     30     D3. Down 3 ~ CEH
     31     """
     32 
     33     /// A bundled 15×15 used for the one demo game seeded with letters already
     34     /// filled in, so the faint filled-cell author-attribution tints can be
     35     /// compared in a realistic grid rather than a 3×3 toy.
     36     private static let filledPuzzleResource = "cm-starter-0001"
     37 
     38     /// The local user's authorID in demo mode, injected into `AuthorIdentity`
     39     /// so the seeded crossmates classify as remote players. Kept distinct from
     40     /// every crossmate id below.
     41     static let localAuthorID = "_demo-you"
     42 
     43     private static let crossmates: [(id: String, name: String)] = [
     44         ("_demo-alice", "Alice"),
     45         ("_demo-bob", "Bob"),
     46         ("_demo-carol", "Carol"),
     47     ]
     48 
     49     @MainActor
     50     static func populate(persistence: PersistenceController, preferences: PlayerPreferences) {
     51         let ctx = persistence.viewContext
     52 
     53         // Keep the Game List out of its "set your profile name" empty state.
     54         if !preferences.hasName {
     55             preferences.name = "You"
     56         }
     57 
     58         guard let xd = try? XD.parse(puzzleSource) else { return }
     59         let puzzle = Puzzle(xd: xd)
     60 
     61         for crossmate in crossmates {
     62             seedFriend(crossmate, in: ctx)
     63         }
     64 
     65         seedGame(
     66             title: "Tuesday Mini",
     67             participants: ["_demo-alice", "_demo-bob"],
     68             puzzle: puzzle,
     69             source: puzzleSource,
     70             in: ctx
     71         )
     72         seedGame(
     73             title: "Sunday Giant",
     74             participants: ["_demo-alice", "_demo-bob", "_demo-carol"],
     75             puzzle: puzzle,
     76             source: puzzleSource,
     77             in: ctx
     78         )
     79 
     80         // A full 15×15 with letters already filled in, attributed across all
     81         // four players, so the desaturated filled-cell tints can be eyeballed
     82         // in a real grid. Reuse the catalog the new-game picker uses; it
     83         // resolves the bundled `.xd` for us and reads its source on demand.
     84         if let entry = PuzzleCatalog.source(matchingResourceID: filledPuzzleResource, title: nil),
     85            let bigSource = try? entry.loadSource(),
     86            let bigXD = try? XD.parse(bigSource) {
     87             let bigPuzzle = Puzzle(xd: bigXD)
     88             let game = seedGame(
     89                 title: entry.title,
     90                 participants: ["_demo-alice", "_demo-bob", "_demo-carol"],
     91                 puzzle: bigPuzzle,
     92                 source: bigSource,
     93                 in: ctx
     94             )
     95             seedFilledLetters(in: game, puzzle: bigPuzzle, in: ctx)
     96         }
     97 
     98         try? ctx.save()
     99     }
    100 
    101     private static func seedFriend(
    102         _ crossmate: (id: String, name: String),
    103         in ctx: NSManagedObjectContext
    104     ) {
    105         let friend = FriendEntity(context: ctx)
    106         friend.authorID = crossmate.id
    107         friend.createdAt = Date()
    108         friend.databaseScope = 0
    109         friend.displayName = crossmate.name
    110         friend.displayNameVersion = 0
    111         friend.friendZoneName = "demo-zone-\(crossmate.id)"
    112         friend.friendZoneOwnerName = "_demo-you"
    113         friend.isBlocked = false
    114         friend.nickname = ""
    115         friend.nicknameVersion = 0
    116         friend.pairKey = "demo-pair-\(crossmate.id)"
    117     }
    118 
    119     @discardableResult
    120     private static func seedGame(
    121         title: String,
    122         participants: [String],
    123         puzzle: Puzzle,
    124         source: String,
    125         in ctx: NSManagedObjectContext
    126     ) -> GameEntity {
    127         let game = GameEntity(context: ctx)
    128         game.id = UUID()
    129         game.title = title
    130         game.puzzleSource = source
    131         game.createdAt = Date()
    132         game.updatedAt = Date()
    133         // A non-nil share record name is what marks the game as shared, which is
    134         // the gate for the Game List participant colour strip.
    135         game.ckShareRecordName = "demo-share-\(title)"
    136         game.populateCachedSummaryFields(from: puzzle)
    137 
    138         for authorID in participants {
    139             let player = PlayerEntity(context: ctx)
    140             player.game = game
    141             player.authorID = authorID
    142             player.name = crossmates.first { $0.id == authorID }?.name
    143             player.ckRecordName = "demo-player-\(title)-\(authorID)"
    144             player.updatedAt = Date()
    145         }
    146         return game
    147     }
    148 
    149     /// Fills a realistic share of `game`'s grid with correct letters, handing
    150     /// each cell to one of the four demo players (you + the three crossmates)
    151     /// in small diagonal patches and leaving scattered gaps so the puzzle reads
    152     /// as in-progress. One `MovesEntity` per author carries that author's cells,
    153     /// exactly as a real co-solve would, so `GridStateMerger` rebuilds the
    154     /// attributed grid — and each filled cell renders that player's faint
    155     /// attribution tint.
    156     private static func seedFilledLetters(
    157         in game: GameEntity,
    158         puzzle: Puzzle,
    159         in ctx: NSManagedObjectContext
    160     ) {
    161         let authors = [localAuthorID] + crossmates.map(\.id)
    162         let now = Date()
    163         var cellsByAuthor: [String: [GridPosition: TimestampedCell]] = [:]
    164 
    165         for r in 0..<puzzle.height {
    166             for c in 0..<puzzle.width {
    167                 let cell = puzzle.cells[r][c]
    168                 guard !cell.isBlock,
    169                       let solution = cell.solution,
    170                       !solution.isEmpty,
    171                       !solution.allSatisfy(\.isWhitespace)
    172                 else { continue }
    173                 // Leave roughly one cell in seven blank for an in-progress look.
    174                 if (r * 5 + c) % 7 == 0 { continue }
    175                 let author = authors[((r / 2) + (c / 2)) % authors.count]
    176                 cellsByAuthor[author, default: [:]][GridPosition(row: r, col: c)] =
    177                     TimestampedCell(
    178                         letter: solution.uppercased(),
    179                         mark: .none,
    180                         updatedAt: now,
    181                         authorID: author
    182                     )
    183             }
    184         }
    185 
    186         for (author, cells) in cellsByAuthor {
    187             let entity = MovesEntity(context: ctx)
    188             entity.game = game
    189             entity.authorID = author
    190             entity.deviceID = "demo-device-\(author)"
    191             entity.ckRecordName = "demo-moves-\(game.id?.uuidString ?? "")-\(author)"
    192             entity.cells = (try? MovesCodec.encode(cells)) ?? Data()
    193             entity.updatedAt = now
    194         }
    195     }
    196 }
    197 
    198 @MainActor
    199 final class AppServices {
    200     enum ReadCursorPublishMode {
    201         case activeLease
    202         case currentTime
    203     }
    204 
    205     private static let readLeaseDuration: TimeInterval = 10 * 60
    206     private static let readLeaseRefreshFloor: TimeInterval = 5 * 60
    207 
    208     enum FreshenReason {
    209         case appeared
    210         case foreground
    211         case manual
    212         case remote
    213 
    214         var diagnosticLabel: String {
    215             switch self {
    216             case .appeared: return "appeared"
    217             case .foreground: return "foreground"
    218             case .manual: return "manual"
    219             case .remote: return "remote"
    220             }
    221         }
    222     }
    223 
    224     let persistence: PersistenceController
    225     let store: GameStore
    226     let syncEngine: SyncEngine
    227     let eventLog: EventLog
    228     let syncMonitor: SyncMonitor
    229     let nytAuth: NYTAuthService
    230     let driveMonitor: DriveMonitor
    231     let nytFetcher: NYTPuzzleFetcher
    232     let inputMonitor: InputMonitor
    233     let movesUpdater: MovesUpdater
    234     let sessionMonitor: SessionMonitor
    235     let announcements: AnnouncementCenter
    236     let playerSelectionPublisher: PlayerSelectionPublisher
    237     let identity: AuthorIdentity
    238     let pushClient: PushClient?
    239     /// Per-game play-session lifecycle: begin/end grace timers, sender-side
    240     /// session pushes, and the catch-up banner. See `SessionCoordinator`.
    241     let sessions: SessionCoordinator
    242     /// Account-scoped push credentials (secret/address mint, rotation,
    243     /// inbound adoption) + push-worker registration; see
    244     /// `AccountPushCoordinator`.
    245     let accountPush: AccountPushCoordinator
    246     /// Finished-game replay loading and the per-session timeline cache; see
    247     /// `ReplayLoader`.
    248     let replays: ReplayLoader
    249     let shareController: ShareController
    250     let friendController: FriendController
    251     let gameArchiver: GameArchiver
    252     let cursorStore: GameCursorStore
    253     /// Device-local record of when each game was last viewed; drives the
    254     /// "changed while you were away" cell borders. Never synced.
    255     let gameViewedStore: GameViewedStore
    256     /// Device-local onboarding-tip state: which tips have been dismissed and
    257     /// whether tips are turned off. Drives the Game List tip banner and the
    258     /// Settings tips archive. Never synced.
    259     let tips: TipStore
    260     let engagementStore: EngagementStore
    261     let cloudService: CloudService
    262     let importService: ImportService
    263     let engagementHost: EngagementHost
    264     let engagementStatus = EngagementStatus()
    265     /// Live-channel lifecycle (room reconcile/mint, teardown/reconnect/
    266     /// lease-expiry timers, inbound channel events); see `EngagementLifecycle`.
    267     /// Lazy so its callbacks into the read-cursor and sync-start paths can
    268     /// capture `self`.
    269     private(set) lazy var engagement = EngagementLifecycle(
    270         preferences: preferences,
    271         persistence: persistence,
    272         store: store,
    273         identity: identity,
    274         syncMonitor: syncMonitor,
    275         engagementHost: engagementHost,
    276         engagementStatus: engagementStatus,
    277         engagementStore: engagementStore,
    278         isAppForeground: { [weak self] in self?.isAppForeground ?? false },
    279         renewReadLease: { [weak self] gameID in
    280             await self?.publishReadCursor(for: gameID, mode: .activeLease)
    281         },
    282         ensureICloudSyncStarted: { [weak self] in
    283             await self?.ensureICloudSyncStarted() ?? false
    284         }
    285     )
    286     /// App-icon badge + delivered-notification reconciliation; see
    287     /// `BadgeCoordinator`. Lazy so the account-seen fan-out can capture `self`.
    288     private(set) lazy var badge = BadgeCoordinator(
    289         store: store,
    290         syncMonitor: syncMonitor,
    291         readLeaseDuration: Self.readLeaseDuration,
    292         publishAccountSeenPush: { [weak self] gameID, readAt in
    293             await self?.accountPush.publishAccountSeenPush(gameID: gameID, readAt: readAt)
    294         }
    295     )
    296     /// Friend-zone traffic — outbound invites, inbound ping handling, durable
    297     /// invite rows, friendship bootstrap, blocking; see `InviteCoordinator`.
    298     /// Lazy so the badge refresh can capture `self`.
    299     private(set) lazy var invites = InviteCoordinator(
    300         persistence: persistence,
    301         identity: identity,
    302         preferences: preferences,
    303         syncMonitor: syncMonitor,
    304         eventLog: eventLog,
    305         syncEngine: syncEngine,
    306         announcements: announcements,
    307         shareController: shareController,
    308         friendController: friendController,
    309         cloudService: cloudService,
    310         refreshAppBadge: { [weak self] reason in
    311             await self?.badge.refreshAppBadge(reason: reason)
    312         }
    313     )
    314 
    315     let preferences: PlayerPreferences
    316 
    317     private let ckContainer = CKContainer(identifier: "iCloud.net.inqk.crossmate.v3")
    318     private var started = false
    319     private var syncStarted = false
    320     /// In-flight `ensureICloudSyncStarted()` work, shared by concurrent
    321     /// callers so the cold-launch race between `services.start()` and a
    322     /// near-simultaneous `syncOnForeground()` doesn't admit two parallel
    323     /// `SyncEngine.start()` runs.
    324     private var syncStartTask: Task<Bool, Never>?
    325     private(set) var playerNamePublisher: PlayerNamePublisher?
    326     private var isReadyForShareAcceptance = false
    327     private var isProcessingShareAcceptanceQueue = false
    328     /// True while `processPendingShareAcceptances` is draining. A share accept
    329     /// holds the shared database to download the puzzle asset on the joining
    330     /// screen; shared-scope pushes that land in this window defer their heavy
    331     /// fan-out so collaborator activity doesn't contend with the join.
    332     private var isAcceptingSharedGame = false
    333     private var pendingShareMetadatas: [CKShare.Metadata] = []
    334     /// Wall-clock timestamp of the most recent inbound silent push. Bypasses
    335     /// the game-list freshen cooldown when a push has arrived since the last
    336     /// freshen, so a collaborator burst isn't held off by debounce.
    337     private var lastRemoteNotificationAt: Date?
    338     private var privatePushCatchUpTask: Task<Void, Never>?
    339     private var sharedPushCatchUpTask: Task<Void, Never>?
    340     private var privateSessionScanTask: Task<Void, Never>?
    341     private var sharedSessionScanTask: Task<Void, Never>?
    342     private var isHandlingPrivateRemoteNotification = false
    343     private var isHandlingSharedRemoteNotification = false
    344     private var gameListFreshenTask: Task<Void, Never>?
    345     private var isFresheningPrivateGameList = false
    346     private var isFresheningSharedGameList = false
    347     /// The archive backstop can scan many completed shared games. Run it once
    348     /// after the first cold-launch game-list freshen, not on every foreground,
    349     /// manual refresh, or remote-triggered refresh.
    350     private var shouldRunColdLaunchArchiveReconcile = true
    351     /// Wall-clock timestamp of the last successful game-list freshen per
    352     /// scope, used to suppress redundant polls when no inbound push has
    353     /// arrived since. Pushes own freshness; the freshen (zone discovery +
    354     /// game/moves catch-up) is only a backstop for the case where Apple
    355     /// drops a silent push or a share-accept notification.
    356     private var lastPrivateGameListFreshenAt: Date?
    357     private var lastSharedGameListFreshenAt: Date?
    358     /// Maximum staleness budget for the game list before an unprompted view
    359     /// event re-runs the freshen. Bypassed by `.manual` and by any push that
    360     /// arrived after the last successful freshen.
    361     private let gameListFreshenCooldown: TimeInterval = 300
    362     private var fresheningPuzzleGridKeys: Set<String> = []
    363     private var lastRemotePuzzleGridFreshenAt: [String: Date] = [:]
    364     /// Collapses bursts of remote-push grid refreshes, but only while the
    365     /// engagement websocket is live for the game (see
    366     /// `shouldSkipRecentRemotePuzzleGridFreshen`). When the live channel is
    367     /// down, the push path is the sole convergence mechanism and is not
    368     /// debounced.
    369     private let remotePuzzleGridFreshenDebounce: TimeInterval = 5
    370     private var isGameListVisible = false
    371     /// Whether the app is foreground-active — the single source of truth for
    372     /// "the user is actively using the app." `publishReadCursor(.activeLease)`
    373     /// consults it so a background CKSyncEngine wake can never re-arm our
    374     /// presence lease. Fed from `RootView`'s scene-phase observer; defaults to
    375     /// `true` because the app launches into the foreground and `.onChange` does
    376     /// not fire for the initial phase.
    377     private(set) var isAppForeground = true
    378 
    379     /// Whether an account-change event warrants purging this device's local
    380     /// store. True only when both the previously-known and the freshly-resolved
    381     /// author IDs are known and differ — i.e. a real switch to a different
    382     /// iCloud account. A first sign-in has no previous ID, and a transient
    383     /// sign-out leaves `AuthorIdentity.refresh` a no-op (so the ID is
    384     /// unchanged); neither should wipe local data.
    385     static func accountSwitchRequiresPurge(previousID: String?, newID: String?) -> Bool {
    386         guard let previousID, let newID else { return false }
    387         return previousID != newID
    388     }
    389 
    390     init() {
    391         let preferences = PlayerPreferences()
    392         self.preferences = preferences
    393         let eventLog = EventLog()
    394         self.eventLog = eventLog
    395         // `--crossmate-seed-demo` (set in the Run scheme's arguments) brings the
    396         // app up against a throwaway in-memory store pre-filled with a couple of
    397         // shared games and a few crossmates, purely so the Game List colour
    398         // strips and the friends list can be eyeballed in the Simulator without
    399         // iCloud. It never touches the real on-disk store.
    400         let isDemoSeed = ProcessInfo.processInfo.arguments.contains("--crossmate-seed-demo")
    401         let persistence = PersistenceController(inMemory: isDemoSeed, eventLog: eventLog)
    402         self.persistence = persistence
    403         if isDemoSeed {
    404             DemoSeed.populate(persistence: persistence, preferences: preferences)
    405         }
    406         let syncEngine = SyncEngine(container: self.ckContainer, persistence: persistence)
    407         self.syncEngine = syncEngine
    408         self.syncMonitor = SyncMonitor(log: eventLog)
    409         self.driveMonitor = DriveMonitor()
    410         self.nytAuth = NYTAuthService(log: { message in
    411             eventLog.note(message)
    412         })
    413         self.nytFetcher = NYTPuzzleFetcher { NYTAuthService.currentCookieResult() }
    414         self.inputMonitor = InputMonitor()
    415         // In demo mode, inject a fixed local authorID so the seeded peers
    416         // classify as remote — otherwise, with no iCloud user, the roster comes
    417         // up empty and the puzzle scoreboard (and its nudge button) never
    418         // populate. A real launch always resolves the ID from CloudKit.
    419         let identity = isDemoSeed ? AuthorIdentity(testing: DemoSeed.localAuthorID) : AuthorIdentity()
    420         self.identity = identity
    421         let pushSyncMonitor = self.syncMonitor
    422         self.pushClient = PushClient(log: { message in
    423             Task { @MainActor in pushSyncMonitor.note(message) }
    424         })
    425         self.pushClient?.updateAuthorID(identity.currentID)
    426 
    427         let movesUpdater = MovesUpdater(
    428             debounceInterval: .milliseconds(500),
    429             persistence: persistence,
    430             writerAuthorIDProvider: { await MainActor.run { identity.currentID } },
    431             sink: { [persistence] gameIDs, drain in
    432                 // MovesUpdater bumps game.updatedAt on a background context.
    433                 // viewContext.automaticallyMergesChangesFromParent applies that
    434                 // change in-memory but doesn't reliably fire the ObjectsDidChange
    435                 // notification that @FetchRequest's NSFetchedResultsController
    436                 // listens for, so the library list keeps showing the stale
    437                 // "last updated" time until something else nudges the context.
    438                 // The inbound path is masked by noteIncomingMovesUpdate's
    439                 // explicit viewContext save; the outbound path has no analog.
    440                 // Refreshing the affected entities re-emits ObjectsDidChange
    441                 // with refreshedObjects, which NSFRC treats as a per-entity
    442                 // update — that path runs unconditionally so local-only games
    443                 // get the same nudge even when iCloud sync is off.
    444                 await MainActor.run {
    445                     let viewContext = persistence.viewContext
    446                     for gameID in gameIDs {
    447                         let req = NSFetchRequest<GameEntity>(entityName: "GameEntity")
    448                         req.predicate = NSPredicate(format: "id == %@", gameID as CVarArg)
    449                         req.fetchLimit = 1
    450                         guard let entity = try? viewContext.fetch(req).first else { continue }
    451                         viewContext.refresh(entity, mergeChanges: true)
    452                     }
    453                 }
    454                 let isEnabled = await MainActor.run { preferences.isICloudSyncEnabled }
    455                 guard isEnabled else { return }
    456                 await syncEngine.enqueueMoves(gameIDs: gameIDs, drain: drain)
    457             }
    458         )
    459         self.movesUpdater = movesUpdater
    460 
    461         self.announcements = AnnouncementCenter()
    462 
    463         let cursorStore = GameCursorStore()
    464         self.cursorStore = cursorStore
    465         let gameViewedStore = GameViewedStore()
    466         self.gameViewedStore = gameViewedStore
    467         self.tips = TipStore()
    468         let engagementStore = EngagementStore()
    469         self.engagementStore = engagementStore
    470         let onGameDeletedHandler = Self.makeOnGameDeleted(
    471             syncEngine: syncEngine,
    472             cursorStore: cursorStore,
    473             viewedStore: gameViewedStore
    474         )
    475 
    476         let store = GameStore(
    477             persistence: persistence,
    478             movesUpdater: movesUpdater,
    479             authorIDProvider: { identity.currentID },
    480             onGameCreated: { [preferences, syncEngine] ckRecordName in
    481                 Task {
    482                     guard await MainActor.run(body: { preferences.isICloudSyncEnabled }) else { return }
    483                     await syncEngine.enqueueGame(ckRecordName: ckRecordName)
    484                 }
    485             },
    486             onGameUpdated: { [preferences, syncEngine] ckRecordName in
    487                 Task {
    488                     guard await MainActor.run(body: { preferences.isICloudSyncEnabled }) else { return }
    489                     await syncEngine.enqueueGame(ckRecordName: ckRecordName)
    490                 }
    491             },
    492             onGameDeleted: { [preferences] deletion in
    493                 // Drop the badge ledger entry regardless of sync state — a
    494                 // deleted game has nothing left to open, so a stale unread
    495                 // horizon would count forever. `deleteGame` fires
    496                 // `onUnreadOtherMovesChanged` right after, which refreshes the
    497                 // app badge.
    498                 BadgeState.forget(gameID: deletion.gameID)
    499                 guard preferences.isICloudSyncEnabled else { return }
    500                 onGameDeletedHandler(deletion)
    501             },
    502             eventLog: eventLog
    503         )
    504         self.store = store
    505         // Publishes resolve (and mint) the game's shared push credential from
    506         // the store so the worker can verify participation.
    507         self.pushClient?.gameCredentialResolver = { [weak store] gameID in
    508             store?.ensurePushCredentials(for: gameID)
    509         }
    510         // Publishes encrypt the structured payload under the game's content key,
    511         // which rides in the same notification credential the push secret does
    512         // (minted/backfilled on first use) so the worker only ever forwards
    513         // ciphertext for the personal fields.
    514         self.pushClient?.contentKeyResolver = { [weak store] gameID in
    515             guard let keyString = store?.ensurePushCredentials(for: gameID)?.contentKey
    516             else { return nil }
    517             return PushPayloadCipher.key(fromBase64: keyString)
    518         }
    519 
    520         let sessionMonitor = SessionMonitor(
    521             store: store,
    522             localAuthorIDProvider: { identity.currentID }
    523         )
    524         self.sessionMonitor = sessionMonitor
    525 
    526         self.sessions = SessionCoordinator(
    527             persistence: persistence,
    528             store: store,
    529             syncEngine: syncEngine,
    530             syncMonitor: self.syncMonitor,
    531             sessionMonitor: sessionMonitor,
    532             gameViewedStore: gameViewedStore,
    533             announcements: self.announcements,
    534             identity: identity,
    535             preferences: preferences,
    536             pushClient: self.pushClient
    537         )
    538 
    539         self.accountPush = AccountPushCoordinator(
    540             identity: identity,
    541             preferences: preferences,
    542             store: store,
    543             syncEngine: syncEngine,
    544             syncMonitor: self.syncMonitor,
    545             pushClient: self.pushClient
    546         )
    547 
    548         self.replays = ReplayLoader(
    549             store: store,
    550             syncEngine: syncEngine,
    551             syncMonitor: self.syncMonitor
    552         )
    553 
    554         self.shareController = ShareController(
    555             container: self.ckContainer,
    556             persistence: persistence,
    557             syncEngine: syncEngine,
    558             syncMonitor: self.syncMonitor
    559         )
    560         self.playerSelectionPublisher = PlayerSelectionPublisher(
    561             // While the live room carries the cursor over the websocket, the
    562             // durable write is a lagging fallback — throttle it hard. When the
    563             // room is down it is the peer's only delivery path, so keep it snappy.
    564             debounceInterval: { [engagementStatus] gameID in
    565                 engagementStatus.isLive(gameID: gameID) ? .milliseconds(2500) : .milliseconds(500)
    566             },
    567             persistence: persistence,
    568             sink: { gameID, authorID, drain in
    569                 let isEnabled = await MainActor.run { preferences.isICloudSyncEnabled }
    570                 guard isEnabled else { return }
    571                 await syncEngine.enqueuePlayer(
    572                     gameID: gameID,
    573                     authorID: authorID,
    574                     reason: "selection",
    575                     drain: drain
    576                 )
    577             },
    578             peerPresent: { [persistence, identity] gameID in
    579                 let localAuthorID = await MainActor.run { identity.currentID }
    580                 return await Self.hasPresentPeer(
    581                     persistence: persistence,
    582                     gameID: gameID,
    583                     localAuthorID: localAuthorID
    584                 )
    585             }
    586         )
    587         self.friendController = FriendController(
    588             container: self.ckContainer,
    589             persistence: persistence,
    590             syncEngine: syncEngine,
    591             syncMonitor: self.syncMonitor,
    592             eventLog: eventLog
    593         )
    594         self.gameArchiver = GameArchiver(
    595             container: self.ckContainer,
    596             persistence: persistence,
    597             syncEngine: syncEngine,
    598             syncMonitor: self.syncMonitor,
    599             eventLog: eventLog
    600         )
    601         self.cloudService = CloudService(
    602             container: self.ckContainer,
    603             syncEngine: syncEngine,
    604             syncMonitor: self.syncMonitor,
    605             store: store,
    606             shareController: shareController
    607         )
    608         self.importService = ImportService(store: store, driveMonitor: self.driveMonitor)
    609         self.engagementHost = EngagementHost()
    610         self.engagementHost.onEvent = { [weak self] event in
    611             self?.engagement.handleEngagementEvent(event)
    612         }
    613         self.store.onLocalCellEdit = { [weak self] edit in
    614             self?.engagement.sendLocalCellEdit(edit)
    615         }
    616         self.store.onLocalCellEditBatch = { [weak self] edits in
    617             self?.engagement.sendLocalCellEdits(edits)
    618         }
    619         self.store.onJournalComplete = { [weak self] gameID, authorID in
    620             self?.beginCompletionJournalUpload(gameID: gameID, authorID: authorID)
    621         }
    622     }
    623 
    624     func start(appDelegate: AppDelegate) async {
    625         guard !started else { return }
    626         started = true
    627 
    628         // Surface one onboarding tip per cold launch. The in-memory
    629         // AnnouncementCenter is empty on a fresh process, so this re-posts the
    630         // next undismissed tip on each cold start; a warm resume doesn't re-run
    631         // start(), so no tip reappears mid-session. Independent of iCloud sync,
    632         // so it runs ahead of the sync-enablement guard below.
    633         if let tip = tips.currentTip() {
    634             announcements.post(tip.liveAnnouncement())
    635         }
    636 
    637         // Hydrate the persisted diagnostics history before live breadcrumbs
    638         // flow, so a log collected this morning still carries last night's
    639         // session. Ordering against startup notes is by timestamp, so a note
    640         // that races ahead of this isn't lost.
    641         await eventLog.loadPersisted()
    642 
    643         nytAuth.loadStoredSession()
    644         driveMonitor.start()
    645 
    646         store.onUnreadOtherMovesChanged = { [weak self] in
    647             guard let self else { return }
    648             Task { await self.badge.refreshAppBadge(reason: "unread changed") }
    649         }
    650         importVisibleNotificationReceipts()
    651         await badge.refreshAppBadge(reason: "startup")
    652         await badge.logNotificationStartupSnapshot()
    653 
    654         // Heal the App Group nickname directory from Core Data ground truth —
    655         // covers the first run after the feature shipped and any rebuild a
    656         // crash or extension write skipped. Cheap: one fetch over the (small)
    657         // friends table.
    658         let nicknameCtx = persistence.container.newBackgroundContext()
    659         nicknameCtx.performAndWait {
    660             FriendEntity.rebuildNicknameDirectory(in: nicknameCtx)
    661             // Heal the App Group content-key directory from the same context —
    662             // covers the first run after the feature shipped and any rebuild a
    663             // crash or extension write skipped. Cheap: one fetch over the games.
    664             GameEntity.rebuildContentKeyDirectory(in: nicknameCtx)
    665         }
    666 
    667         appDelegate.onRemoteNotification = {
    668             summary, scope, event, gameID, kind, senderDeviceID, readAt, isBackground in
    669             await self.handleRemoteNotification(
    670                 summary: summary,
    671                 scope: scope,
    672                 event: event,
    673                 gameID: gameID,
    674                 kind: kind,
    675                 senderDeviceID: senderDeviceID,
    676                 readAt: readAt,
    677                 isBackground: isBackground
    678             )
    679         }
    680         appDelegate.onVisibleNotificationReceiptsAvailable = { [weak self] in
    681             Task { @MainActor in
    682                 self?.importVisibleNotificationReceipts()
    683             }
    684         }
    685         appDelegate.onAPNsRegistrationResult = { [syncMonitor] message in
    686             syncMonitor.note(message)
    687         }
    688         appDelegate.onAPNsToken = { [weak self] data in
    689             Task { @MainActor in self?.pushClient?.updateAPNsToken(data) }
    690         }
    691         CloudShareAcceptanceBroker.shared.onAcceptShare = { metadata in
    692             await self.enqueueShareAcceptance(metadata)
    693         }
    694 
    695         await syncEngine.setTracer { [syncMonitor] message in
    696             syncMonitor.note(message)
    697         }
    698 
    699         await syncEngine.setSuccessCheckpoint { [syncMonitor] in
    700             syncMonitor.noteSuccess()
    701         }
    702 
    703         await syncEngine.setLocalAuthorIDProvider { [identity] in
    704             identity.currentID
    705         }
    706 
    707         await syncEngine.setOnRemoteMovesUpdated { [weak self, store, identity] gameIDs in
    708             store.noteIncomingMovesUpdate(
    709                 gameIDs: gameIDs,
    710                 currentAuthorID: identity.currentID
    711             )
    712             if let currentID = store.currentEntity?.id,
    713                gameIDs.contains(currentID) {
    714                 store.refreshCurrentGame()
    715                 // `readAt` doubles as the other-author read cursor: advancing it
    716                 // marks these incoming peer moves as seen. Gate on `isSuppressed`
    717                 // — "the user is viewing *this* puzzle right now" — so moves are
    718                 // marked read only while actually on screen, not merely because
    719                 // the app is foreground on some other view (`currentEntity`
    720                 // lingers after navigating away). The background re-lease is
    721                 // blocked separately by publishReadCursor's foreground gate.
    722                 if NotificationState.isSuppressed(gameID: currentID) {
    723                     await self?.publishReadCursor(for: currentID, mode: .activeLease)
    724                 }
    725             }
    726             // Maintain the per-cell letter-change ledger that the "changed while
    727             // you were away" borders and banner read. Captures peer fills/clears
    728             // (never check re-stamps) as they arrive, whether or not the game is
    729             // open. Fire-and-forget: the inbound-moves hot path must not wait on
    730             // this background write, so the next batch isn't throttled behind it.
    731             store.enqueuePeerChangeLedgerUpdate(for: gameIDs)
    732         }
    733 
    734         // Friendship bootstrap keys off the *first* sight of a collaborator's
    735         // Player record (their identity) — fires once per new collaborator,
    736         // not on moves and not on their later name / cursor updates.
    737         await syncEngine.setOnRemotePlayersUpdated { [weak self] gameIDs in
    738             await self?.invites.reconcileFriendships(forGameIDs: gameIDs)
    739             // A newly-arrived shared game means a new address slot to mint and
    740             // a token to register under it (so this device can receive the
    741             // game's pushes without opening it first).
    742             await self?.accountPush.reconcilePushRegistration()
    743         }
    744 
    745         // An inbound Player record may have updated a peer's cursor track;
    746         // nudge the selection publisher and engagement coordinator to
    747         // re-evaluate peer presence. This must fire for existing Player
    748         // records too: known collaborators opening a puzzle are the common
    749         // live co-solving path.
    750         await syncEngine.setOnRemotePlayerPresenceChanged { [weak self] gameIDs in
    751             await self?.playerSelectionPublisher.peerPresenceMayHaveChanged(gameIDs: gameIDs)
    752             guard let self else { return }
    753             for gameID in gameIDs {
    754                 await self.engagement.reconcileEngagement(gameID: gameID)
    755             }
    756         }
    757 
    758         // A peer minted or rotated the shared engagement room (the Game
    759         // record's `engagement` creds changed). Reconcile so this device joins
    760         // — or migrates onto — whatever room the record now advertises.
    761         await syncEngine.setOnRemoteEngagementChanged { [weak self] gameIDs in
    762             guard let self else { return }
    763             for gameID in gameIDs {
    764                 await self.engagement.reconcileEngagement(gameID: gameID)
    765             }
    766         }
    767 
    768         // A sibling device of the same iCloud account has published its read
    769         // horizon; apply it directly because SyncEngine has already accepted
    770         // the Player record under last-writer-wins freshness checks. A
    771         // future-dated readAt is an active-session lease — a sibling is in the
    772         // puzzle right now — so withdraw any session notifications we already
    773         // delivered for that game (e.g. "X is solving"); opening it here is no
    774         // longer something to nudge for. A past readAt is just a closed-session
    775         // horizon bump and leaves delivered notifications untouched.
    776         await syncEngine.setOnIncomingReadCursor { [weak self, store, gameViewedStore] pairs in
    777             let now = Date()
    778             for (gameID, readAt, seenBaselineData) in pairs {
    779                 let (previous, adopted) = store.noteIncomingReadCursor(gameID: gameID, readAt: readAt)
    780                 self?.syncMonitor.note(
    781                     "cursor ADOPT[\(gameID.uuidString.prefix(8))] src=sync " +
    782                     "readAt=\(readAt.ISO8601Format()) " +
    783                     "was=\(previous?.ISO8601Format() ?? "—")" +
    784                     (adopted ? "" : " (no-op)")
    785                 )
    786                 // A sibling device shipped its "last viewed" baseline on its own
    787                 // `Player.sessionSnapshot`; fold it in monotonically so we
    788                 // converge on the latest view time across the account rather than
    789                 // recomputing from this device's (possibly stale) local view. An
    790                 // older or old-format payload simply fails to advance / decode.
    791                 if let data = seenBaselineData,
    792                    let baseline = try? JSONDecoder().decode(SeenBaseline.self, from: data) {
    793                     gameViewedStore.advance(baseline.viewedAt, forGame: gameID)
    794                 }
    795                 if readAt > now {
    796                     await self?.badge.dismissDeliveredNotifications(
    797                         for: gameID,
    798                         seenAt: readAt,
    799                         publishAccountSeen: false,
    800                         preserveUnread: true
    801                     )
    802                 } else if NotificationState.activePuzzleID() == gameID {
    803                     // A past-dated readAt is a sibling closing its session, which
    804                     // under last-writer-wins just pulled the shared account
    805                     // horizon back to that close time. This device is still
    806                     // actively viewing the same puzzle, so it still holds a
    807                     // presence lease — re-assert it now instead of waiting up to
    808                     // `readLeaseRefreshFloor` (5 min) for the next renewal tick.
    809                     // `requireActivePuzzle` re-checks inside the write so a leave
    810                     // racing this inbound can't strand a stale future lease.
    811                     // The local badge ledger keeps this device's own suppression
    812                     // horizon — a sibling's close doesn't mean *we* stopped
    813                     // looking.
    814                     await self?.publishReadCursor(
    815                         for: gameID,
    816                         mode: .activeLease,
    817                         requireActivePuzzle: true
    818                     )
    819                 } else {
    820                     // Sibling closed its session and nothing is on screen here:
    821                     // the account stopped looking at `readAt`. Pull the badge
    822                     // ledger's suppression horizon back to that instant (and
    823                     // advance the watermark to it) so a push arriving after the
    824                     // close badges here instead of staying swallowed under the
    825                     // sibling's old lease, which this device adopted when the
    826                     // lease was minted.
    827                     BadgeState.markSeen(gameID: gameID, at: readAt)
    828                     BadgeState.collapseSuppression(gameID: gameID, to: readAt)
    829                 }
    830             }
    831         }
    832 
    833         await syncEngine.setOnAccountPushAddress { [weak self] address in
    834             await self?.accountPush.adoptInboundPushAddress(address)
    835         }
    836 
    837         await syncEngine.setOnAccountPushSecret { [weak self] secret, version in
    838             await self?.accountPush.adoptInboundPushSecret(secret, version: version)
    839         }
    840 
    841         shareController.onShareSaved = { [weak self] gameID in
    842             guard let self else { return }
    843             self.store.markShared(gameID: gameID)
    844             // Mint the game's notification content key now, at share time,
    845             // rather than lazily on the first push. The key rides the Game
    846             // record (`setNotification` enqueues its push), so minting here
    847             // gives it time to propagate to participants before any encrypted
    848             // notification is sent. Lazy minting let the first push for a game
    849             // with no prior activity (e.g. an immediate resign on a game with
    850             // no moves) outrun the key's sync, leaving the recipient unable to
    851             // decrypt and falling back to the generic alert. Idempotent.
    852             self.store.ensurePushCredentials(for: gameID)
    853             // Register this device under the newly-shared game's derived push
    854             // address so peers can reach it.
    855             Task { @MainActor [weak self] in
    856                 await self?.accountPush.reconcilePushRegistration()
    857             }
    858             // Register the app for notifications now that the user has chosen
    859             // to collaborate. Surfaces the app in Settings > Notifications and
    860             // makes the icon-badge permission available before any inbound
    861             // moves can arrive.
    862             Task { await AppDelegate.requestNotificationAuthorizationIfNeeded() }
    863         }
    864 
    865         await syncEngine.setOnPings { [weak self] pings in
    866             guard let self else { return }
    867             await self.invites.presentPings(pings)
    868         }
    869 
    870         await syncEngine.setOnAccountChange { [weak self] in
    871             guard let self else { return }
    872             let previousID = self.identity.currentID
    873             await self.identity.refresh(using: self.ckContainer)
    874             let newID = self.identity.currentID
    875             // A switch to a *different* iCloud account: drop this device's
    876             // cache of the previous account's data so it neither lingers in
    877             // the library nor mixes with the new author's rows. Local only —
    878             // the previous account keeps its games in its own CloudKit; this
    879             // device just resyncs as the new account. Gated on the author ID
    880             // actually changing so a first sign-in (no previous) or a
    881             // transient sign-out (`refresh` no-ops, ID unchanged) doesn't
    882             // purge.
    883             if Self.accountSwitchRequiresPurge(previousID: previousID, newID: newID) {
    884                 do {
    885                     try await self.cloudService.purgeLocalData()
    886                 } catch {
    887                     self.syncMonitor.note("account-switch purge failed — \(error)")
    888                 }
    889             }
    890             self.pushClient?.updateAuthorID(newID)
    891             // Recompute the address set for the new account; addresses that
    892             // belonged to the old account drop out and are unregistered.
    893             await self.accountPush.reconcilePushRegistration()
    894         }
    895 
    896         await syncEngine.setOnGameAccessRevoked { [store, gameViewedStore, announcements, gameArchiver] gameID in
    897             store.markAccessRevoked(gameID: gameID)
    898             // Supersede any pending catch-up banner: advancing the view baseline
    899             // to now leaves nothing for the next open to diff against.
    900             gameViewedStore.advance(Date(), forGame: gameID)
    901             // Surface the revocation as a sticky, input-blocking banner on
    902             // the open puzzle, replacing the former AccessRevokedBanner
    903             // overlay. Game-scoped, so it only shows for this puzzle.
    904             announcements.post(.accessRevoked(gameID: gameID))
    905             // The owner deleted the shared zone. For a *finished* game, swap the
    906             // revoked tombstone for a durable owned copy rebuilt from the
    907             // private-zone archive (and from the still-present local data);
    908             // in-progress games are left as revoked rows.
    909             await gameArchiver.promoteRevoked(gameID: gameID)
    910         }
    911 
    912         await syncEngine.setOnGameRemoved { [weak self, store, gameViewedStore, announcements] gameID in
    913             let wasOpen = store.handleRemoteRemoval(gameID: gameID)
    914             gameViewedStore.advance(Date(), forGame: gameID)
    915             // The local row is gone, so drop its badge ledger entry: a seen
    916             // horizon can't clear it once there's no game left to open.
    917             BadgeState.forget(gameID: gameID)
    918             await self?.badge.refreshAppBadge(reason: "game removed")
    919             // A hard-deleted game (private zone gone, or a shared game left
    920             // elsewhere) only needs UI when its puzzle is on screen: a sticky,
    921             // input-blocking banner freezes the now-orphaned puzzle until the
    922             // user backs out. Off-screen removals just drop from the list.
    923             if wasOpen {
    924                 announcements.post(.gameRemoved(gameID: gameID))
    925             }
    926         }
    927 
    928         await syncEngine.setOnGameCompleted { [weak self] gameID in
    929             await self?.shareController.closeTicketForCompletedGame(gameID: gameID)
    930             // Completion learned purely via sync (this device wasn't present at
    931             // the finish, so persistCompletion never ran): drop the now-useless
    932             // peer-change ledger, the writer's terminal-game path doing the work.
    933             self?.store.enqueuePeerChangeLedgerUpdate(for: [gameID])
    934         }
    935 
    936         await syncEngine.setOnGameJoined { [weak self] gameID in
    937             guard let self else { return }
    938             // A shared zone just synced in for this game — joined here or on
    939             // a sibling device. Its "Invited" row is now redundant; drop it
    940             // so a freshly-synced game and its stale invite don't show side
    941             // by side. `applyInvitePings` GCs the same row, but only when a
    942             // ping is next fetched.
    943             do {
    944                 try self.invites.removePendingInvite(forGameID: gameID)
    945                 // The pending invite (if any) is gone; drop it from the badge.
    946                 await self.badge.refreshAppBadge(reason: "game joined")
    947             } catch {
    948                 self.announcements.post(Announcement(
    949                     id: "remove-pending-invite-error-\(gameID.uuidString)",
    950                     scope: .global,
    951                     severity: .error,
    952                     title: "Clearing Failed",
    953                     body: error.localizedDescription,
    954                     dismissal: .manual
    955                 ))
    956             }
    957             // Defer the sync enqueue out of the `onGameJoined` callback; the
    958             // actual CKSyncEngine send drain remains detached in SyncEngine.
    959             Task { @MainActor [weak self] in
    960                 await self?.accountPush.reconcilePushRegistration()
    961             }
    962         }
    963 
    964         // A sibling device consumed (deleted) a directed ping; withdraw any
    965         // copy of that game's notification we delivered before the deletion
    966         // reached us, and clear any durable invite row backed by that Ping.
    967         await syncEngine.setOnPingDeleted { [weak self] pings in
    968             guard let self else { return }
    969             try? self.invites.removePendingInvites(forPingRecordNames: Set(pings.map { $0.recordName }))
    970             await self.badge.refreshAppBadge(reason: "ping deleted")
    971             for gameID in Set(pings.map { $0.gameID }) {
    972                 await self.badge.dismissDeliveredNotifications(
    973                     for: gameID,
    974                     publishAccountSeen: false
    975                 )
    976             }
    977         }
    978 
    979         cloudService.onShareJoined = { [weak self] gameID in
    980             guard let self else { return }
    981             // Register the app for notifications now that the user has joined
    982             // a collaboration. Mirrors the owner path in `onShareSaved` so the
    983             // app is in Settings > Notifications before any inbound moves.
    984             await AppDelegate.requestNotificationAuthorizationIfNeeded()
    985             await self.accountPush.reconcilePushRegistration()
    986             // Stamp (minting if needed) this account's own derived push address
    987             // for the joined game, both so the room broadcast below can exclude
    988             // our own devices and so we're addressable for inbound pushes.
    989             let ownAddress = self.identity.currentID.flatMap {
    990                 self.accountPush.setDerivedPushAddress(gameID: gameID, authorID: $0)
    991             }
    992             await self.accountPush.publishAccountJoinedPush(gameID: gameID)
    993             // Tell everyone already in the room that we've joined.
    994             await self.sessions.publishJoinPush(gameID: gameID, excludeAddress: ownAddress)
    995         }
    996 
    997         // PlayerNamePublisher fans out name changes as `name` Decisions to the
    998         // account zone and every friend zone. PuzzleDisplayView publishes the
    999         // open game's Player-record name snapshot directly, which covers
   1000         // first-sync-after-share-create / accept and pre-friendship display.
   1001         playerNamePublisher = PlayerNamePublisher(
   1002             preferences: preferences,
   1003             persistence: persistence,
   1004             authorIdentity: identity,
   1005             enqueuePlayer: { [preferences, syncEngine] gameID, authorID, reason in
   1006                 let isEnabled = await MainActor.run { preferences.isICloudSyncEnabled }
   1007                 guard isEnabled else { return }
   1008                 await syncEngine.enqueuePlayer(
   1009                     gameID: gameID,
   1010                     authorID: authorID,
   1011                     reason: reason
   1012                 )
   1013             },
   1014             enqueueNameDecision: { [preferences, syncEngine] authorID, name, version, zoneID, scope in
   1015                 let isEnabled = await MainActor.run { preferences.isICloudSyncEnabled }
   1016                 guard isEnabled else { return }
   1017                 await syncEngine.enqueueNameDecision(
   1018                     authorID: authorID,
   1019                     name: name,
   1020                     version: version,
   1021                     zoneID: zoneID,
   1022                     scope: scope
   1023                 )
   1024             }
   1025         )
   1026 
   1027         guard await ensureICloudSyncStarted() else {
   1028             syncMonitor.note("iCloud sync disabled — engine startup skipped")
   1029             return
   1030         }
   1031         // The scene-active phase that fires alongside cold launch runs the
   1032         // first fetch + push via `syncOnForeground`. Doing it here as well
   1033         // would mean two concurrent CKSyncEngine fetches on a fresh engine.
   1034     }
   1035 
   1036     func enqueueShareAcceptance(_ metadata: CKShare.Metadata) async {
   1037         guard preferences.isICloudSyncEnabled else {
   1038             syncMonitor.note("share acceptance ignored while iCloud sync is disabled")
   1039             return
   1040         }
   1041         pendingShareMetadatas.append(metadata)
   1042         syncMonitor.note(
   1043             "share acceptance queued: container=\(metadata.containerIdentifier)"
   1044         )
   1045         await processPendingShareAcceptances()
   1046     }
   1047 
   1048     func syncOnForeground() async {
   1049         importVisibleNotificationReceipts()
   1050         await movesUpdater.flush()
   1051         guard await ensureICloudSyncStarted() else { return }
   1052         let recoveredMoveCount = await syncEngine.enqueueUnconfirmedMoves()
   1053         if recoveredMoveCount > 0 {
   1054             syncMonitor.note("recovered \(recoveredMoveCount) unconfirmed move(s) for CloudKit enqueue")
   1055         }
   1056         await syncMonitor.run("foreground push") {
   1057             try await syncEngine.pushChanges()
   1058         }
   1059         if isGameListVisible {
   1060             syncMonitor.note("foreground fetch skipped: game list will refresh")
   1061             await freshenGameList(reason: .foreground)
   1062             return
   1063         }
   1064         if let (gameID, scope) = activePuzzleGridTarget() {
   1065             syncMonitor.note("foreground fetch skipped: active puzzle will refresh")
   1066             await freshenPuzzleGrid(gameID: gameID, scope: scope, reason: .foreground)
   1067             return
   1068         }
   1069         await syncMonitor.run("foreground fetch") {
   1070             try await syncEngine.fetchChanges(source: "foreground")
   1071         }
   1072         await refreshSnapshot()
   1073     }
   1074 
   1075     private func importVisibleNotificationReceipts() {
   1076         for entry in VisibleNotificationReceiptLog.drain() {
   1077             eventLog.note(VisibleNotificationReceiptLog.message(for: entry))
   1078         }
   1079     }
   1080 
   1081     func gameListAppeared() async {
   1082         isGameListVisible = true
   1083         await freshenGameList(reason: .appeared)
   1084     }
   1085 
   1086     func gameListDisappeared() {
   1087         isGameListVisible = false
   1088     }
   1089 
   1090     /// Runs `work` to completion under a `UIApplication` background-execution
   1091     /// assertion, so a flush or enqueue that begins as the app heads to the
   1092     /// background still reaches durable state before iOS suspends us. The
   1093     /// assertion is taken **synchronously** — before `work`'s first await — and
   1094     /// released exactly once: when `work` returns, or when iOS signals imminent
   1095     /// expiration, whichever comes first. Best-effort: a force-quit before
   1096     /// `work` lands is the one case this can't cover, so callers pair it with a
   1097     /// foreground reconcile sweep that re-runs anything that didn't finish.
   1098     ///
   1099     /// The assertion owns its own lifetime — no instance slot — so overlapping
   1100     /// calls each hold an independent assertion that self-releases. Correct
   1101     /// only on the main actor: the expiration handler and the completion both
   1102     /// run there, so the `released` latch serialises into a single
   1103     /// `endBackgroundTask` (a double release is a UIKit fault). `name` is the
   1104     /// debug label iOS shows for the assertion.
   1105     func ensureInBackground(_ name: String, _ work: @escaping () async -> Void) {
   1106         var token = UIBackgroundTaskIdentifier.invalid
   1107         var released = false
   1108         func release() {
   1109             guard !released, token != .invalid else { return }
   1110             released = true
   1111             UIApplication.shared.endBackgroundTask(token)
   1112         }
   1113         token = UIApplication.shared.beginBackgroundTask(withName: name, expirationHandler: release)
   1114         Task {
   1115             await work()
   1116             release()
   1117         }
   1118     }
   1119 
   1120     /// Flush buffered cell edits on the way to the background. Held under a
   1121     /// background assertion so the persist + CKSyncEngine enqueue completes
   1122     /// even if the scene is suspended immediately; whatever doesn't land is
   1123     /// recovered by the next foreground's `enqueueUnconfirmedMoves`.
   1124     func syncOnBackground() {
   1125         ensureInBackground("moves-flush") { [weak self] in
   1126             await self?.movesUpdater.flush()
   1127         }
   1128         ensureInBackground("event-log-flush") { [weak self] in
   1129             await self?.eventLog.flush()
   1130         }
   1131     }
   1132 
   1133     /// Pull-to-refresh action for the library. Discovers any zones the
   1134     /// device hasn't seen yet on both database scopes, then runs the normal
   1135     /// engine fetch so any in-flight changes also catch up. Bypasses
   1136     /// CKSyncEngine's database-scope change delivery, which can lag behind
   1137     /// reality when the engine has been idle.
   1138     func refreshLibrary() async {
   1139         await freshenGameList(reason: .manual)
   1140         guard await ensureICloudSyncStarted() else { return }
   1141         await syncMonitor.run("library refresh: engine fetch") {
   1142             try await syncEngine.fetchChanges(source: "library refresh")
   1143         }
   1144         await refreshSnapshot()
   1145     }
   1146 
   1147     func freshenGameList(reason: FreshenReason) async {
   1148         guard await ensureICloudSyncStarted() else { return }
   1149         if let task = gameListFreshenTask {
   1150             syncMonitor.note(
   1151                 "freshen game list \(reason.diagnosticLabel): coalesced into in-flight freshen"
   1152             )
   1153             await task.value
   1154             return
   1155         }
   1156 
   1157         let task = Task { @MainActor in
   1158             await self.runFreshenGameList(reason: reason)
   1159         }
   1160         gameListFreshenTask = task
   1161         await task.value
   1162         gameListFreshenTask = nil
   1163     }
   1164 
   1165     private func runFreshenGameList(reason: FreshenReason) async {
   1166         // The game list is a foreground-visible freshness path, not the live
   1167         // collaboration path. Keep the two database scopes serialized so list
   1168         // appearance and foreground transitions do not create a read burst.
   1169         await freshenGameListScope(
   1170             .private,
   1171             label: "private",
   1172             reason: reason
   1173         )
   1174         await freshenGameListScope(
   1175             .shared,
   1176             label: "shared",
   1177             reason: reason
   1178         )
   1179         await refreshSnapshot()
   1180         // Now that Core Data unread reflects server ground truth, prune any
   1181         // badge-ledger entry no longer backed by unread state or a delivered
   1182         // notification. Reconciling here (rather than once at startup, before
   1183         // the freshen settles) clears orphans like a game whose push stamped
   1184         // the ledger but was since opened — the divergence that otherwise pins
   1185         // the badge above the count the library list shows.
   1186         await badge.reconcileBadgeLedgerWithDeliveredNotifications()
   1187         await badge.refreshAppBadge(reason: "game list freshen")
   1188         await reconcilePendingJournalUploads()
   1189         if shouldRunColdLaunchArchiveReconcile {
   1190             shouldRunColdLaunchArchiveReconcile = false
   1191             // Startup-only backstop for the private-DB archive: re-attempts any
   1192             // completed participant game whose archive never landed, without
   1193             // repeating the scan on every foreground/manual/remote refresh.
   1194             await gameArchiver.reconcileUnarchived()
   1195         }
   1196     }
   1197 
   1198     /// Level-triggered backstop for replay journal uploads. The upload is
   1199     /// normally fired edge-style — once, at local completion or when an inbound
   1200     /// sync reveals the completion. But the local-completion enqueue is async
   1201     /// (journal flush → prefs check → `enqueueJournalUpload`), so a solver who
   1202     /// swipes the app away the instant they win can be suspended before the save
   1203     /// reaches CKSyncEngine's durable state; nothing re-fires it, and replay's
   1204     /// strict completeness then waits on that contributor forever. This sweep
   1205     /// re-enqueues any completed game whose journal hasn't been confirmed
   1206     /// uploaded. A re-send is a benign no-op, and `journalUploaded` (set on the
   1207     /// confirmed save, here for games this device never contributed to) makes it
   1208     /// converge to a no-op rather than re-enqueuing every freshen.
   1209     private func reconcilePendingJournalUploads() async {
   1210         guard let authorID = identity.currentID, !authorID.isEmpty else { return }
   1211         let ctx = persistence.container.newBackgroundContext()
   1212         ctx.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump
   1213         let candidates: [UUID] = ctx.performAndWait {
   1214             let req = NSFetchRequest<GameEntity>(entityName: "GameEntity")
   1215             req.predicate = NSPredicate(format: "completedAt != nil AND journalUploaded == NO")
   1216             return ((try? ctx.fetch(req)) ?? []).compactMap(\.id)
   1217         }
   1218         guard !candidates.isEmpty else { return }
   1219 
   1220         var nothingToUpload: [UUID] = []
   1221         for gameID in candidates {
   1222             if store.localJournalEntries(for: gameID).isEmpty {
   1223                 nothingToUpload.append(gameID)
   1224             } else {
   1225                 await syncEngine.enqueueJournalUpload(gameID: gameID, authorID: authorID)
   1226             }
   1227         }
   1228         guard !nothingToUpload.isEmpty else { return }
   1229 
   1230         // No local journal for these — this device never played them, so there
   1231         // is nothing to publish. Mark them done so the sweep stops reconsidering.
   1232         let toMark = nothingToUpload
   1233         ctx.performAndWait {
   1234             let req = NSFetchRequest<GameEntity>(entityName: "GameEntity")
   1235             req.predicate = NSPredicate(format: "id IN %@", toMark)
   1236             for game in (try? ctx.fetch(req)) ?? [] {
   1237                 game.journalUploaded = true
   1238             }
   1239             if ctx.hasChanges { try? ctx.save() }
   1240         }
   1241     }
   1242 
   1243     /// Edge-triggered, immediate companion to `reconcilePendingJournalUploads`:
   1244     /// fired synchronously on completion (`GameStore.onJournalComplete`). Runs
   1245     /// under a background assertion so the journal flush and the CKSyncEngine
   1246     /// enqueue reach durable state even if the user backgrounds the app the
   1247     /// instant they finish; CKSyncEngine then completes the send. The flush runs
   1248     /// regardless of iCloud (it persists local journal entries that would
   1249     /// otherwise be lost on termination); the enqueue is gated on sync being
   1250     /// enabled. A force-quit before the work completes is the one case this
   1251     /// can't cover — that falls to the foreground sweep.
   1252     func beginCompletionJournalUpload(gameID: UUID, authorID: String) {
   1253         ensureInBackground("journal-upload-\(gameID.uuidString)") { [weak self] in
   1254             guard let self else { return }
   1255             // Flush the cell buffer and journal queue first, so both the journal
   1256             // upload and the archive snapshot see the finished grid and full log
   1257             // (the winning move is still in flight when completion fires). The
   1258             // flush persists local entries regardless of iCloud; the cloud-bound
   1259             // steps below are gated on it.
   1260             await self.store.flushCompletionWrites()
   1261             guard self.preferences.isICloudSyncEnabled else { return }
   1262             await self.shareController.closeTicketForCompletedGame(gameID: gameID)
   1263             await self.syncEngine.enqueueJournalUpload(gameID: gameID, authorID: authorID)
   1264             // Snapshot finished participant games to this user's private DB for
   1265             // cross-device durability. A no-op for owned games (already durable)
   1266             // and ones already archived; sequenced after the flush so it can't
   1267             // capture a stale grid or miss the final journal rows.
   1268             await self.gameArchiver.archiveIfNeeded(gameID: gameID)
   1269         }
   1270     }
   1271 
   1272     private func freshenGameList(
   1273         scope: CKDatabase.Scope,
   1274         reason: FreshenReason
   1275     ) async {
   1276         let label: String
   1277         switch scope {
   1278         case .private:
   1279             label = "private"
   1280         case .shared:
   1281             label = "shared"
   1282         case .public:
   1283             return
   1284         @unknown default:
   1285             return
   1286         }
   1287         guard await ensureICloudSyncStarted() else { return }
   1288         if let task = gameListFreshenTask {
   1289             syncMonitor.note(
   1290                 "freshen game list \(reason.diagnosticLabel): \(label) coalesced into in-flight freshen"
   1291             )
   1292             await task.value
   1293             return
   1294         }
   1295         await freshenGameListScope(scope, label: label, reason: reason)
   1296         await refreshSnapshot()
   1297     }
   1298 
   1299     private func freshenGameListScope(
   1300         _ scope: CKDatabase.Scope,
   1301         label: String,
   1302         reason: FreshenReason
   1303     ) async {
   1304         let reasonLabel = reason.diagnosticLabel
   1305         if !shouldRunGameListFreshen(scope: scope, reason: reason, label: label) {
   1306             return
   1307         }
   1308         guard beginGameListFreshen(scope: scope, label: label, reason: reasonLabel) else {
   1309             return
   1310         }
   1311         defer { endGameListFreshen(scope: scope) }
   1312 
   1313         await syncMonitor.run("freshen game list \(reasonLabel): \(label) discovery") {
   1314             _ = try await self.syncEngine.discoverNewZonesDirect(scope: scope)
   1315         }
   1316         let catchUpResult: Int? = await syncMonitor.run("freshen game list \(reasonLabel): \(label) game/moves") {
   1317             try await self.syncEngine.fetchKnownGameMovesDirect(scope: scope)
   1318         }
   1319         let inviteResult: Int? = await syncMonitor.run("freshen game list \(reasonLabel): \(label) invites") {
   1320             try await self.syncEngine.fetchFriendInvitesDirect(scope: scope)
   1321         }
   1322         if inviteResult != nil {
   1323             await badge.refreshAppBadge(reason: "invite freshen")
   1324         }
   1325         if catchUpResult != nil {
   1326             noteGameListFreshenCompleted(scope: scope)
   1327         }
   1328     }
   1329 
   1330     /// Decides whether a game-list freshen should run for this scope. The
   1331     /// freshen polls each active zone for new records and enumerates
   1332     /// database zones for newly-shared games; between inbound pushes it
   1333     /// can't surface anything new, so we skip when the push signal hasn't
   1334     /// moved since the last successful run and the staleness budget hasn't
   1335     /// been exhausted. `.manual` (pull-to-refresh) always runs because the
   1336     /// user explicitly asked.
   1337     private func shouldRunGameListFreshen(
   1338         scope: CKDatabase.Scope,
   1339         reason: FreshenReason,
   1340         label: String
   1341     ) -> Bool {
   1342         if reason == .manual {
   1343             return true
   1344         }
   1345         guard let last = lastGameListFreshenAt(scope: scope) else {
   1346             return true
   1347         }
   1348         if let pushAt = lastRemoteNotificationAt, pushAt > last {
   1349             return true
   1350         }
   1351         let elapsed = Date().timeIntervalSince(last)
   1352         if elapsed >= gameListFreshenCooldown {
   1353             return true
   1354         }
   1355         let elapsedSeconds = Int(elapsed.rounded())
   1356         syncMonitor.note(
   1357             "freshen game list \(reason.diagnosticLabel): \(label) skipped (cooldown, last \(elapsedSeconds)s ago)"
   1358         )
   1359         return false
   1360     }
   1361 
   1362     private func lastGameListFreshenAt(scope: CKDatabase.Scope) -> Date? {
   1363         switch scope {
   1364         case .private:
   1365             return lastPrivateGameListFreshenAt
   1366         case .shared:
   1367             return lastSharedGameListFreshenAt
   1368         case .public:
   1369             return nil
   1370         @unknown default:
   1371             return nil
   1372         }
   1373     }
   1374 
   1375     private func noteGameListFreshenCompleted(scope: CKDatabase.Scope) {
   1376         let now = Date()
   1377         switch scope {
   1378         case .private:
   1379             lastPrivateGameListFreshenAt = now
   1380         case .shared:
   1381             lastSharedGameListFreshenAt = now
   1382         case .public:
   1383             return
   1384         @unknown default:
   1385             return
   1386         }
   1387     }
   1388 
   1389     private func beginGameListFreshen(
   1390         scope: CKDatabase.Scope,
   1391         label: String,
   1392         reason: String
   1393     ) -> Bool {
   1394         switch scope {
   1395         case .private:
   1396             guard !isFresheningPrivateGameList else {
   1397                 syncMonitor.note("freshen game list \(reason): \(label) coalesced into in-flight freshen")
   1398                 return false
   1399             }
   1400             isFresheningPrivateGameList = true
   1401             return true
   1402         case .shared:
   1403             guard !isFresheningSharedGameList else {
   1404                 syncMonitor.note("freshen game list \(reason): \(label) coalesced into in-flight freshen")
   1405                 return false
   1406             }
   1407             isFresheningSharedGameList = true
   1408             return true
   1409         case .public:
   1410             return false
   1411         @unknown default:
   1412             return false
   1413         }
   1414     }
   1415 
   1416     private func endGameListFreshen(scope: CKDatabase.Scope) {
   1417         switch scope {
   1418         case .private:
   1419             isFresheningPrivateGameList = false
   1420         case .shared:
   1421             isFresheningSharedGameList = false
   1422         case .public:
   1423             return
   1424         @unknown default:
   1425             return
   1426         }
   1427     }
   1428 
   1429     func freshenPuzzleGrid(
   1430         gameID: UUID,
   1431         scope: CKDatabase.Scope,
   1432         reason: FreshenReason
   1433     ) async {
   1434         await movesUpdater.flush()
   1435         guard await ensureICloudSyncStarted() else { return }
   1436         let label = reason.diagnosticLabel
   1437         if reason == .remote,
   1438            shouldSkipRecentRemotePuzzleGridFreshen(
   1439                gameID: gameID,
   1440                scope: scope,
   1441                label: label
   1442            ) {
   1443             return
   1444         }
   1445         guard beginPuzzleGridFreshen(gameID: gameID, scope: scope, reason: label) else {
   1446             return
   1447         }
   1448         defer {
   1449             endPuzzleGridFreshen(gameID: gameID, scope: scope)
   1450             if reason == .remote {
   1451                 noteRemotePuzzleGridFreshenCompleted(gameID: gameID, scope: scope)
   1452             }
   1453         }
   1454 
   1455         await syncMonitor.run("freshen puzzle grid \(label)") {
   1456             let handled = try await syncEngine.fetchGameDirect(
   1457                 scope: scope,
   1458                 gameID: gameID
   1459             )
   1460             if !handled {
   1461                 try await syncEngine.fetchChanges(source: "puzzle grid \(label)")
   1462             }
   1463         }
   1464         await refreshSnapshot()
   1465     }
   1466 
   1467     private func beginPuzzleGridFreshen(
   1468         gameID: UUID,
   1469         scope: CKDatabase.Scope,
   1470         reason: String
   1471     ) -> Bool {
   1472         let key = puzzleGridFreshenKey(gameID: gameID, scope: scope)
   1473         guard !fresheningPuzzleGridKeys.contains(key) else {
   1474             syncMonitor.note(
   1475                 "freshen puzzle grid \(reason): \(scopeLabel(scope)) \(gameID.uuidString.prefix(8)) coalesced into in-flight freshen"
   1476             )
   1477             return false
   1478         }
   1479         fresheningPuzzleGridKeys.insert(key)
   1480         return true
   1481     }
   1482 
   1483     private func endPuzzleGridFreshen(gameID: UUID, scope: CKDatabase.Scope) {
   1484         fresheningPuzzleGridKeys.remove(puzzleGridFreshenKey(gameID: gameID, scope: scope))
   1485     }
   1486 
   1487     private func shouldSkipRecentRemotePuzzleGridFreshen(
   1488         gameID: UUID,
   1489         scope: CKDatabase.Scope,
   1490         label: String
   1491     ) -> Bool {
   1492         // The debounce only suppresses refreshes that the live channel already
   1493         // covers. When the engagement websocket is live for this game, grid
   1494         // deltas arrive over it and the push-driven fetch is redundant, so
   1495         // collapsing a burst of pushes is harmless. When it is not live, the
   1496         // CK-push path is the only thing converging the grid — never skip it,
   1497         // or convergence stalls exactly when the live overlay is down.
   1498         guard engagementStatus.isLive(gameID: gameID) else { return false }
   1499         let key = puzzleGridFreshenKey(gameID: gameID, scope: scope)
   1500         guard let last = lastRemotePuzzleGridFreshenAt[key] else { return false }
   1501         let elapsed = Date().timeIntervalSince(last)
   1502         guard elapsed < remotePuzzleGridFreshenDebounce else { return false }
   1503         syncMonitor.note(
   1504             "freshen puzzle grid \(label): \(scopeLabel(scope)) \(gameID.uuidString.prefix(8)) skipped (recent remote refresh \(Int(elapsed.rounded()))s ago)"
   1505         )
   1506         return true
   1507     }
   1508 
   1509     private func noteRemotePuzzleGridFreshenCompleted(gameID: UUID, scope: CKDatabase.Scope) {
   1510         lastRemotePuzzleGridFreshenAt[puzzleGridFreshenKey(gameID: gameID, scope: scope)] = Date()
   1511     }
   1512 
   1513     private func puzzleGridFreshenKey(gameID: UUID, scope: CKDatabase.Scope) -> String {
   1514         "\(scopeLabel(scope)):\(gameID.uuidString)"
   1515     }
   1516 
   1517     private func scopeLabel(_ scope: CKDatabase.Scope) -> String {
   1518         switch scope {
   1519         case .private:
   1520             return "private"
   1521         case .shared:
   1522             return "shared"
   1523         case .public:
   1524             return "public"
   1525         @unknown default:
   1526             return "unknown"
   1527         }
   1528     }
   1529 
   1530     func makePlayerRoster(for gameID: UUID, preferences: PlayerPreferences) -> PlayerRoster {
   1531         PlayerRoster(
   1532             gameID: gameID,
   1533             authorIdentity: identity,
   1534             preferences: preferences,
   1535             persistence: persistence,
   1536             container: ckContainer,
   1537             engagementStore: engagementStore,
   1538             tracer: { [syncMonitor] message in syncMonitor.note(message) }
   1539         )
   1540     }
   1541 
   1542     private func handleRemoteNotification(
   1543         summary: String,
   1544         scope: CKDatabase.Scope?,
   1545         event: PushPayload.Event?,
   1546         gameID: UUID?,
   1547         kind: String?,
   1548         senderDeviceID: String?,
   1549         readAt: Date?,
   1550         isBackground: Bool
   1551     ) async {
   1552         // Authoritative foreground correction. A content-available push is, by
   1553         // definition, not the user looking at this app, so when the OS reports
   1554         // we're backgrounded, pull the cached `isAppForeground` flag false now.
   1555         // `scenePhase`'s `.onChange` — the flag's only other writer — never
   1556         // fires for the *initial* phase of a process launched or woken straight
   1557         // into the background, so the flag otherwise keeps its optimistic `true`
   1558         // default and every background wake slips past the presence and
   1559         // engagement foreground gates: re-arming a departed peer's read lease
   1560         // (the ghost) and re-dialling the live socket it can't sustain. Only
   1561         // ever downgrade here — a genuine foreground is restored by the
   1562         // `.active` scenePhase transition, never by a push.
   1563         if isBackground {
   1564             noteAppForeground(false)
   1565         }
   1566         guard preferences.isICloudSyncEnabled else {
   1567             syncMonitor.note("remote notification ignored while iCloud sync is disabled")
   1568             return
   1569         }
   1570         guard await ensureICloudSyncStarted() else { return }
   1571         lastRemoteNotificationAt = Date()
   1572         syncMonitor.note("remote notification: \(summary)")
   1573 
   1574         if await handleAccountControlPush(
   1575             kind: kind,
   1576             gameID: gameID,
   1577             senderDeviceID: senderDeviceID,
   1578             readAt: readAt
   1579         ) {
   1580             return
   1581         }
   1582 
   1583         if event == .replay {
   1584             let label = gameID.map { String($0.uuidString.prefix(8)) } ?? "unknown"
   1585             syncMonitor.note("push(replay): syncing game \(label)")
   1586             await syncMonitor.run("replay push fetch") {
   1587                 try await syncEngine.fetchChanges(source: "replay push")
   1588             }
   1589             await refreshSnapshot()
   1590             await reconcilePendingJournalUploads()
   1591             return
   1592         }
   1593 
   1594         guard let scope, scope != .public else {
   1595             await syncMonitor.run("remote-notification fetch") {
   1596                 try await syncEngine.fetchChanges(source: "push")
   1597             }
   1598             await refreshSnapshot()
   1599             return
   1600         }
   1601 
   1602         guard beginRemoteNotificationHandling(scope: scope) else { return }
   1603         defer { endRemoteNotificationHandling(scope: scope) }
   1604 
   1605         cancelBackgroundPushCatchUp(scope: scope)
   1606 
   1607         // A share accept is downloading the puzzle asset on the joining screen.
   1608         // Don't fan out a session scan, zone discovery, or fetchChanges against
   1609         // the shared database while it does — the deferred catch-up runs once
   1610         // the burst (and the join) settle.
   1611         if scope == .shared, isAcceptingSharedGame {
   1612             syncMonitor.note("shared remote notification deferred during share acceptance")
   1613             scheduleBackgroundPushCatchUp(scope: scope)
   1614             await refreshSnapshot()
   1615             return
   1616         }
   1617 
   1618         if isBackground {
   1619             scheduleBackgroundSessionScan(scope: scope)
   1620             scheduleBackgroundPushCatchUp(scope: scope)
   1621             await refreshSnapshot()
   1622             return
   1623         }
   1624 
   1625         if isGameListVisible {
   1626             syncMonitor.note("remote notification: game list visible, refreshing list only")
   1627             await freshenGameList(scope: scope, reason: .remote)
   1628             scheduleBackgroundPushCatchUp(scope: scope)
   1629             await refreshSnapshot()
   1630             return
   1631         }
   1632 
   1633         if let activeGameID = activeGameID(in: scope) {
   1634             // Hot path: collaborator activity on the open puzzle. The Puzzle
   1635             // Grid surface owns the direct Game/Moves/Player fetch so push
   1636             // handling and open-puzzle polling coalesce instead of duplicating
   1637             // the same active-zone query.
   1638             syncMonitor.note("remote notification: active puzzle visible, refreshing game only")
   1639             await freshenPuzzleGrid(
   1640                 gameID: activeGameID,
   1641                 scope: scope,
   1642                 reason: .remote
   1643             )
   1644         } else {
   1645             // Cold path: no puzzle open. Discover any zones this device
   1646             // hasn't seen yet (e.g. a freshly-accepted share or a game
   1647             // started on another device of the same iCloud user). The broader
   1648             // game/moves catch-up is delayed below so a cold push doesn't fan
   1649             // out multiple immediate CloudKit read paths.
   1650             await syncMonitor.run("remote-notification zone discovery") {
   1651                 _ = try await self.syncEngine.discoverNewZonesDirect(scope: scope)
   1652             }
   1653             scheduleBackgroundPushCatchUp(scope: scope)
   1654         }
   1655 
   1656         await refreshSnapshot()
   1657     }
   1658 
   1659     private func handleAccountControlPush(
   1660         kind: String?,
   1661         gameID: UUID?,
   1662         senderDeviceID: String?,
   1663         readAt: Date?
   1664     ) async -> Bool {
   1665         guard let kind,
   1666               kind == AccountPushCoordinator.accountJoinedPushKind || kind == AccountPushCoordinator.accountSeenPushKind
   1667         else { return false }
   1668         if senderDeviceID == RecordSerializer.localDeviceID {
   1669             syncMonitor.note("push(\(kind)): ignored self-send")
   1670             return true
   1671         }
   1672         guard let gameID else {
   1673             syncMonitor.note("push(\(kind)): ignored (no gameID)")
   1674             return true
   1675         }
   1676 
   1677         switch kind {
   1678         case AccountPushCoordinator.accountJoinedPushKind:
   1679             syncMonitor.note("push(accountJoined): sibling joined \(gameID.uuidString.prefix(8))")
   1680             await syncMonitor.run("account-joined shared discovery") {
   1681                 try await syncEngine.fetchChanges(source: "account joined")
   1682             }
   1683             await freshenGameList(scope: .shared, reason: .remote)
   1684             await accountPush.reconcilePushRegistration()
   1685             await refreshSnapshot()
   1686         case AccountPushCoordinator.accountSeenPushKind:
   1687             guard let readAt else {
   1688                 syncMonitor.note("push(accountSeen): ignored (no readAt)")
   1689                 return true
   1690             }
   1691             let (previous, adopted) = store.noteIncomingReadCursor(gameID: gameID, readAt: readAt)
   1692             syncMonitor.note(
   1693                 "push(accountSeen): sibling saw \(gameID.uuidString.prefix(8)) " +
   1694                 "readAt=\(readAt.ISO8601Format()) " +
   1695                 "was=\(previous?.ISO8601Format() ?? "—")" +
   1696                 (adopted ? "" : " (no-op)")
   1697             )
   1698             // The catch-up baseline is no longer recomputed here — it arrives,
   1699             // accurate, on the sibling's `Player.sessionSnapshot` via the
   1700             // record sync this push's companion DB change triggers. This fast
   1701             // push is now only the cross-device notification-dismissal signal.
   1702             // A forward-dated readAt is an active presence lease: the sibling is
   1703             // in this game right now and has seen its events live, so retract
   1704             // even the unread-marking notifications (win/resign/pause) from this
   1705             // device — the "soon-swept" half of sending to every device. A past
   1706             // readAt is a plain read watermark (the sibling left), where we still
   1707             // preserve genuinely-unread alerts.
   1708             let siblingPresent = readAt > Date()
   1709             await badge.dismissDeliveredNotifications(
   1710                 for: gameID,
   1711                 seenAt: readAt,
   1712                 publishAccountSeen: false,
   1713                 preserveUnread: !siblingPresent
   1714             )
   1715         default:
   1716             break
   1717         }
   1718         return true
   1719     }
   1720 
   1721     private func activePuzzleGridTarget() -> (UUID, CKDatabase.Scope)? {
   1722         guard let entity = store.currentEntity,
   1723               let gameID = entity.id
   1724         else { return nil }
   1725         switch entity.databaseScope {
   1726         case 0:
   1727             return (gameID, .private)
   1728         case 1:
   1729             return (gameID, .shared)
   1730         default:
   1731             return nil
   1732         }
   1733     }
   1734 
   1735     private func beginRemoteNotificationHandling(scope: CKDatabase.Scope) -> Bool {
   1736         switch scope {
   1737         case .private:
   1738             guard !isHandlingPrivateRemoteNotification else {
   1739                 syncMonitor.note("private remote notification coalesced into in-flight handler")
   1740                 return false
   1741             }
   1742             isHandlingPrivateRemoteNotification = true
   1743             return true
   1744         case .shared:
   1745             guard !isHandlingSharedRemoteNotification else {
   1746                 syncMonitor.note("shared remote notification coalesced into in-flight handler")
   1747                 return false
   1748             }
   1749             isHandlingSharedRemoteNotification = true
   1750             return true
   1751         case .public:
   1752             return false
   1753         @unknown default:
   1754             return false
   1755         }
   1756     }
   1757 
   1758     private func endRemoteNotificationHandling(scope: CKDatabase.Scope) {
   1759         switch scope {
   1760         case .private:
   1761             isHandlingPrivateRemoteNotification = false
   1762         case .shared:
   1763             isHandlingSharedRemoteNotification = false
   1764         case .public:
   1765             return
   1766         @unknown default:
   1767             return
   1768         }
   1769     }
   1770 
   1771     private func cancelBackgroundPushCatchUp(scope: CKDatabase.Scope) {
   1772         switch scope {
   1773         case .private:
   1774             privatePushCatchUpTask?.cancel()
   1775             privatePushCatchUpTask = nil
   1776         case .shared:
   1777             sharedPushCatchUpTask?.cancel()
   1778             sharedPushCatchUpTask = nil
   1779         case .public:
   1780             return
   1781         @unknown default:
   1782             return
   1783         }
   1784     }
   1785 
   1786     private func scheduleBackgroundPushCatchUp(scope: CKDatabase.Scope) {
   1787         switch scope {
   1788         case .private:
   1789             privatePushCatchUpTask?.cancel()
   1790             privatePushCatchUpTask = makeBackgroundPushCatchUpTask(scope: scope, label: "private")
   1791         case .shared:
   1792             sharedPushCatchUpTask?.cancel()
   1793             sharedPushCatchUpTask = makeBackgroundPushCatchUpTask(scope: scope, label: "shared")
   1794         case .public:
   1795             return
   1796         @unknown default:
   1797             return
   1798         }
   1799     }
   1800 
   1801     /// Trailing-edge window over which a burst of background pushes is collapsed
   1802     /// into a single session scan. A collaborator playing live writes a record
   1803     /// every second or two; running the full-zone scan per record turned a
   1804     /// backgrounded join into minutes of back-to-back fetches.
   1805     private static let backgroundSessionScanDebounce: UInt64 = 5_000_000_000
   1806 
   1807     /// Coalesces background-push session scans. Unlike the catch-up scheduler
   1808     /// this does *not* cancel a pending scan — under sustained activity a
   1809     /// cancel-and-reschedule would push the scan out indefinitely and starve
   1810     /// presence. The first push arms a scan; later pushes within the window are
   1811     /// no-ops; the task clears its own handle when it runs.
   1812     private func scheduleBackgroundSessionScan(scope: CKDatabase.Scope) {
   1813         switch scope {
   1814         case .private:
   1815             guard privateSessionScanTask == nil else { return }
   1816             privateSessionScanTask = makeBackgroundSessionScanTask(scope: scope)
   1817         case .shared:
   1818             guard sharedSessionScanTask == nil else { return }
   1819             sharedSessionScanTask = makeBackgroundSessionScanTask(scope: scope)
   1820         case .public:
   1821             return
   1822         @unknown default:
   1823             return
   1824         }
   1825     }
   1826 
   1827     private func clearBackgroundSessionScanTask(scope: CKDatabase.Scope) {
   1828         switch scope {
   1829         case .private:
   1830             privateSessionScanTask = nil
   1831         case .shared:
   1832             sharedSessionScanTask = nil
   1833         case .public:
   1834             return
   1835         @unknown default:
   1836             return
   1837         }
   1838     }
   1839 
   1840     private func makeBackgroundSessionScanTask(scope: CKDatabase.Scope) -> Task<Void, Never> {
   1841         Task { @MainActor in
   1842             defer { clearBackgroundSessionScanTask(scope: scope) }
   1843             do {
   1844                 try await Task.sleep(nanoseconds: Self.backgroundSessionScanDebounce)
   1845             } catch {
   1846                 return
   1847             }
   1848             guard !Task.isCancelled else { return }
   1849             guard await ensureICloudSyncStarted() else { return }
   1850             let result = await syncMonitor.run("remote-notification background session scan") {
   1851                 try await syncEngine.fetchBackgroundSessionsDirect(scope: scope)
   1852             }
   1853             if let result {
   1854                 // The receiver-side `presentBegins` path is no longer wired
   1855                 // up. The catch-up banner that summarises peer adds/clears
   1856                 // still consumes the SessionMonitor buckets via `consumeOnOpen`
   1857                 // — see `handlePuzzleOpened`.
   1858                 if result.isEmpty {
   1859                     syncMonitor.note("remote-notification background session scan: no active sessions")
   1860                 }
   1861             }
   1862             await refreshSnapshot()
   1863         }
   1864     }
   1865 
   1866     private func makeBackgroundPushCatchUpTask(
   1867         scope: CKDatabase.Scope,
   1868         label: String
   1869     ) -> Task<Void, Never> {
   1870         syncMonitor.note("\(label) game/moves catch-up scheduled")
   1871         return Task { @MainActor in
   1872             let shortMoveCount = await runBackgroundPushCatchUp(
   1873                 scope: scope,
   1874                 label: label,
   1875                 delayNanoseconds: 5_000_000_000,
   1876                 phaseSuffix: "short"
   1877             )
   1878             guard !Task.isCancelled else { return }
   1879             guard shortMoveCount == 0 else {
   1880                 syncMonitor.note(
   1881                     "\(label) game/moves catch-up long skipped after short fetched \(shortMoveCount) move record(s)"
   1882                 )
   1883                 return
   1884             }
   1885             _ = await runBackgroundPushCatchUp(
   1886                 scope: scope,
   1887                 label: label,
   1888                 delayNanoseconds: 15_000_000_000,
   1889                 phaseSuffix: "long"
   1890             )
   1891         }
   1892     }
   1893 
   1894     private func runBackgroundPushCatchUp(
   1895         scope: CKDatabase.Scope,
   1896         label: String,
   1897         delayNanoseconds: UInt64,
   1898         phaseSuffix: String
   1899     ) async -> Int {
   1900         do {
   1901             try await Task.sleep(nanoseconds: delayNanoseconds)
   1902         } catch {
   1903             return 0
   1904         }
   1905         guard !Task.isCancelled else { return 0 }
   1906         guard await ensureICloudSyncStarted() else { return 0 }
   1907         guard beginGameListFreshen(
   1908             scope: scope,
   1909             label: label,
   1910             reason: "remote \(phaseSuffix) catch-up"
   1911         ) else {
   1912             return 0
   1913         }
   1914         defer { endGameListFreshen(scope: scope) }
   1915 
   1916         let moveCount = await syncMonitor.run("remote-notification \(label) game/moves catch-up \(phaseSuffix)") {
   1917             try await syncEngine.fetchKnownGameMovesDirect(scope: scope)
   1918         }
   1919         await refreshSnapshot()
   1920         return moveCount ?? 0
   1921     }
   1922 
   1923     private func activeGameID(in scope: CKDatabase.Scope) -> UUID? {
   1924         guard let target = activePuzzleGridTarget() else { return nil }
   1925         return target.1 == scope ? target.0 : nil
   1926     }
   1927 
   1928     private func ensureICloudSyncStarted() async -> Bool {
   1929         guard preferences.isICloudSyncEnabled else { return false }
   1930         guard !syncStarted else { return true }
   1931         if let inFlight = syncStartTask { return await inFlight.value }
   1932 
   1933         let task = Task { @MainActor in
   1934             await identity.refresh(using: ckContainer)
   1935             pushClient?.updateAuthorID(identity.currentID)
   1936             await syncEngine.start()
   1937             syncStarted = true
   1938 
   1939             let recoveredMoveCount = await syncEngine.enqueueUnconfirmedMoves()
   1940             if recoveredMoveCount > 0 {
   1941                 syncMonitor.note("recovered \(recoveredMoveCount) unconfirmed move(s) for CloudKit enqueue")
   1942             }
   1943             isReadyForShareAcceptance = true
   1944             await processPendingShareAcceptances()
   1945             // Only when this device has nothing cached to derive from is
   1946             // `reconcilePushRegistration` about to *mint* a fresh secret/address
   1947             // — and minting before a sibling's already-published value has been
   1948             // fetched is what enqueues divergent per-game addresses that briefly
   1949             // clobber the converged set. `start()` only constructs the engines,
   1950             // so fetch the account zone first in exactly that case, letting the
   1951             // inbound path (`onAccountPushSecret`) adopt and cache the winner so
   1952             // reconcile derives from it instead of minting. On every later
   1953             // launch the value is already cached, so this is skipped and startup
   1954             // is unchanged. If the fetch throws, `run` swallows it and reconcile
   1955             // still runs (no worse than before).
   1956             if let authorID = identity.currentID, !authorID.isEmpty,
   1957                !accountPush.hasCachedAccountPushCredentials(authorID: authorID) {
   1958                 await syncMonitor.run("startup account sync") {
   1959                     try await syncEngine.fetchChanges(source: "startup")
   1960                 }
   1961             }
   1962             await accountPush.reconcilePushRegistration()
   1963             return true
   1964         }
   1965         syncStartTask = task
   1966         let result = await task.value
   1967         syncStartTask = nil
   1968         return result
   1969     }
   1970 
   1971     /// Parses the silent-push payload into a short, human-readable summary
   1972     /// (database scope, notification type, subscription ID, pruned flag).
   1973     /// Used by the diagnostics log to confirm whether shared-DB pushes are
   1974     /// actually being delivered to the device.
   1975     static func describePush(userInfo: [AnyHashable: Any]) -> String {
   1976         guard let note = CKNotification(fromRemoteNotificationDictionary: userInfo) else {
   1977             let kind = (userInfo["kind"] as? String) ?? "<nil>"
   1978             let gameID = (userInfo["gameID"] as? String) ?? "<nil>"
   1979             return "custom kind=\(kind) gameID=\(gameID)"
   1980         }
   1981         let kind: String
   1982         let scope: CKDatabase.Scope?
   1983         switch note {
   1984         case let n as CKDatabaseNotification:
   1985             kind = "database"
   1986             scope = n.databaseScope
   1987         case let n as CKRecordZoneNotification:
   1988             kind = "recordZone"
   1989             scope = n.databaseScope
   1990         case let n as CKQueryNotification:
   1991             kind = "query(\(n.queryNotificationReason.rawValue))"
   1992             scope = n.databaseScope
   1993         default:
   1994             kind = "type(\(note.notificationType.rawValue))"
   1995             scope = nil
   1996         }
   1997         let scopeLabel: String
   1998         switch scope {
   1999         case .private: scopeLabel = "private"
   2000         case .shared: scopeLabel = "shared"
   2001         case .public: scopeLabel = "public"
   2002         case .none: scopeLabel = "n/a"
   2003         case .some(let other): scopeLabel = "scope(\(other.rawValue))"
   2004         }
   2005         let sub = note.subscriptionID ?? "<nil>"
   2006         return "scope=\(scopeLabel) kind=\(kind) sub=\(sub) pruned=\(note.isPruned)"
   2007     }
   2008 
   2009     static func databaseScope(fromPush userInfo: [AnyHashable: Any]) -> CKDatabase.Scope? {
   2010         guard let note = CKNotification(fromRemoteNotificationDictionary: userInfo) else {
   2011             return nil
   2012         }
   2013         switch note {
   2014         case let n as CKDatabaseNotification:
   2015             return n.databaseScope
   2016         case let n as CKRecordZoneNotification:
   2017             return n.databaseScope
   2018         case let n as CKQueryNotification:
   2019             return n.databaseScope
   2020         default:
   2021             return nil
   2022         }
   2023     }
   2024 
   2025     private func processPendingShareAcceptances() async {
   2026         guard isReadyForShareAcceptance, !isProcessingShareAcceptanceQueue else { return }
   2027         isProcessingShareAcceptanceQueue = true
   2028         isAcceptingSharedGame = true
   2029         defer {
   2030             isProcessingShareAcceptanceQueue = false
   2031             isAcceptingSharedGame = false
   2032         }
   2033 
   2034         while !pendingShareMetadatas.isEmpty {
   2035             let metadata = pendingShareMetadatas.removeFirst()
   2036             do {
   2037                 let outcome = try await cloudService.acceptShare(metadata: metadata)
   2038                 // Accepted but the puzzle hasn't synced in yet — reassure the
   2039                 // user it's coming, mirroring the link-tap path.
   2040                 if case .pendingSync = outcome {
   2041                     announcements.post(.puzzleStillSyncing())
   2042                 }
   2043             } catch {
   2044                 // The CloudService already recorded the detailed CloudKit
   2045                 // failure; OS-delivered share acceptances have no caller to
   2046                 // surface the error to, so keep draining the queue.
   2047             }
   2048         }
   2049     }
   2050 
   2051     private func refreshSnapshot() async {
   2052         let snapshot = await syncEngine.diagnosticSnapshot()
   2053         syncMonitor.updateSnapshot(snapshot)
   2054     }
   2055 
   2056     /// Publishes this account's read horizon for other-author moves by
   2057     /// updating `GameEntity.lastReadOtherMoveAt` and re-enqueuing its Player
   2058     /// record. Active puzzle sessions write a future lease and refresh it
   2059     /// only when less than `readLeaseRefreshFloor` remains; exits/background
   2060     /// write the current time, which can intentionally close that lease.
   2061     /// Records the app's foreground/active state. The one place the "user is
   2062     /// actively using the app" fact is set; `publishReadCursor` reads it to
   2063     /// decide whether a presence-lease renewal is legitimate.
   2064     func noteAppForeground(_ foreground: Bool) {
   2065         isAppForeground = foreground
   2066     }
   2067 
   2068     func publishReadCursor(
   2069         for gameID: UUID,
   2070         mode: ReadCursorPublishMode = .activeLease,
   2071         requireActivePuzzle: Bool = false
   2072     ) async {
   2073         guard let authorID = identity.currentID, !authorID.isEmpty else { return }
   2074         // A read lease asserts "the user is actively present on this puzzle," so
   2075         // only a foregrounded app may advance it. A background CKSyncEngine wake
   2076         // must never re-arm presence — that is what resurrected a departed
   2077         // peer's cursor and held the engagement room open. The `.currentTime`
   2078         // collapse is always allowed: we must be able to *end* the lease on the
   2079         // way to the background.
   2080         if case .activeLease = mode, !isAppForeground {
   2081             syncMonitor.note("readCursor(activeLease) skipped for \(gameID.uuidString): backgrounded")
   2082             return
   2083         }
   2084         // A re-assert triggered by an inbound sibling close (`requireActivePuzzle`)
   2085         // is decided before an `await`, so the user may have left the puzzle in
   2086         // the gap. Re-check here, synchronously in the write's critical section,
   2087         // that this is still the puzzle on screen. The strict `activePuzzleID`
   2088         // (no leave-grace tail) flips synchronously on `.onDisappear`/`.background`,
   2089         // so a concurrent leave deterministically wins and we never strand a
   2090         // future lease on a puzzle no device is actually viewing.
   2091         if case .activeLease = mode,
   2092            requireActivePuzzle,
   2093            NotificationState.activePuzzleID() != gameID {
   2094             syncMonitor.note("readCursor(activeLease) skipped for \(gameID.uuidString): not active puzzle")
   2095             return
   2096         }
   2097         let now = Date()
   2098         let didUpdate: Bool
   2099         switch mode {
   2100         case .activeLease:
   2101             let readAt = now.addingTimeInterval(Self.readLeaseDuration)
   2102             didUpdate = store.setReadCursor(
   2103                 gameID: gameID,
   2104                 readAt: readAt,
   2105                 minimumExistingReadAt: now.addingTimeInterval(Self.readLeaseRefreshFloor)
   2106             )
   2107             if didUpdate {
   2108                 // Ghost-peer probe: every active-session lease passes through
   2109                 // here. `suppressed` is "is this device actually viewing this
   2110                 // puzzle right now." A mint with suppressed=false is a presence
   2111                 // lease asserted while the user isn't looking — the resurrection
   2112                 // — and the foreground flag tells us which gate let it through.
   2113                 syncMonitor.note(
   2114                     "lease MINT[\(gameID.uuidString.prefix(8))] " +
   2115                     "readAt=\(readAt.ISO8601Format()) foreground=\(isAppForeground) " +
   2116                     "suppressed=\(NotificationState.isSuppressed(gameID: gameID))"
   2117                 )
   2118                 // Mirror the renewed lease into the badge ledger so an NSE
   2119                 // push landing mid-session stays suppressed past the open's
   2120                 // initial horizon (the open stamps one via
   2121                 // `dismissDeliveredNotifications`; this covers the refreshes).
   2122                 BadgeState.adoptReadHorizon(gameID: gameID, horizon: readAt)
   2123                 await accountPush.publishAccountSeenPush(gameID: gameID, readAt: readAt)
   2124             }
   2125         case .currentTime:
   2126             // Leaving / backgrounding: collapse the presence lease to now and,
   2127             // in lockstep, advance the read watermark to now — the user was
   2128             // looking right up to here, so they've seen everything through now.
   2129             // The watermark write is what stops a peer re-summarising moves we
   2130             // saw live just before leaving; it never reaches into the future.
   2131             let collapsed = store.setReadCursor(gameID: gameID, readAt: now)
   2132             let advanced = store.advanceReadThrough(gameID: gameID, through: now)
   2133             // Collapse the badge ledger's suppression horizon in the same
   2134             // lockstep. This write is local (App Group defaults), so it lands
   2135             // even when the CloudKit lease collapse doesn't — an NSE push
   2136             // arriving a minute after leave badges instead of being swallowed
   2137             // for the rest of the lease window.
   2138             BadgeState.markSeen(gameID: gameID, at: now)
   2139             BadgeState.collapseSuppression(gameID: gameID, to: now)
   2140             didUpdate = collapsed || advanced
   2141         }
   2142         guard didUpdate else { return }
   2143         let reason: String
   2144         let drain: Bool
   2145         switch mode {
   2146         case .activeLease:
   2147             reason = "readCursor(activeLease)"
   2148             drain = true
   2149         case .currentTime:
   2150             // Exit/background cursor: enqueue durably but don't force a send.
   2151             // Live presence rides the engagement socket; CloudKit carries the
   2152             // cursor on its own schedule, off the scarce suspension budget.
   2153             reason = "readCursor(currentTime)"
   2154             drain = false
   2155         }
   2156         await syncEngine.enqueuePlayer(
   2157             gameID: gameID,
   2158             authorID: authorID,
   2159             reason: reason,
   2160             drain: drain
   2161         )
   2162     }
   2163 
   2164     /// Diagnostic: logs each participant's `Player.readAt` lease for `gameID`
   2165     /// at open, so a lingering peer cursor or engagement room can be reasoned
   2166     /// about from the device log alone — the lease value is otherwise only
   2167     /// visible server-side. One line per player: self/peer, author prefix,
   2168     /// name, the raw `readAt` (UTC), and whether it currently reads as present
   2169     /// (`+Ns` until expiry) or lapsed (`Ns ago`).
   2170     func logPlayerLeaseSnapshot(gameID: UUID) async {
   2171         let localAuthorID = identity.currentID
   2172         let context = persistence.container.newBackgroundContext()
   2173         let lines: [String] = await withCheckedContinuation { continuation in
   2174             context.perform {
   2175                 let req = NSFetchRequest<PlayerEntity>(entityName: "PlayerEntity")
   2176                 req.predicate = NSPredicate(format: "game.id == %@", gameID as CVarArg)
   2177                 let now = Date()
   2178                 let players = (try? context.fetch(req)) ?? []
   2179                 let lines = players.map { player -> String in
   2180                     let author = player.authorID ?? "?"
   2181                     let isLocal = author == CKCurrentUserDefaultName
   2182                         || (localAuthorID.map { author == $0 } ?? false)
   2183                     let tag = isLocal ? "self" : "peer"
   2184                     let name = (player.name?.isEmpty == false) ? player.name! : "—"
   2185                     guard let readAt = player.readAt else {
   2186                         return "\(tag) \(author.prefix(8)) [\(name)] readAt=nil"
   2187                     }
   2188                     let delta = Int(readAt.timeIntervalSince(now))
   2189                     let state = delta > 0 ? "present, +\(delta)s" : "absent, \(-delta)s ago"
   2190                     return "\(tag) \(author.prefix(8)) [\(name)] readAt=\(readAt.ISO8601Format()) (\(state))"
   2191                 }
   2192                 continuation.resume(returning: lines)
   2193             }
   2194         }
   2195         syncMonitor.note("open lease snapshot \(gameID.uuidString.prefix(8)): \(lines.count) player(s)")
   2196         for line in lines {
   2197             syncMonitor.note("  \(line)")
   2198         }
   2199     }
   2200 
   2201     /// Builds the `GameStore.onGameDeleted` callback. Extracted so tests can
   2202     /// drive the exact same closure that production wires up — keeps the
   2203     /// cursor-cleanup branch from drifting silently. (Friend colours need no
   2204     /// cleanup: they are derived on the fly, never persisted per game.)
   2205     static func makeOnGameDeleted(
   2206         syncEngine: SyncEngine,
   2207         cursorStore: GameCursorStore? = nil,
   2208         viewedStore: GameViewedStore? = nil
   2209     ) -> (GameCloudDeletion) -> Void {
   2210         { deletion in
   2211             cursorStore?.clearCursor(forGame: deletion.gameID)
   2212             viewedStore?.clearLastViewed(forGame: deletion.gameID)
   2213             Task { await syncEngine.enqueueDeleteGame(deletion) }
   2214         }
   2215     }
   2216 
   2217     /// True iff some non-local participant in `gameID` currently holds a
   2218     /// valid read lease (`readAt` in the future). The active-lease cursor —
   2219     /// set ~10 minutes ahead while the puzzle is open and collapsed to `now`
   2220     /// on leave — is the presence signal: it survives think-time without
   2221     /// cursor movement and self-expires if a peer vanishes uncleanly, so a
   2222     /// solo solver in a shared puzzle stops treating a departed peer as
   2223     /// present within the lease window rather than on every paused minute.
   2224     static func hasPresentPeer(
   2225         persistence: PersistenceController,
   2226         gameID: UUID,
   2227         localAuthorID: String?
   2228     ) async -> Bool {
   2229         let context = persistence.container.newBackgroundContext()
   2230         return await withCheckedContinuation { continuation in
   2231             context.perform {
   2232                 let now = Date()
   2233                 let req = NSFetchRequest<PlayerEntity>(entityName: "PlayerEntity")
   2234                 req.predicate = NSPredicate(
   2235                     format: "game.id == %@ AND readAt > %@",
   2236                     gameID as CVarArg,
   2237                     PeerPresence.presenceCutoff(asOf: now) as NSDate
   2238                 )
   2239                 let players = (try? context.fetch(req)) ?? []
   2240                 let hasPeer = players.contains { player in
   2241                     guard let authorID = player.authorID, !authorID.isEmpty else { return false }
   2242                     if authorID == CKCurrentUserDefaultName { return false }
   2243                     if let localAuthorID, !localAuthorID.isEmpty, authorID == localAuthorID { return false }
   2244                     return PeerPresence.isPresent(readAt: player.readAt, asOf: now)
   2245                 }
   2246                 continuation.resume(returning: hasPeer)
   2247             }
   2248         }
   2249     }
   2250 
   2251     /// The earliest future `readAt` among non-local participants in `gameID` —
   2252     /// i.e. when the soonest peer lease lapses — or `nil` if no peer is
   2253     /// present. `nil` is the same condition `hasPresentPeer` reports as absent,
   2254     /// so callers can derive presence from this and also schedule against the
   2255     /// expiry instant.
   2256     static func soonestPeerLease(
   2257         persistence: PersistenceController,
   2258         gameID: UUID,
   2259         localAuthorID: String?
   2260     ) async -> Date? {
   2261         let context = persistence.container.newBackgroundContext()
   2262         return await withCheckedContinuation { continuation in
   2263             context.perform {
   2264                 let now = Date()
   2265                 let req = NSFetchRequest<PlayerEntity>(entityName: "PlayerEntity")
   2266                 req.predicate = NSPredicate(
   2267                     format: "game.id == %@ AND readAt > %@",
   2268                     gameID as CVarArg,
   2269                     PeerPresence.presenceCutoff(asOf: now) as NSDate
   2270                 )
   2271                 var soonest: Date?
   2272                 for player in (try? context.fetch(req)) ?? [] {
   2273                     guard let authorID = player.authorID, !authorID.isEmpty else { continue }
   2274                     if authorID == CKCurrentUserDefaultName { continue }
   2275                     if let localAuthorID, !localAuthorID.isEmpty, authorID == localAuthorID { continue }
   2276                     guard let readAt = player.readAt,
   2277                           PeerPresence.isPresent(readAt: readAt, asOf: now) else { continue }
   2278                     if soonest == nil || readAt < soonest! { soonest = readAt }
   2279                 }
   2280                 continuation.resume(returning: soonest)
   2281             }
   2282         }
   2283     }
   2284 
   2285     static func presentPeers(
   2286         persistence: PersistenceController,
   2287         gameIDs: Set<UUID>?,
   2288         localAuthorID: String?
   2289     ) async -> [UUID: [String]] {
   2290         let context = persistence.container.newBackgroundContext()
   2291         return await withCheckedContinuation { continuation in
   2292             context.perform {
   2293                 let now = Date()
   2294                 let req = NSFetchRequest<PlayerEntity>(entityName: "PlayerEntity")
   2295                 var predicates = [
   2296                     NSPredicate(format: "readAt > %@", PeerPresence.presenceCutoff(asOf: now) as NSDate)
   2297                 ]
   2298                 if let gameIDs, !gameIDs.isEmpty {
   2299                     predicates.append(NSPredicate(format: "game.id IN %@", Array(gameIDs)))
   2300                 }
   2301                 if let localAuthorID, !localAuthorID.isEmpty {
   2302                     predicates.append(NSPredicate(format: "authorID != %@", localAuthorID))
   2303                 }
   2304                 req.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: predicates)
   2305 
   2306                 var result: [UUID: Set<String>] = [:]
   2307                 for player in (try? context.fetch(req)) ?? [] {
   2308                     guard let gameID = player.game?.id,
   2309                           let authorID = player.authorID,
   2310                           !authorID.isEmpty,
   2311                           authorID != CKCurrentUserDefaultName,
   2312                           PeerPresence.isPresent(readAt: player.readAt, asOf: now) else { continue }
   2313                     result[gameID, default: []].insert(authorID)
   2314                 }
   2315                 continuation.resume(returning: result.mapValues { Array($0) })
   2316             }
   2317         }
   2318     }
   2319 
   2320 }