crossmate

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

AppServices.swift (133903B)


      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 friends 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 friend 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     /// Which library to seed. The two differ only in the collaborative
     16     /// showcase game's roster: development wants four players (you + three
     17     /// friends) so all four attribution tints can be eyeballed at once, while
     18     /// marketing must never depict more than three players.
     19     enum Profile {
     20         case development
     21         case marketing
     22     }
     23 
     24     /// The local user's authorID in demo mode, injected into `AuthorIdentity`
     25     /// so the seeded friends classify as remote players. Kept distinct from
     26     /// every friend id below.
     27     static let localAuthorID = "_demo-you"
     28 
     29     private static let alice = "_demo-alice"
     30     private static let bob = "_demo-bob"
     31     private static let carol = "_demo-carol"
     32 
     33     private static let friends: [(id: String, name: String)] = [
     34         (alice, "Alice"),
     35         (bob, "Bob"),
     36         (carol, "Carol"),
     37     ]
     38 
     39     /// One seeded game backed by a real bundled 15×15 starter. `fillAuthors`
     40     /// are the authors given filled cells (drives the desaturated attribution
     41     /// tints and, together with `participants`, the Game List colour strip);
     42     /// empty leaves the grid blank (or fully solved, when `completed`).
     43     private struct GameSpec {
     44         let title: String
     45         let resourceID: String
     46         let participants: [String]
     47         let fillAuthors: [String]
     48         let completed: Bool
     49     }
     50 
     51     /// The demo library for `profile`, backed by real bundled 15×15 starters so
     52     /// the Game List shows full grids rather than toy puzzles. Each game draws a
     53     /// distinct puzzle so the row thumbnails differ; the mix spans shared/solo
     54     /// and in-progress/completed to exercise every card state.
     55     private static func games(for profile: Profile) -> [GameSpec] {
     56         // The only per-profile difference: how many players share (and have
     57         // filled cells in) the in-progress showcase game.
     58         let showcase: GameSpec = switch profile {
     59         case .development:
     60             GameSpec(
     61                 title: "Tuesday Crossword",
     62                 resourceID: "cm-starter-0001",
     63                 participants: [alice, bob, carol],
     64                 fillAuthors: [localAuthorID, alice, bob, carol],
     65                 completed: false
     66             )
     67         case .marketing:
     68             GameSpec(
     69                 title: "Tuesday Crossword",
     70                 resourceID: "cm-starter-0001",
     71                 participants: [alice, bob],
     72                 fillAuthors: [localAuthorID, alice, bob],
     73                 completed: false
     74             )
     75         }
     76         return [
     77             showcase,
     78             GameSpec(
     79                 title: "Sunday Special",
     80                 resourceID: "cm-starter-0002",
     81                 participants: [alice, bob],
     82                 fillAuthors: [localAuthorID, alice, bob],
     83                 completed: false
     84             ),
     85             GameSpec(
     86                 title: "Coffee Break",
     87                 resourceID: "cm-starter-0003",
     88                 participants: [],
     89                 fillAuthors: [],
     90                 completed: true
     91             ),
     92             GameSpec(
     93                 title: "Weekend Challenge",
     94                 resourceID: "cm-starter-0004",
     95                 participants: [alice, carol],
     96                 fillAuthors: [],
     97                 completed: true
     98             ),
     99         ]
    100     }
    101 
    102     @MainActor
    103     static func populate(
    104         persistence: PersistenceController,
    105         preferences: PlayerPreferences,
    106         profile: Profile
    107     ) {
    108         let ctx = persistence.viewContext
    109 
    110         // Keep the Game List out of its "set your profile name" empty state.
    111         if !preferences.hasName {
    112             preferences.name = "You"
    113         }
    114 
    115         for friend in friends {
    116             seedFriend(friend, in: ctx)
    117         }
    118 
    119         // Reuse the catalog the new-game picker uses; it resolves each bundled
    120         // `.xd` for us and reads its source on demand.
    121         for spec in games(for: profile) {
    122             guard let entry = PuzzleCatalog.source(matchingResourceID: spec.resourceID, title: nil),
    123                   let source = try? entry.loadSource(),
    124                   let xd = try? XD.parse(source) else { continue }
    125             let puzzle = Puzzle(xd: xd)
    126             let game = seedGame(
    127                 title: spec.title,
    128                 resourceID: spec.resourceID,
    129                 participants: spec.participants,
    130                 puzzle: puzzle,
    131                 source: source,
    132                 completed: spec.completed,
    133                 in: ctx
    134             )
    135             if !spec.fillAuthors.isEmpty {
    136                 seedFilledLetters(in: game, puzzle: puzzle, authors: spec.fillAuthors, in: ctx)
    137             }
    138         }
    139 
    140         try? ctx.save()
    141     }
    142 
    143     private static func seedFriend(
    144         _ seed: (id: String, name: String),
    145         in ctx: NSManagedObjectContext
    146     ) {
    147         let friend = FriendEntity(context: ctx)
    148         friend.authorID = seed.id
    149         friend.createdAt = Date()
    150         friend.displayName = seed.name
    151         friend.displayNameVersion = 0
    152         friend.isBlocked = false
    153         friend.nickname = ""
    154         friend.nicknameVersion = 0
    155         friend.pairKey = "demo-pair-\(seed.id)"
    156     }
    157 
    158     @discardableResult
    159     private static func seedGame(
    160         title: String,
    161         resourceID: String,
    162         participants: [String],
    163         puzzle: Puzzle,
    164         source: String,
    165         completed: Bool,
    166         in ctx: NSManagedObjectContext
    167     ) -> GameEntity {
    168         let now = Date()
    169         let game = GameEntity(context: ctx)
    170         game.id = UUID()
    171         game.title = title
    172         game.puzzleSource = source
    173         game.puzzleParserVersion = Int64(XD.currentParserVersion)
    174         game.puzzleResourceID = resourceID
    175         game.createdAt = now
    176         game.updatedAt = now
    177         game.syncVersion = GameSyncVersion.current
    178         // A non-nil share record name is what marks the game as shared, which is
    179         // the gate for the Game List participant colour strip; a solo game (no
    180         // participants) leaves it nil.
    181         if !participants.isEmpty {
    182             game.ckShareRecordName = "demo-share-\(title)"
    183         }
    184         // A completed game is terminal: the Game List files it under "Completed"
    185         // and renders it as a fully-solved board.
    186         if completed {
    187             game.completedAt = now
    188             game.completedBy = participants.first ?? localAuthorID
    189         }
    190         game.populateCachedSummaryFields(from: puzzle)
    191 
    192         for authorID in participants {
    193             let player = PlayerEntity(context: ctx)
    194             player.game = game
    195             player.authorID = authorID
    196             player.name = friends.first { $0.id == authorID }?.name
    197             player.ckRecordName = "demo-player-\(title)-\(authorID)"
    198             player.updatedAt = now
    199         }
    200         return game
    201     }
    202 
    203     /// Fills a realistic share of `game`'s grid with correct letters, handing
    204     /// each cell to one of `authors` in small diagonal patches and leaving
    205     /// scattered gaps so the puzzle reads as in-progress. One `MovesEntity` per
    206     /// author carries that author's cells, exactly as a real co-solve would, so
    207     /// `GridStateMerger` rebuilds the attributed grid — and each filled cell
    208     /// renders that player's faint attribution tint. The author set also caps
    209     /// how many colours the Game List strip shows for this game.
    210     private static func seedFilledLetters(
    211         in game: GameEntity,
    212         puzzle: Puzzle,
    213         authors: [String],
    214         in ctx: NSManagedObjectContext
    215     ) {
    216         guard !authors.isEmpty else { return }
    217         let now = Date()
    218         var cellsByAuthor: [String: [GridPosition: TimestampedCell]] = [:]
    219 
    220         for r in 0..<puzzle.height {
    221             for c in 0..<puzzle.width {
    222                 let cell = puzzle.cells[r][c]
    223                 guard !cell.isBlock,
    224                       let solution = cell.solution,
    225                       !solution.isEmpty,
    226                       !solution.allSatisfy(\.isWhitespace)
    227                 else { continue }
    228                 // Leave roughly one cell in seven blank for an in-progress look.
    229                 if (r * 5 + c) % 7 == 0 { continue }
    230                 let author = authors[((r / 2) + (c / 2)) % authors.count]
    231                 cellsByAuthor[author, default: [:]][GridPosition(row: r, col: c)] =
    232                     TimestampedCell(
    233                         letter: solution.uppercased(),
    234                         mark: .none,
    235                         updatedAt: now,
    236                         authorID: author
    237                     )
    238             }
    239         }
    240 
    241         for (author, cells) in cellsByAuthor {
    242             let entity = MovesEntity(context: ctx)
    243             entity.game = game
    244             entity.authorID = author
    245             entity.deviceID = "demo-device-\(author)"
    246             entity.ckRecordName = "demo-moves-\(game.id?.uuidString ?? "")-\(author)"
    247             entity.cells = (try? MovesCodec.encode(cells)) ?? Data()
    248             entity.updatedAt = now
    249         }
    250     }
    251 }
    252 
    253 @MainActor
    254 final class AppServices {
    255     /// The process's one services instance, installed by `CrossmateApp.init`
    256     /// and owned by the App's `@State`. Exists for the app delegate's push
    257     /// path: on a background-only launch no scene ever activates, so the root
    258     /// view's startup task never calls `start` — the delegate drives startup
    259     /// through this reference instead. Weak because the App owns the
    260     /// instance's lifetime; this is a lookup, not a retain.
    261     static weak var current: AppServices?
    262 
    263     enum ReadCursorPublishMode {
    264         case activeLease
    265         case currentTime
    266     }
    267 
    268     private static let readLeaseDuration: TimeInterval = 10 * 60
    269     private static let readLeaseRefreshFloor: TimeInterval = 5 * 60
    270 
    271     enum FreshenReason {
    272         case appeared
    273         case foreground
    274         case manual
    275         case remote
    276 
    277         var diagnosticLabel: String {
    278             switch self {
    279             case .appeared: return "appeared"
    280             case .foreground: return "foreground"
    281             case .manual: return "manual"
    282             case .remote: return "remote"
    283             }
    284         }
    285     }
    286 
    287     let persistence: PersistenceController
    288     let store: GameStore
    289     let syncEngine: SyncEngine
    290     let eventLog: EventLog
    291     let syncMonitor: SyncMonitor
    292     let nytAuth: NYTAuthService
    293     let driveMonitor: DriveMonitor
    294     let nytFetcher: NYTPuzzleFetcher
    295     let inputMonitor: InputMonitor
    296     let movesUpdater: MovesUpdater
    297     let sessionMonitor: SessionMonitor
    298     let announcements: AnnouncementCenter
    299     let playerSelectionPublisher: PlayerSelectionPublisher
    300     let identity: AuthorIdentity
    301     let pushClient: PushClient?
    302     /// Per-game play-session lifecycle: begin/end grace timers, sender-side
    303     /// session pushes, and the catch-up banner. See `SessionCoordinator`.
    304     let sessions: SessionCoordinator
    305     /// Account-scoped push credentials (secret/address mint, rotation,
    306     /// inbound adoption) + push-worker registration; see
    307     /// `AccountPushCoordinator`.
    308     let accountPush: AccountPushCoordinator
    309     /// Finished-game replay loading and the per-session timeline cache; see
    310     /// `ReplayLoader`.
    311     let replays: ReplayLoader
    312     let shareController: ShareController
    313     let friendController: FriendController
    314     let gameArchiver: GameArchiver
    315     let cursorStore: GameCursorStore
    316     /// Device-local most-recently-used ordering for direct friend invites.
    317     let friendInviteRecency: FriendInviteRecencyStore
    318     /// Device-local record of when each game was last viewed; drives the
    319     /// "changed while you were away" cell borders. Never synced.
    320     let gameViewedStore: GameViewedStore
    321     /// Device-local onboarding-tip state: which tips have been dismissed and
    322     /// whether tips are turned off. Drives the Game List tip banner and the
    323     /// Settings tips archive. Never synced.
    324     let tips: TipStore
    325     let engagementStore: EngagementStore
    326     let cloudService: CloudService
    327     let importService: ImportService
    328     let engagementHost: EngagementHost
    329     let engagementStatus = EngagementStatus()
    330     let inviteDeliveries = InviteDeliveryStore()
    331     private(set) lazy var appActions = AppActions(services: self)
    332     /// Live-channel lifecycle (room reconcile/mint, teardown/reconnect/
    333     /// lease-expiry timers, inbound channel events); see `EngagementLifecycle`.
    334     /// Lazy so its callbacks into the read-cursor and sync-start paths can
    335     /// capture `self`.
    336     private(set) lazy var engagement = EngagementLifecycle(
    337         preferences: preferences,
    338         persistence: persistence,
    339         store: store,
    340         identity: identity,
    341         syncMonitor: syncMonitor,
    342         engagementHost: engagementHost,
    343         engagementStatus: engagementStatus,
    344         engagementStore: engagementStore,
    345         isAppForeground: { [weak self] in self?.isAppForeground ?? false },
    346         renewReadLease: { [weak self] gameID in
    347             await self?.publishReadCursor(for: gameID, mode: .activeLease)
    348         },
    349         ensureICloudSyncStarted: { [weak self] in
    350             await self?.ensureICloudSyncStarted() ?? false
    351         }
    352     )
    353     /// App-icon badge + delivered-notification reconciliation; see
    354     /// `BadgeCoordinator`. Lazy so the account-seen fan-out can capture `self`.
    355     private(set) lazy var badge = BadgeCoordinator(
    356         store: store,
    357         syncMonitor: syncMonitor,
    358         readLeaseDuration: Self.readLeaseDuration,
    359         publishAccountSeenPush: { [weak self] gameID, presenceUntil in
    360             await self?.accountPush.publishAccountSeenPush(gameID: gameID, presenceUntil: presenceUntil)
    361         }
    362     )
    363     /// Friend-zone traffic — outbound invites, inbound ping handling, durable
    364     /// invite rows, friendship bootstrap, blocking; see `InviteCoordinator`.
    365     /// Lazy so the badge refresh can capture `self`.
    366     private(set) lazy var invites = InviteCoordinator(
    367         persistence: persistence,
    368         identity: identity,
    369         preferences: preferences,
    370         syncMonitor: syncMonitor,
    371         eventLog: eventLog,
    372         store: store,
    373         syncEngine: syncEngine,
    374         announcements: announcements,
    375         shareController: shareController,
    376         friendController: friendController,
    377         cloudService: cloudService,
    378         refreshAppBadge: { [weak self] reason in
    379             await self?.badge.refreshAppBadge(reason: reason)
    380         },
    381         publishInvitePush: { [weak self] friendAuthorID, gameID, title, inviterName in
    382             await self?.accountPush.publishInvitePush(
    383                 to: friendAuthorID,
    384                 gameID: gameID,
    385                 puzzleTitle: title,
    386                 inviterName: inviterName
    387             )
    388         }
    389     )
    390 
    391     let preferences: PlayerPreferences
    392 
    393     private let ckContainer = CloudContainer.container
    394     /// The process-wide startup operation. Retaining the task makes `start`
    395     /// both one-shot and awaitable: a caller that arrives while startup is in
    396     /// progress waits for the same readiness boundary instead of treating
    397     /// "startup entered" as "startup complete."
    398     private var startupTask: Task<Void, Never>?
    399     private var syncStarted = false
    400     /// In-flight `ensureICloudSyncStarted()` work, shared by concurrent
    401     /// callers so the cold-launch race between `services.start()` and a
    402     /// near-simultaneous `syncOnForeground()` doesn't admit two parallel
    403     /// `SyncEngine.start()` runs.
    404     private var syncStartTask: Task<Bool, Never>?
    405     private(set) var playerNamePublisher: PlayerNamePublisher?
    406     private var isReadyForShareAcceptance = false
    407     private var isProcessingShareAcceptanceQueue = false
    408     /// True while `processPendingShareAcceptances` is draining. A share accept
    409     /// holds the shared database to download the puzzle asset on the joining
    410     /// screen; shared-scope pushes that land in this window defer their heavy
    411     /// fan-out so collaborator activity doesn't contend with the join.
    412     private var isAcceptingSharedGame = false
    413     private var pendingShareMetadatas: [CKShare.Metadata] = []
    414     /// Wall-clock timestamp of the most recent inbound silent push. Bypasses
    415     /// the game-list freshen cooldown when a push has arrived since the last
    416     /// freshen, so a collaborator burst isn't held off by debounce.
    417     private var lastRemoteNotificationAt: Date?
    418     private var privatePushCatchUpTask: Task<Void, Never>?
    419     private var sharedPushCatchUpTask: Task<Void, Never>?
    420     private var privateSessionScanTask: Task<Void, Never>?
    421     private var sharedSessionScanTask: Task<Void, Never>?
    422     private var isHandlingPrivateRemoteNotification = false
    423     private var isHandlingSharedRemoteNotification = false
    424     private var gameListFreshenTask: Task<Void, Never>?
    425     /// Serialises Chronicle/Game metadata paging. Main-actor methods are
    426     /// reentrant across CloudKit awaits, so notification and pull-to-refresh
    427     /// requests must not reset the shared cursor during a Load More operation.
    428     private var completedPageTask: Task<GameArchiver.CompletedPage, Never>?
    429     private var completedPageTaskID: UUID?
    430     private var hasStartedRemoteCompletedMigration = false
    431     private var isFresheningPrivateGameList = false
    432     private var isFresheningSharedGameList = false
    433     /// The archive backstop can scan many completed shared games. Run it once
    434     /// after the first cold-launch game-list freshen, not on every foreground,
    435     /// manual refresh, or remote-triggered refresh.
    436     private var shouldRunColdLaunchArchiveReconcile = true
    437     /// Wall-clock timestamp of the last successful game-list freshen per
    438     /// scope, used to suppress redundant polls when no inbound push has
    439     /// arrived since. Pushes own freshness; the freshen (zone discovery +
    440     /// game/moves catch-up) is only a backstop for the case where Apple
    441     /// drops a silent push or a share-accept notification.
    442     private var lastPrivateGameListFreshenAt: Date?
    443     private var lastSharedGameListFreshenAt: Date?
    444     /// Maximum staleness budget for the game list before an unprompted view
    445     /// event re-runs the freshen. Bypassed by `.manual` and by any push that
    446     /// arrived after the last successful freshen.
    447     private let gameListFreshenCooldown: TimeInterval = 300
    448     private var fresheningPuzzleGridKeys: Set<String> = []
    449     private var lastRemotePuzzleGridFreshenAt: [String: Date] = [:]
    450     /// Collapses bursts of remote-push grid refreshes, but only while the
    451     /// engagement websocket is live for the game (see
    452     /// `shouldSkipRecentRemotePuzzleGridFreshen`). When the live channel is
    453     /// down, the push path is the sole convergence mechanism and is not
    454     /// debounced.
    455     private let remotePuzzleGridFreshenDebounce: TimeInterval = 5
    456     private var isGameListVisible = false
    457     /// Puzzle destinations currently mounted by SwiftUI. Unlike
    458     /// `GameStore.currentEntity`, this is cleared synchronously on disappearance,
    459     /// so Chronicle cleanup can distinguish a visible or transitioning puzzle
    460     /// from a stale last-loaded entity.
    461     private var mountedPuzzleCounts: [UUID: Int] = [:]
    462     /// Whether the app is foreground-active — the single source of truth for
    463     /// "the user is actively using the app." `publishReadCursor(.activeLease)`
    464     /// consults it so a background CKSyncEngine wake can never re-arm our
    465     /// presence lease. Fed from `RootView`'s scene-phase observer; defaults to
    466     /// `true` because the app launches into the foreground and `.onChange` does
    467     /// not fire for the initial phase.
    468     private(set) var isAppForeground = true
    469 
    470     /// Whether an account-change event warrants purging this device's local
    471     /// store. True only when both the previously-known and the freshly-resolved
    472     /// author IDs are known and differ — i.e. a real switch to a different
    473     /// iCloud account. A first sign-in has no previous ID, and a transient
    474     /// sign-out leaves `AuthorIdentity.refresh` a no-op (so the ID is
    475     /// unchanged); neither should wipe local data.
    476     static func accountSwitchRequiresPurge(previousID: String?, newID: String?) -> Bool {
    477         guard let previousID, let newID else { return false }
    478         return previousID != newID
    479     }
    480 
    481     init() {
    482         let eventLog = EventLog()
    483         self.eventLog = eventLog
    484         // `--crossmate-seed-demo` (set in the Run scheme's arguments) brings the
    485         // app up against a throwaway in-memory store pre-filled with a couple of
    486         // shared games and a few friends, purely so the Game List colour
    487         // strips and the friends list can be eyeballed in the Simulator without
    488         // iCloud. It never touches the real on-disk store. `--crossmate-seed-
    489         // marketing` seeds the same way but caps every game at three players,
    490         // for the import marketing screenshot's backdrop.
    491         let arguments = ProcessInfo.processInfo.arguments
    492         let seedProfile: DemoSeed.Profile? =
    493             if arguments.contains("--crossmate-seed-marketing") {
    494                 .marketing
    495             } else if arguments.contains("--crossmate-seed-demo") {
    496                 .development
    497             } else {
    498                 nil
    499             }
    500         let isDemoSeed = seedProfile != nil
    501         // The demo seed writes a scratch profile name; give it a throwaway
    502         // preferences store so that write can't sync into a real launch via the
    503         // shared iCloud key-value store.
    504         let preferences = isDemoSeed ? PlayerPreferences.ephemeral() : PlayerPreferences()
    505         self.preferences = preferences
    506         let persistence = PersistenceController(inMemory: isDemoSeed, eventLog: eventLog)
    507         self.persistence = persistence
    508         if let seedProfile {
    509             DemoSeed.populate(persistence: persistence, preferences: preferences, profile: seedProfile)
    510             // Preview the one-time v4 reset notice on demand
    511             // (`run-demo.sh --v4-notice`); set explicitly so a normal demo run
    512             // clears any leftover flag on the reused demo simulator.
    513             UserDefaults.standard.set(
    514                 ProcessInfo.processInfo.arguments.contains("--crossmate-show-v4-notice"),
    515                 forKey: Self.showV4NoticeDefaultsKey
    516             )
    517         }
    518         let syncEngine = SyncEngine(container: self.ckContainer, persistence: persistence)
    519         self.syncEngine = syncEngine
    520         self.syncMonitor = SyncMonitor(log: eventLog)
    521         self.driveMonitor = DriveMonitor()
    522         self.nytAuth = NYTAuthService(log: { message in
    523             eventLog.note(message)
    524         })
    525         self.nytFetcher = NYTPuzzleFetcher { NYTAuthService.currentCookieResult() }
    526         self.inputMonitor = InputMonitor()
    527         // In demo mode, inject a fixed local authorID so the seeded peers
    528         // classify as remote — otherwise, with no iCloud user, the roster comes
    529         // up empty and the puzzle scoreboard (and its nudge button) never
    530         // populate. A real launch always resolves the ID from CloudKit.
    531         let identity = isDemoSeed ? AuthorIdentity(testing: DemoSeed.localAuthorID) : AuthorIdentity()
    532         self.identity = identity
    533         let pushSyncMonitor = self.syncMonitor
    534         self.pushClient = PushClient(log: { message in
    535             Task { @MainActor in pushSyncMonitor.note(message) }
    536         })
    537         self.pushClient?.updateAuthorID(identity.currentID)
    538 
    539         let movesUpdater = MovesUpdater(
    540             debounceInterval: .milliseconds(500),
    541             persistence: persistence,
    542             writerAuthorIDProvider: { await MainActor.run { identity.currentID } },
    543             sink: { [persistence] gameIDs, drain in
    544                 // MovesUpdater bumps game.updatedAt on a background context.
    545                 // viewContext.automaticallyMergesChangesFromParent applies that
    546                 // change in-memory but doesn't reliably fire the ObjectsDidChange
    547                 // notification that @FetchRequest's NSFetchedResultsController
    548                 // listens for, so the library list keeps showing the stale
    549                 // "last updated" time until something else nudges the context.
    550                 // The inbound path is masked by noteIncomingMovesUpdate's
    551                 // explicit viewContext save; the outbound path has no analog.
    552                 // Refreshing the affected entities re-emits ObjectsDidChange
    553                 // with refreshedObjects, which NSFRC treats as a per-entity
    554                 // update — that path runs unconditionally so local-only games
    555                 // get the same nudge even when iCloud sync is off.
    556                 await MainActor.run {
    557                     let viewContext = persistence.viewContext
    558                     for gameID in gameIDs {
    559                         let req = NSFetchRequest<GameEntity>(entityName: "GameEntity")
    560                         req.predicate = NSPredicate(format: "id == %@", gameID as CVarArg)
    561                         req.fetchLimit = 1
    562                         guard let entity = try? viewContext.fetch(req).first else { continue }
    563                         viewContext.refresh(entity, mergeChanges: true)
    564                     }
    565                 }
    566                 let isEnabled = await MainActor.run { preferences.isICloudSyncEnabled }
    567                 guard isEnabled else { return }
    568                 await syncEngine.enqueueMoves(gameIDs: gameIDs, drain: drain)
    569             }
    570         )
    571         self.movesUpdater = movesUpdater
    572 
    573         self.announcements = AnnouncementCenter()
    574 
    575         let cursorStore = GameCursorStore()
    576         self.cursorStore = cursorStore
    577         self.friendInviteRecency = FriendInviteRecencyStore()
    578         let gameViewedStore = GameViewedStore()
    579         self.gameViewedStore = gameViewedStore
    580         self.tips = TipStore()
    581         let engagementStore = EngagementStore()
    582         self.engagementStore = engagementStore
    583         let onGameDeletedHandler = Self.makeOnGameDeleted(
    584             syncEngine: syncEngine,
    585             cursorStore: cursorStore,
    586             viewedStore: gameViewedStore
    587         )
    588 
    589         let store = GameStore(
    590             persistence: persistence,
    591             movesUpdater: movesUpdater,
    592             authorIDProvider: { identity.currentID },
    593             onGameCreated: { [preferences, syncEngine] ckRecordName in
    594                 Task {
    595                     guard await MainActor.run(body: { preferences.isICloudSyncEnabled }) else { return }
    596                     await syncEngine.enqueueGame(ckRecordName: ckRecordName)
    597                 }
    598             },
    599             onGameUpdated: { [preferences, syncEngine] ckRecordName in
    600                 Task {
    601                     guard await MainActor.run(body: { preferences.isICloudSyncEnabled }) else { return }
    602                     await syncEngine.enqueueGame(ckRecordName: ckRecordName)
    603                 }
    604             },
    605             onGameDeleted: { [preferences] deletion in
    606                 // Drop the badge ledger entry regardless of sync state — a
    607                 // deleted game has nothing left to open, so a stale unread
    608                 // horizon would count forever. `deleteGame` fires
    609                 // `onUnreadOtherMovesChanged` right after, which refreshes the
    610                 // app badge.
    611                 BadgeState.forget(gameID: deletion.gameID)
    612                 guard preferences.isICloudSyncEnabled else { return }
    613                 onGameDeletedHandler(deletion)
    614             },
    615             eventLog: eventLog
    616         )
    617         self.store = store
    618         // When an iCloud id first resolves after offline play, realign every
    619         // row authored under the device-local fallback to the real id — before
    620         // `ensureICloudSyncStarted` lets the sync engine push them.
    621         identity.onIdentityResolved = { [weak store] fallbackID, realID in
    622             store?.remapAuthorID(from: fallbackID, to: realID)
    623         }
    624         // Publishes resolve (and mint) the game's shared push credential from
    625         // the store so the worker can verify participation.
    626         self.pushClient?.gameCredentialResolver = { [weak store] gameID in
    627             store?.ensurePushCredentials(for: gameID)
    628         }
    629         // Publishes encrypt the structured payload under the game's content key,
    630         // which rides in the same notification credential the push secret does
    631         // (minted/backfilled on first use) so the worker only ever forwards
    632         // ciphertext for the personal fields.
    633         self.pushClient?.contentKeyResolver = { [weak store] gameID in
    634             guard let keyString = store?.ensurePushCredentials(for: gameID)?.contentKey
    635             else { return nil }
    636             return PushPayloadCipher.key(fromBase64: keyString)
    637         }
    638 
    639         let sessionMonitor = SessionMonitor(
    640             store: store,
    641             localAuthorIDProvider: { identity.currentID }
    642         )
    643         self.sessionMonitor = sessionMonitor
    644 
    645         self.sessions = SessionCoordinator(
    646             persistence: persistence,
    647             store: store,
    648             syncEngine: syncEngine,
    649             syncMonitor: self.syncMonitor,
    650             sessionMonitor: sessionMonitor,
    651             gameViewedStore: gameViewedStore,
    652             announcements: self.announcements,
    653             identity: identity,
    654             preferences: preferences,
    655             pushClient: self.pushClient
    656         )
    657 
    658         let accountPush = AccountPushCoordinator(
    659             identity: identity,
    660             preferences: preferences,
    661             persistence: persistence,
    662             store: store,
    663             syncEngine: syncEngine,
    664             syncMonitor: self.syncMonitor,
    665             pushClient: self.pushClient
    666         )
    667         self.accountPush = accountPush
    668         store.onPushRegistrationMayNeedRefresh = { [weak accountPush] in
    669             guard let accountPush else { return }
    670             Task { @MainActor in
    671                 await accountPush.reconcilePushRegistration()
    672             }
    673         }
    674 
    675         self.replays = ReplayLoader(
    676             store: store,
    677             syncEngine: syncEngine,
    678             syncMonitor: self.syncMonitor
    679         )
    680 
    681         self.shareController = ShareController(
    682             container: self.ckContainer,
    683             persistence: persistence,
    684             syncEngine: syncEngine,
    685             syncMonitor: self.syncMonitor
    686         )
    687         self.playerSelectionPublisher = PlayerSelectionPublisher(
    688             // While the live room carries the cursor over the websocket, the
    689             // durable write is a lagging fallback — throttle it hard. When the
    690             // room is down it is the peer's only delivery path, so keep it snappy.
    691             debounceInterval: { [engagementStatus] gameID in
    692                 engagementStatus.isLive(gameID: gameID) ? .milliseconds(2500) : .milliseconds(500)
    693             },
    694             persistence: persistence,
    695             sink: { gameID, authorID, drain in
    696                 let isEnabled = await MainActor.run { preferences.isICloudSyncEnabled }
    697                 guard isEnabled else { return }
    698                 await syncEngine.enqueuePlayer(
    699                     gameID: gameID,
    700                     authorID: authorID,
    701                     reason: "selection",
    702                     drain: drain
    703                 )
    704             },
    705             peerPresent: { [persistence, identity] gameID in
    706                 let localAuthorID = await MainActor.run { identity.currentID }
    707                 return await Self.hasPresentPeer(
    708                     persistence: persistence,
    709                     gameID: gameID,
    710                     localAuthorID: localAuthorID
    711                 )
    712             }
    713         )
    714         self.friendController = FriendController(
    715             container: self.ckContainer,
    716             persistence: persistence,
    717             syncEngine: syncEngine,
    718             syncMonitor: self.syncMonitor,
    719             eventLog: eventLog,
    720             publishFriendInvitationKey: { [accountPush] pairKey, zoneID, scope in
    721                 await accountPush.ensureFriendInvitationKeyPublished(
    722                     pairKey: pairKey,
    723                     friendZoneID: zoneID,
    724                     friendZoneScope: scope
    725                 )
    726             }
    727         )
    728         self.gameArchiver = GameArchiver(
    729             container: self.ckContainer,
    730             persistence: persistence,
    731             syncEngine: syncEngine,
    732             syncMonitor: self.syncMonitor,
    733             eventLog: eventLog,
    734             localIdentity: { [identity, preferences] in
    735                 guard let authorID = identity.currentID, !authorID.isEmpty else { return nil }
    736                 return (authorID, preferences.name)
    737             }
    738         )
    739         self.cloudService = CloudService(
    740             container: self.ckContainer,
    741             syncEngine: syncEngine,
    742             syncMonitor: self.syncMonitor,
    743             store: store,
    744             shareController: shareController
    745         )
    746         self.importService = ImportService(store: store, driveMonitor: self.driveMonitor)
    747         self.engagementHost = EngagementHost()
    748         self.engagementHost.onEvent = { [weak self] event in
    749             self?.engagement.handleEngagementEvent(event)
    750         }
    751         self.store.onLocalCellEdit = { [weak self] edit in
    752             self?.engagement.sendLocalCellEdit(edit)
    753         }
    754         self.store.onLocalCellEditBatch = { [weak self] edits in
    755             self?.engagement.sendLocalCellEdits(edits)
    756         }
    757         self.store.onJournalComplete = { [weak self] gameID, authorID, resigned, notifyPeers in
    758             if notifyPeers {
    759                 self?.sessions.stageCompletionDelivery(gameID: gameID, resigned: resigned)
    760             }
    761             self?.beginCompletionJournalUpload(gameID: gameID, authorID: authorID)
    762         }
    763     }
    764 
    765     private static let cloudGenerationKey = "cloudGeneration"
    766     private static let currentCloudGeneration = 4
    767     /// UserDefaults flag that drives the one-time v4 reset notice; read by
    768     /// `GameListView` via `@AppStorage`. See `NoticeView`.
    769     static let showV4NoticeDefaultsKey = "showV4Notice"
    770 
    771     /// Detects a device carrying data from the pre-v4 CloudKit container, wipes
    772     /// the local cache of it, and flags the one-time explanatory notice.
    773     /// Idempotent via a stored generation marker, so it runs at most once per
    774     /// device across the v3→v4 boundary. The wipe is *local only*
    775     /// (`purgeLocalData`): the v3 container is abandoned, not deleted — we can't
    776     /// reach co-owners' shared zones anyway, and dormant v3 data is harmless.
    777     private func migrateOffLegacyContainerIfNeeded() async {
    778         // The demo seed brings up a throwaway in-memory store that is not a real
    779         // v3→v4 transition — never purge it. (The notice can still be previewed
    780         // via the flag set at seed time.)
    781         guard !ProcessInfo.processInfo.arguments.contains("--crossmate-seed-demo")
    782         else { return }
    783         let defaults = UserDefaults.standard
    784         guard defaults.integer(forKey: Self.cloudGenerationKey) < Self.currentCloudGeneration
    785         else { return }
    786 
    787         if await deviceHasLegacyData() {
    788             do {
    789                 try await cloudService.purgeLocalData()
    790             } catch {
    791                 syncMonitor.note("v4 transition purge failed — \(error)")
    792             }
    793             defaults.set(true, forKey: Self.showV4NoticeDefaultsKey)
    794             syncMonitor.note("Migrated device off legacy CloudKit container — local cache cleared for v4")
    795         }
    796         defaults.set(Self.currentCloudGeneration, forKey: Self.cloudGenerationKey)
    797     }
    798 
    799     /// UserDefaults marker for the one-time v3 reclamation. Set only after a
    800     /// clean sweep, so a run that was offline, signed out, or partially
    801     /// rejected is retried on the next launch instead of being written off.
    802     private static let legacyCloudReclaimedKey = "legacyCloudDataReclaimed"
    803 
    804     /// Deletes this account's remaining v3 records, once, in the background.
    805     /// Silent by design: the v4 notice already explained that pre-v4 games are
    806     /// gone, so there is nothing further to tell the user — the work only
    807     /// returns storage that is already unreachable.
    808     private func reclaimLegacyCloudStorageIfNeeded() {
    809         // The demo seed runs against a throwaway store and must never touch a
    810         // real account's cloud data.
    811         guard !ProcessInfo.processInfo.arguments.contains("--crossmate-seed-demo")
    812         else { return }
    813         let defaults = UserDefaults.standard
    814         guard !defaults.bool(forKey: Self.legacyCloudReclaimedKey) else { return }
    815 
    816         Task { [syncEngine, syncMonitor] in
    817             let swept = await syncEngine.deleteLegacyCloudData { message in
    818                 syncMonitor.note(message)
    819             }
    820             guard swept else { return }
    821             UserDefaults.standard.set(true, forKey: Self.legacyCloudReclaimedKey)
    822         }
    823     }
    824 
    825     /// Pre-v4 data signal: the local store first (offline, instant), then — for
    826     /// a reinstalled device with an empty store — a probe of the legacy v3
    827     /// container for any custom zone (an `account` or per-game zone). A probe
    828     /// failure (signed out / offline) reports `false`, so no spurious notice
    829     /// shows.
    830     private func deviceHasLegacyData() async -> Bool {
    831         if store.hasAnyGames() { return true }
    832         do {
    833             let zones = try await CloudContainer.v3Container
    834                 .privateCloudDatabase.allRecordZones()
    835             let defaultZoneName = CKRecordZone.default().zoneID.zoneName
    836             return zones.contains { $0.zoneID.zoneName != defaultZoneName }
    837         } catch {
    838             return false
    839         }
    840     }
    841 
    842     func start(appDelegate: AppDelegate) async {
    843         if let startupTask {
    844             await startupTask.value
    845             return
    846         }
    847 
    848         let task = Task { @MainActor in
    849             await self.performStartup(appDelegate: appDelegate)
    850         }
    851         startupTask = task
    852         await task.value
    853     }
    854 
    855     private func performStartup(appDelegate: AppDelegate) async {
    856         // One-time transition off the pre-v4 CloudKit container. The v4 build
    857         // points at a brand-new, empty container, so any games this device
    858         // cached under v3 are orphans that can never sync again — wipe them and
    859         // flag the explanatory notice. Runs before sync starts so the library
    860         // never shows the stale rows.
    861         await migrateOffLegacyContainerIfNeeded()
    862 
    863         // Reclaim the iCloud quota the abandoned v3 container still holds. The
    864         // v4 cutover pointed the app at a fresh container and left every zone
    865         // the account had written to v3 in place, where it consumes quota
    866         // indefinitely for data nothing can reach. Detached so a slow sweep
    867         // never delays launch, and it touches no container the sync engine
    868         // uses, so it cannot interfere with v4 syncing.
    869         reclaimLegacyCloudStorageIfNeeded()
    870 
    871         // Surface one onboarding tip per cold launch. The in-memory
    872         // AnnouncementCenter is empty on a fresh process, so this re-posts the
    873         // next undismissed tip on each cold start; a warm resume doesn't re-run
    874         // start(), so no tip reappears mid-session. Independent of iCloud sync,
    875         // so it runs ahead of the sync-enablement guard below. The launch note
    876         // holds tips off for a new user's first couple of visits to the Game
    877         // List (see TipStore.launchesBeforeTips), so it must run before the tip
    878         // is read.
    879         tips.noteColdLaunch()
    880         // Keep onboarding tips out of marketing screenshots — the import scene
    881         // shows the Game List, and a tip banner is chrome that doesn't belong in
    882         // the captured image.
    883         #if DEBUG
    884         let suppressTips = MarketingLaunch.isScreenshot
    885         #else
    886         let suppressTips = false
    887         #endif
    888         if !suppressTips, let tip = tips.currentTip() {
    889             announcements.post(tip.liveAnnouncement())
    890         }
    891 
    892         // Hydrate the persisted diagnostics history before live breadcrumbs
    893         // flow, so a log collected this morning still carries last night's
    894         // session. Ordering against startup notes is by timestamp, so a note
    895         // that races ahead of this isn't lost.
    896         await eventLog.loadPersisted()
    897 
    898         nytAuth.loadStoredSession()
    899         driveMonitor.start()
    900 
    901         store.onUnreadOtherMovesChanged = { [weak self] in
    902             guard let self else { return }
    903             Task { await self.badge.refreshAppBadge(reason: "unread changed") }
    904         }
    905         importVisibleNotificationReceipts()
    906         await badge.refreshAppBadge(reason: "startup")
    907         await badge.logNotificationStartupSnapshot()
    908 
    909         // Heal the App Group nickname directory from Core Data ground truth —
    910         // covers the first run after the feature shipped and any rebuild a
    911         // crash or extension write skipped. Cheap: one fetch over the (small)
    912         // friends table.
    913         let nicknameCtx = persistence.container.newBackgroundContext()
    914         await nicknameCtx.perform {
    915             FriendEntity.rebuildNicknameDirectory(in: nicknameCtx)
    916             // Heal the App Group content-key directory from the same context —
    917             // covers the first run after the feature shipped and any rebuild a
    918             // crash or extension write skipped. Cheap: one fetch over the games.
    919             GameEntity.rebuildContentKeyDirectory(in: nicknameCtx)
    920         }
    921 
    922         appDelegate.onVisibleNotificationReceiptsAvailable = { [weak self] in
    923             Task { @MainActor in
    924                 self?.importVisibleNotificationReceipts()
    925             }
    926         }
    927         appDelegate.onAPNsRegistrationResult = { [syncMonitor] message in
    928             syncMonitor.note(message)
    929         }
    930         appDelegate.onAPNsToken = { [weak self] data in
    931             Task { @MainActor in self?.pushClient?.updateAPNsToken(data) }
    932         }
    933         CloudShareAcceptanceBroker.shared.onAcceptShare = { metadata in
    934             await self.enqueueShareAcceptance(metadata)
    935         }
    936 
    937         await syncEngine.setTracer { [syncMonitor] message in
    938             syncMonitor.note(message)
    939         }
    940 
    941         await syncEngine.setSuccessCheckpoint { [syncMonitor] in
    942             syncMonitor.noteSuccess()
    943         }
    944 
    945         await syncEngine.setLocalAuthorIDProvider { [identity] in
    946             identity.currentID
    947         }
    948 
    949         await syncEngine.setOnRemoteMovesUpdated { [weak self, store, identity] gameIDs in
    950             store.noteIncomingMovesUpdate(
    951                 gameIDs: gameIDs,
    952                 currentAuthorID: identity.currentID
    953             )
    954             if let currentID = store.currentEntity?.id,
    955                gameIDs.contains(currentID) {
    956                 store.refreshCurrentGame()
    957                 // `presenceUntil` doubles as the other-author read cursor: advancing it
    958                 // marks these incoming peer moves as seen. Gate on `isSuppressed`
    959                 // — "the user is viewing *this* puzzle right now" — so moves are
    960                 // marked read only while actually on screen, not merely because
    961                 // the app is foreground on some other view (`currentEntity`
    962                 // lingers after navigating away). The background re-lease is
    963                 // blocked separately by publishReadCursor's foreground gate.
    964                 if NotificationState.isSuppressed(gameID: currentID) {
    965                     await self?.publishReadCursor(for: currentID, mode: .activeLease)
    966                 }
    967             }
    968             // Maintain the per-cell letter-change ledger that the "changed while
    969             // you were away" borders and banner read. Captures peer fills/clears
    970             // (never check re-stamps) as they arrive, whether or not the game is
    971             // open. Fire-and-forget: the inbound-moves hot path must not wait on
    972             // this background write, so the next batch isn't throttled behind it.
    973             store.enqueuePeerChangeLedgerUpdate(for: gameIDs)
    974         }
    975 
    976         // Friendship bootstrap keys off the *first* sight of a collaborator's
    977         // Player record (their identity) — fires once per new collaborator,
    978         // not on moves and not on their later name / cursor updates.
    979         await syncEngine.setOnRemotePlayersUpdated { [weak self] gameIDs in
    980             await self?.invites.reconcileFriendships(forGameIDs: gameIDs)
    981             // A newly-arrived shared game means a new address slot to mint and
    982             // a token to register under it (so this device can receive the
    983             // game's pushes without opening it first).
    984             await self?.accountPush.reconcilePushRegistration()
    985         }
    986 
    987         // An inbound Player record may have updated a peer's cursor track;
    988         // nudge the selection publisher and engagement coordinator to
    989         // re-evaluate peer presence. This must fire for existing Player
    990         // records too: known collaborators opening a puzzle are the common
    991         // live co-solving path.
    992         await syncEngine.setOnRemotePlayerPresenceChanged { [weak self] gameIDs in
    993             await self?.playerSelectionPublisher.peerPresenceMayHaveChanged(gameIDs: gameIDs)
    994             guard let self else { return }
    995             for gameID in gameIDs {
    996                 await self.engagement.reconcileEngagement(gameID: gameID)
    997             }
    998         }
    999 
   1000         // A peer minted or rotated the shared engagement room (the Game
   1001         // record's `engagement` creds changed). Reconcile so this device joins
   1002         // — or migrates onto — whatever room the record now advertises.
   1003         await syncEngine.setOnRemoteEngagementChanged { [weak self] gameIDs in
   1004             guard let self else { return }
   1005             for gameID in gameIDs {
   1006                 await self.engagement.reconcileEngagement(gameID: gameID)
   1007             }
   1008         }
   1009 
   1010         // A previously accepted participant vanished from a game's zone-wide
   1011         // share — they left or were removed. Rotate the game's push
   1012         // credentials (they hold every field of the old ones) and re-register
   1013         // so this device binds under the new credID and drops the old binding.
   1014         await syncEngine.setOnPushCredentialRotationNeeded { [weak self] gameIDs in
   1015             guard let self else { return }
   1016             var rotated = false
   1017             for gameID in gameIDs where self.store.rotatePushCredentials(for: gameID) != nil {
   1018                 self.syncMonitor.note("push credentials rotated for \(gameID.uuidString) after roster shrink")
   1019                 rotated = true
   1020             }
   1021             if rotated {
   1022                 await self.accountPush.reconcilePushRegistration()
   1023             }
   1024         }
   1025 
   1026         // A peer minted or rotated a game's push credential and this device
   1027         // just adopted it. Re-run registration so the device binds under the
   1028         // new credID (and unregisters the old binding) now, rather than on the
   1029         // next launch or APNs token delivery — until then, publishes signed
   1030         // with the new credential could not reach it.
   1031         await syncEngine.setOnRemoteCredentialsChanged { [weak self] _ in
   1032             await self?.accountPush.reconcilePushRegistration()
   1033         }
   1034 
   1035         await syncEngine.setOnBlockedFriendsChanged { [weak self] authorIDs in
   1036             let hiddenChanges = await self?.store.reconcileBlockedFriendHiddenGames(
   1037                 forAuthorIDs: authorIDs
   1038             ) ?? 0
   1039             if hiddenChanges > 0 {
   1040                 self?.syncMonitor.note("block visibility reconcile: updated \(hiddenChanges) game(s)")
   1041             }
   1042             await self?.refreshSnapshot()
   1043         }
   1044 
   1045         await syncEngine.setOnGameVisibilityCandidates { [weak self] gameIDs in
   1046             if let currentID = self?.store.currentEntity?.id,
   1047                gameIDs.contains(currentID) {
   1048                 self?.store.refreshCurrentSyncState()
   1049             }
   1050             let hiddenChanges = await self?.store.reconcileBlockedFriendHiddenGames(
   1051                 forGameIDs: gameIDs
   1052             ) ?? 0
   1053             if hiddenChanges > 0 {
   1054                 self?.syncMonitor.note("block visibility reconcile: updated \(hiddenChanges) game(s)")
   1055                 await self?.refreshSnapshot()
   1056             }
   1057         }
   1058 
   1059         // A sibling device of the same iCloud account has published its read
   1060         // horizon; apply it directly because SyncEngine has already accepted
   1061         // the Player record under last-writer-wins freshness checks. A
   1062         // future-dated presenceUntil is an active-session lease — a sibling is in the
   1063         // puzzle right now — so withdraw any session notifications we already
   1064         // delivered for that game (e.g. "X is solving"); opening it here is no
   1065         // longer something to nudge for. A past presenceUntil is just a closed-session
   1066         // horizon bump and leaves delivered notifications untouched.
   1067         await syncEngine.setOnIncomingReadCursor { [weak self, store, gameViewedStore] pairs in
   1068             let now = Date()
   1069             for (gameID, presenceUntil, viewedAt) in pairs {
   1070                 let (previous, adopted) = store.noteIncomingReadCursor(gameID: gameID, presenceUntil: presenceUntil)
   1071                 self?.syncMonitor.note(
   1072                     "lease ADOPT[\(gameID.uuidString.prefix(8))] src=sync " +
   1073                     "presenceUntil=\(presenceUntil.ISO8601Format()) " +
   1074                     "was=\(previous?.ISO8601Format() ?? "—")" +
   1075                     (adopted ? "" : " (no-op)")
   1076                 )
   1077                 // A sibling device shipped its "last viewed" cutoff on its own
   1078                 // `Player.viewedAt`; fold it in monotonically so we converge on
   1079                 // the latest view time across the account rather than
   1080                 // recomputing from this device's (possibly stale) local view.
   1081                 if let viewedAt {
   1082                     gameViewedStore.advance(viewedAt, forGame: gameID)
   1083                 }
   1084                 if presenceUntil > now {
   1085                     await self?.badge.dismissDeliveredNotifications(
   1086                         for: gameID,
   1087                         seenAt: presenceUntil,
   1088                         publishAccountSeen: false,
   1089                         preserveUnread: true
   1090                     )
   1091                 } else if NotificationState.activePuzzleID() == gameID {
   1092                     self?.syncMonitor.note(
   1093                         "lease ADOPT[\(gameID.uuidString.prefix(8))] past while active; reasserting"
   1094                     )
   1095                     // A past-dated presenceUntil is a sibling closing its session, which
   1096                     // under last-writer-wins just pulled the shared account
   1097                     // horizon back to that close time. This device is still
   1098                     // actively viewing the same puzzle, so it still holds a
   1099                     // presence lease — re-assert it now instead of waiting up to
   1100                     // `readLeaseRefreshFloor` (5 min) for the next renewal tick.
   1101                     // `requireActivePuzzle` re-checks inside the write so a leave
   1102                     // racing this inbound can't strand a stale future lease.
   1103                     // The local badge ledger keeps this device's own suppression
   1104                     // horizon — a sibling's close doesn't mean *we* stopped
   1105                     // looking.
   1106                     await self?.publishReadCursor(
   1107                         for: gameID,
   1108                         mode: .activeLease,
   1109                         requireActivePuzzle: true
   1110                     )
   1111                 } else {
   1112                     // Sibling closed its session and nothing is on screen here:
   1113                     // the account stopped looking at `presenceUntil`. Pull the badge
   1114                     // ledger's suppression horizon back to that instant (and
   1115                     // advance the watermark to it) so a push arriving after the
   1116                     // close badges here instead of staying swallowed under the
   1117                     // sibling's old lease, which this device adopted when the
   1118                     // lease was minted.
   1119                     BadgeState.markSeen(gameID: gameID, at: presenceUntil)
   1120                     BadgeState.collapseSuppression(gameID: gameID, to: presenceUntil)
   1121                 }
   1122             }
   1123         }
   1124 
   1125         await syncEngine.setOnAccountPushAddress { [weak self] address in
   1126             await self?.accountPush.adoptInboundPushAddress(address)
   1127         }
   1128 
   1129         await syncEngine.setOnAccountPushSecret { [weak self] secret, version in
   1130             await self?.accountPush.adoptInboundPushSecret(secret, version: version)
   1131         }
   1132 
   1133         shareController.onParticipantRemoved = { [weak self] gameID in
   1134             guard let self else { return }
   1135             guard self.store.rotatePushCredentials(for: gameID) != nil else { return }
   1136             self.syncMonitor.note(
   1137                 "push credentials rotated for \(gameID.uuidString) after participant removal"
   1138             )
   1139             Task { @MainActor [weak self] in
   1140                 await self?.accountPush.reconcilePushRegistration()
   1141             }
   1142         }
   1143 
   1144         shareController.onShareSaved = { [weak self] gameID in
   1145             guard let self else { return }
   1146             self.store.markShared(gameID: gameID)
   1147             // Mint the game's notification content key now, at share time,
   1148             // rather than lazily on the first push. The key rides the Game
   1149             // record (`setNotification` enqueues its push), so minting here
   1150             // gives it time to propagate to participants before any encrypted
   1151             // notification is sent. Lazy minting let the first push for a game
   1152             // with no prior activity (e.g. an immediate resign on a game with
   1153             // no moves) outrun the key's sync, leaving the recipient unable to
   1154             // decrypt and falling back to the generic alert. Idempotent.
   1155             self.store.ensurePushCredentials(for: gameID)
   1156             // Register this device under the newly-shared game's derived push
   1157             // address so peers can reach it.
   1158             Task { @MainActor [weak self] in
   1159                 await self?.accountPush.reconcilePushRegistration()
   1160             }
   1161             // Register the app for notifications now that the user has chosen
   1162             // to collaborate. Surfaces the app in Settings > Notifications and
   1163             // makes the icon-badge permission available before any inbound
   1164             // moves can arrive.
   1165             Task { await AppDelegate.requestNotificationAuthorizationIfNeeded() }
   1166         }
   1167 
   1168         await syncEngine.setOnPings { [weak self] pings in
   1169             guard let self else { return }
   1170             await self.invites.presentPings(pings)
   1171         }
   1172 
   1173         await syncEngine.setOnPingDeliveryUpdate { [weak self] update in
   1174             guard let self else { return }
   1175             switch update.state {
   1176             case .queued:
   1177                 self.inviteDeliveries.markQueued(
   1178                     recordName: update.recordName,
   1179                     gameID: update.gameID,
   1180                     friendAuthorID: update.addressee
   1181                 )
   1182                 self.dismissInviteFailureAnnouncement(
   1183                     gameID: update.gameID,
   1184                     friendAuthorID: update.addressee
   1185                 )
   1186             case .sent:
   1187                 self.inviteDeliveries.markSent(
   1188                     recordName: update.recordName,
   1189                     gameID: update.gameID,
   1190                     friendAuthorID: update.addressee
   1191                 )
   1192                 self.dismissInviteFailureAnnouncement(
   1193                     gameID: update.gameID,
   1194                     friendAuthorID: update.addressee
   1195                 )
   1196             case .failed:
   1197                 let failure = update.failure ?? .other
   1198                 self.inviteDeliveries.markFailed(
   1199                     recordName: update.recordName,
   1200                     gameID: update.gameID,
   1201                     friendAuthorID: update.addressee,
   1202                     failure: failure
   1203                 )
   1204                 // Always post, even with an invite sheet open. The sheet shows
   1205                 // the same failure inline but only for the send it issued
   1206                 // itself, so gating on "a sheet is presented" could swallow a
   1207                 // failure entirely — including the one that arrives after a
   1208                 // confirmed-send wait has already timed out. The banner is
   1209                 // scoped to the Game List behind the sheet, and the `.queued`
   1210                 // and `.sent` cases above retract it, so a retry that succeeds
   1211                 // never leaves a stale one behind.
   1212                 self.announcements.post(Announcement(
   1213                     id: InviteDeliveryStore.failureAnnouncementID(
   1214                         gameID: update.gameID,
   1215                         friendAuthorID: update.addressee
   1216                     ),
   1217                     scope: .global,
   1218                     severity: .error,
   1219                     title: String(localized: failure.title),
   1220                     body: String(localized: failure.body),
   1221                     dismissal: .manual
   1222                 ))
   1223                 if update.rollbackParticipantOnFailure {
   1224                     Task { @MainActor [weak self] in
   1225                         await self?.invites.rollbackUndeliveredInvite(
   1226                             gameID: update.gameID,
   1227                             friendAuthorID: update.addressee
   1228                         )
   1229                     }
   1230                 }
   1231             }
   1232         }
   1233 
   1234         await syncEngine.setOnAccountChange { [weak self] in
   1235             guard let self else { return }
   1236             let previousID = self.identity.currentID
   1237             await self.identity.refresh(using: self.ckContainer)
   1238             let newID = self.identity.currentID
   1239             // A switch to a *different* iCloud account: drop this device's
   1240             // cache of the previous account's data so it neither lingers in
   1241             // the library nor mixes with the new author's rows. Local only —
   1242             // the previous account keeps its games in its own CloudKit; this
   1243             // device just resyncs as the new account. Gated on the author ID
   1244             // actually changing so a first sign-in (no previous) or a
   1245             // transient sign-out (`refresh` no-ops, ID unchanged) doesn't
   1246             // purge.
   1247             if Self.accountSwitchRequiresPurge(previousID: previousID, newID: newID) {
   1248                 do {
   1249                     try await self.cloudService.purgeLocalData()
   1250                 } catch {
   1251                     self.syncMonitor.note("account-switch purge failed — \(error)")
   1252                 }
   1253             }
   1254             self.pushClient?.updateAuthorID(newID)
   1255             // Recompute the address set for the new account; addresses that
   1256             // belonged to the old account drop out and are unregistered.
   1257             await self.accountPush.reconcilePushRegistration()
   1258         }
   1259 
   1260         await syncEngine.setOnGameAccessRevoked { [weak self, store, gameViewedStore, announcements, gameArchiver] gameID in
   1261             store.markAccessRevoked(gameID: gameID)
   1262             // Supersede any pending catch-up banner: advancing the view baseline
   1263             // to now leaves nothing for the next open to diff against.
   1264             gameViewedStore.advance(Date(), forGame: gameID)
   1265             // The owner deleted the shared zone. For a *finished* game, swap the
   1266             // revoked tombstone for a durable owned copy rebuilt from the
   1267             // private-zone archive; in-progress games are left as revoked rows.
   1268             //
   1269             // With that puzzle on screen the live row is kept (and hidden) rather
   1270             // than deleted, so the mounted view isn't left on a dead
   1271             // `GameEntity`; the open puzzle is then handed over to the Chronicle,
   1272             // which shows its own load rather than a banner over a dead game.
   1273             let chronicleID = await gameArchiver.promoteRevoked(gameID: gameID)
   1274             if let chronicleID {
   1275                 if self?.isPuzzleOnScreen(gameID: gameID) == true {
   1276                     NotificationNavigationBroker.shared.replaceOpenGame(
   1277                         gameID,
   1278                         with: chronicleID
   1279                     )
   1280                 } else {
   1281                     await gameArchiver.retireSupersededLiveRow(gameID: gameID)
   1282                 }
   1283             }
   1284             // Surface the revocation as a sticky, input-blocking banner on
   1285             // the open puzzle, replacing the former AccessRevokedBanner
   1286             // overlay. Game-scoped, so it only shows for this puzzle.
   1287             //
   1288             // A completed handover earns no banner: the zone is retired by design
   1289             // once every participant has chronicled the game, so a finished puzzle
   1290             // passing to a complete Chronicle is not news. Everything else is —
   1291             // an unfinished game, or one whose Chronicle never completed before
   1292             // the owner hit the retention deadline, which leaves a revoked row
   1293             // the user can still open and `OpenPuzzleBanner` re-banners.
   1294             if chronicleID == nil {
   1295                 announcements.post(.accessRevoked(gameID: gameID))
   1296             }
   1297             await self?.accountPush.reconcilePushRegistration()
   1298         }
   1299 
   1300         await syncEngine.setOnGameRemoved { [weak self, store, gameViewedStore, announcements, gameArchiver] gameID in
   1301             let wasOpen = store.handleRemoteRemoval(gameID: gameID)
   1302             // Another owner device may have retired this completed live zone.
   1303             // Its compact private Archive is account-wide, but the Archive
   1304             // record was intentionally inert while the live row existed; apply
   1305             // it now that the zone deletion removed that row.
   1306             let chronicleID = await gameArchiver.restoreRetired(gameID: gameID)
   1307             gameViewedStore.advance(Date(), forGame: gameID)
   1308             // The local row is gone, so drop its badge ledger entry: a seen
   1309             // horizon can't clear it once there's no game left to open.
   1310             BadgeState.forget(gameID: gameID)
   1311             await self?.badge.refreshAppBadge(reason: "game removed")
   1312             // A retirement is not a deletion the user needs told about: a
   1313             // sibling device retires the zone once the game is chronicled, so an
   1314             // open puzzle is handed over to the Chronicle that replaced it —
   1315             // the same substitution `onGameAccessRevoked` makes for a
   1316             // participant whose shared zone went.
   1317             //
   1318             // The banner is what remains for a genuine hard delete (a solo
   1319             // puzzle deleted on another device, a shared game left elsewhere) or
   1320             // for a retirement whose Chronicle could not be read: sticky and
   1321             // input-blocking, it freezes the now-orphaned puzzle on screen until
   1322             // the user backs out. Off-screen removals just drop from the list.
   1323             if let chronicleID {
   1324                 if self?.isPuzzleOnScreen(gameID: gameID) == true {
   1325                     NotificationNavigationBroker.shared.replaceOpenGame(
   1326                         gameID,
   1327                         with: chronicleID
   1328                     )
   1329                 }
   1330             } else if wasOpen {
   1331                 announcements.post(.gameRemoved(gameID: gameID))
   1332             }
   1333             await self?.accountPush.reconcilePushRegistration()
   1334         }
   1335 
   1336         await syncEngine.setOnGameCompleted { [weak self, gameArchiver] gameID in
   1337             await self?.shareController.closeTicketForCompletedGame(gameID: gameID)
   1338             // Completion learned purely via sync (this device wasn't present at
   1339             // the finish, so persistCompletion never ran): drop the now-useless
   1340             // peer-change ledger, the writer's terminal-game path doing the work.
   1341             self?.store.enqueuePeerChangeLedgerUpdate(for: [gameID])
   1342             await gameArchiver.archiveIfNeeded(gameID: gameID)
   1343         }
   1344 
   1345         await syncEngine.setOnReplayJournalsSynced { [gameArchiver] gameIDs in
   1346             for gameID in gameIDs {
   1347                 await gameArchiver.archiveIfNeeded(gameID: gameID)
   1348             }
   1349         }
   1350 
   1351         await syncEngine.setOnCompletionRecordsSaved { [weak self] records in
   1352             await self?.sessions.noteCompletionRecordsSaved(records)
   1353         }
   1354         await sessions.resumePendingCompletionDeliveries()
   1355 
   1356         await syncEngine.setOnGameJoined { [weak self] gameID in
   1357             guard let self else { return }
   1358             // A shared zone just synced in for this game — joined here or on
   1359             // a sibling device. Its "Invited" row is now redundant; drop it
   1360             // so a freshly-synced game and its stale invite don't show side
   1361             // by side. `applyInvitePings` GCs the same row, but only when a
   1362             // ping is next fetched.
   1363             do {
   1364                 try self.invites.removePendingInvite(forGameID: gameID)
   1365                 // The pending invite (if any) is gone; drop it from the badge.
   1366                 await self.badge.refreshAppBadge(reason: "game joined")
   1367             } catch {
   1368                 self.announcements.post(Announcement(
   1369                     id: "remove-pending-invite-error-\(gameID.uuidString)",
   1370                     scope: .global,
   1371                     severity: .error,
   1372                     title: "Clearing Failed",
   1373                     body: error.localizedDescription,
   1374                     dismissal: .manual
   1375                 ))
   1376             }
   1377             // Defer the sync enqueue out of the `onGameJoined` callback; the
   1378             // actual CKSyncEngine send drain remains detached in SyncEngine.
   1379             Task { @MainActor [weak self] in
   1380                 await self?.accountPush.reconcilePushRegistration()
   1381             }
   1382         }
   1383 
   1384         // A sibling device consumed (deleted) a directed ping; withdraw any
   1385         // copy of that game's notification we delivered before the deletion
   1386         // reached us, and clear any durable invite row backed by that Ping.
   1387         await syncEngine.setOnPingDeleted { [weak self] pings in
   1388             guard let self else { return }
   1389             try? self.invites.removePendingInvites(forPingRecordNames: Set(pings.map { $0.recordName }))
   1390             await self.badge.refreshAppBadge(reason: "ping deleted")
   1391             for gameID in Set(pings.map { $0.gameID }) {
   1392                 await self.badge.dismissDeliveredNotifications(
   1393                     for: gameID,
   1394                     publishAccountSeen: false
   1395                 )
   1396             }
   1397         }
   1398 
   1399         cloudService.onShareJoined = { [weak self] gameID in
   1400             guard let self else { return }
   1401             // Register the app for notifications now that the user has joined
   1402             // a collaboration. Mirrors the owner path in `onShareSaved` so the
   1403             // app is in Settings > Notifications before any inbound moves.
   1404             await AppDelegate.requestNotificationAuthorizationIfNeeded()
   1405             await self.accountPush.reconcilePushRegistration()
   1406             // Stamp (minting if needed) this account's own derived push address
   1407             // for the joined game, both so the room broadcast below can exclude
   1408             // our own devices and so we're addressable for inbound pushes.
   1409             let ownAddress = self.identity.currentID.flatMap {
   1410                 self.accountPush.setDerivedPushAddress(gameID: gameID, authorID: $0)
   1411             }
   1412             // Joining can complete without presenting PuzzleDisplayView (for
   1413             // example when the app backgrounds during acceptance). Publish the
   1414             // profile-name snapshot here as part of the join itself rather than
   1415             // relying on a later puzzle open to fill the Player record.
   1416             await self.playerNamePublisher?.publishName(for: gameID)
   1417             await self.accountPush.publishAccountJoinedPush(gameID: gameID)
   1418             // Tell everyone already in the room that we've joined.
   1419             await self.sessions.publishJoinPush(gameID: gameID, excludeAddress: ownAddress)
   1420         }
   1421 
   1422         // PlayerNamePublisher fans out name changes as `name` Decisions to the
   1423         // account zone and every friend zone. PuzzleDisplayView publishes the
   1424         // open game's Player-record name snapshot directly, which covers
   1425         // first-sync-after-share-create / accept and pre-friendship display.
   1426         playerNamePublisher = PlayerNamePublisher(
   1427             preferences: preferences,
   1428             persistence: persistence,
   1429             authorIdentity: identity,
   1430             enqueuePlayer: { [preferences, syncEngine] gameID, authorID, reason in
   1431                 let isEnabled = await MainActor.run { preferences.isICloudSyncEnabled }
   1432                 guard isEnabled else { return }
   1433                 await syncEngine.enqueuePlayer(
   1434                     gameID: gameID,
   1435                     authorID: authorID,
   1436                     reason: reason
   1437                 )
   1438             },
   1439             enqueueNameDecision: { [preferences, syncEngine] authorID, name, version, zoneID, scope in
   1440                 let isEnabled = await MainActor.run { preferences.isICloudSyncEnabled }
   1441                 guard isEnabled else { return }
   1442                 await syncEngine.enqueueNameDecision(
   1443                     authorID: authorID,
   1444                     name: name,
   1445                     version: version,
   1446                     zoneID: zoneID,
   1447                     scope: scope
   1448                 )
   1449             }
   1450         )
   1451 
   1452         // Install this only after every SyncEngine callback above is ready.
   1453         // Assignment starts the delegate's buffered-notification drain; doing
   1454         // it earlier could let that drain start SyncEngine and advance its
   1455         // change token while one-shot callbacks were still absent. Keep the
   1456         // installation above the sync-enablement guard so buffered wakes are
   1457         // also drained (and diagnosed as ignored) when iCloud sync is off.
   1458         appDelegate.onRemoteNotification = {
   1459             summary, scope, event, gameID, kind, senderDeviceID, presenceUntil, isBackground in
   1460             await self.handleRemoteNotification(
   1461                 summary: summary,
   1462                 scope: scope,
   1463                 event: event,
   1464                 gameID: gameID,
   1465                 kind: kind,
   1466                 senderDeviceID: senderDeviceID,
   1467                 presenceUntil: presenceUntil,
   1468                 isBackground: isBackground
   1469             )
   1470         }
   1471 
   1472         guard await ensureICloudSyncStarted() else {
   1473             syncMonitor.note("iCloud sync disabled — engine startup skipped")
   1474             return
   1475         }
   1476         // Re-announce any inbox share a friend has still not accepted. The
   1477         // bootstrap Ping is otherwise one-shot: a friend whose accept failed at
   1478         // delivery is stuck at `friendshipNotReady` with no other retry path.
   1479         // Runs only once the engine is up (it enqueues a share and a bootstrap
   1480         // ping) and only with sync enabled. Detached so it never delays the
   1481         // foreground sync below.
   1482         if let localAuthorID = identity.currentID, !localAuthorID.isEmpty {
   1483             Task { [friendController, preferences] in
   1484                 await friendController.healPendingBootstraps(
   1485                     localAuthorID: localAuthorID,
   1486                     localDisplayName: preferences.name
   1487                 )
   1488             }
   1489         }
   1490         // The scene-active phase that fires alongside cold launch runs the
   1491         // first fetch + push via `syncOnForeground`. Doing it here as well
   1492         // would mean two concurrent CKSyncEngine fetches on a fresh engine.
   1493     }
   1494 
   1495     func enqueueShareAcceptance(_ metadata: CKShare.Metadata) async {
   1496         guard preferences.isICloudSyncEnabled else {
   1497             syncMonitor.note("share acceptance ignored while iCloud sync is disabled")
   1498             return
   1499         }
   1500         pendingShareMetadatas.append(metadata)
   1501         syncMonitor.note(
   1502             "share acceptance queued: container=\(metadata.containerIdentifier)"
   1503         )
   1504         await processPendingShareAcceptances()
   1505     }
   1506 
   1507     func syncOnForeground() async {
   1508         importVisibleNotificationReceipts()
   1509         await movesUpdater.flush()
   1510         guard await ensureICloudSyncStarted() else { return }
   1511         let recoveredMoveCount = await syncEngine.enqueueUnconfirmedMoves()
   1512         if recoveredMoveCount > 0 {
   1513             syncMonitor.note("recovered \(recoveredMoveCount) unconfirmed move(s) for CloudKit enqueue")
   1514         }
   1515         await syncMonitor.run("foreground push") {
   1516             try await syncEngine.pushChanges()
   1517         }
   1518         if isGameListVisible {
   1519             syncMonitor.note("foreground fetch skipped: game list will refresh")
   1520             await freshenGameList(reason: .foreground)
   1521             return
   1522         }
   1523         if let (gameID, scope) = activePuzzleGridTarget() {
   1524             syncMonitor.note("foreground fetch skipped: active puzzle will refresh")
   1525             await freshenPuzzleGrid(gameID: gameID, scope: scope, reason: .foreground)
   1526             return
   1527         }
   1528         await syncMonitor.run("foreground fetch") {
   1529             try await syncEngine.fetchChanges(source: "foreground")
   1530         }
   1531         await refreshSnapshot()
   1532     }
   1533 
   1534     private func importVisibleNotificationReceipts() {
   1535         for entry in VisibleNotificationReceiptLog.drain() {
   1536             eventLog.note(VisibleNotificationReceiptLog.message(for: entry))
   1537         }
   1538     }
   1539 
   1540     /// Whether `gameID` still has a mounted puzzle destination. This stays true
   1541     /// while backgrounded and through a pop transition, until `onDisappear`
   1542     /// confirms that deleting its live Core Data row is safe.
   1543     private func isPuzzleOnScreen(gameID: UUID) -> Bool {
   1544         mountedPuzzleCounts[gameID, default: 0] > 0
   1545     }
   1546 
   1547     func gameListAppeared() async {
   1548         isGameListVisible = true
   1549         // Land any Chronicle swap that was deferred while its puzzle was on
   1550         // screen, before the list renders, so the row goes straight from live
   1551         // game to Chronicle without a revoked state in between. Also the
   1552         // cold-launch backstop for a revocation that arrived while terminated.
   1553         let retirable = await gameArchiver.sweepSupersededLiveGames()
   1554         for gameID in retirable where !isPuzzleOnScreen(gameID: gameID) {
   1555             await gameArchiver.retireSupersededLiveRow(gameID: gameID)
   1556         }
   1557         await freshenGameList(reason: .appeared)
   1558     }
   1559 
   1560     func gameListDisappeared() {
   1561         isGameListVisible = false
   1562     }
   1563 
   1564     func puzzleAppeared(gameID: UUID) {
   1565         mountedPuzzleCounts[gameID, default: 0] += 1
   1566     }
   1567 
   1568     /// Clears the mounted destination before starting Chronicle cleanup. If
   1569     /// promotion is still fetching, the archiver joins that work before
   1570     /// deciding whether a complete Chronicle exists.
   1571     ///
   1572     /// The retirement attempt is driven by persisted state rather than by what
   1573     /// the closing puzzle believed about itself: a revocation flips the mutator
   1574     /// the open view holds, but an owner-side retirement replaces the row from
   1575     /// underneath without the view ever hearing about it. The archiver deletes
   1576     /// nothing that a Chronicle has not already replaced, so asking on every
   1577     /// close is safe.
   1578     func puzzleDisappeared(gameID: UUID) {
   1579         let remaining = max(0, mountedPuzzleCounts[gameID, default: 0] - 1)
   1580         if remaining == 0 {
   1581             mountedPuzzleCounts[gameID] = nil
   1582         } else {
   1583             mountedPuzzleCounts[gameID] = remaining
   1584         }
   1585         guard remaining == 0 else { return }
   1586         Task { await gameArchiver.retireSupersededLiveRow(gameID: gameID) }
   1587     }
   1588 
   1589     func loadRecentCompleted(since cutoff: Date) async -> GameArchiver.CompletedPage {
   1590         guard await ensureICloudSyncStarted() else {
   1591             return .init(oldestCompletedAt: nil, hasMore: false)
   1592         }
   1593         let predecessor = completedPageTask
   1594         let task = Task { @MainActor in
   1595             if let predecessor { _ = await predecessor.value }
   1596             return await gameArchiver.loadRecentCompleted(since: cutoff)
   1597         }
   1598         let taskID = UUID()
   1599         completedPageTask = task
   1600         completedPageTaskID = taskID
   1601         let page = await task.value
   1602         if completedPageTaskID == taskID {
   1603             completedPageTask = nil
   1604             completedPageTaskID = nil
   1605         }
   1606         beginRemoteCompletedMigrationIfNeeded()
   1607         return page
   1608     }
   1609 
   1610     func loadMoreCompleted() async -> GameArchiver.CompletedPage {
   1611         guard await ensureICloudSyncStarted() else {
   1612             return .init(oldestCompletedAt: nil, hasMore: false)
   1613         }
   1614         let predecessor = completedPageTask
   1615         let task = Task { @MainActor in
   1616             if let predecessor { _ = await predecessor.value }
   1617             return await gameArchiver.loadMoreCompleted()
   1618         }
   1619         let taskID = UUID()
   1620         completedPageTask = task
   1621         completedPageTaskID = taskID
   1622         let page = await task.value
   1623         if completedPageTaskID == taskID {
   1624             completedPageTask = nil
   1625             completedPageTaskID = nil
   1626         }
   1627         return page
   1628     }
   1629 
   1630     private func beginRemoteCompletedMigrationIfNeeded() {
   1631         guard !hasStartedRemoteCompletedMigration else { return }
   1632         hasStartedRemoteCompletedMigration = true
   1633         Task { @MainActor [weak self] in
   1634             await self?.gameArchiver.migrateMissingCompletedGames()
   1635             // After the migration, so an archive it compacts this launch is
   1636             // already in the ledger and needs no payload read of its own.
   1637             await self?.gameArchiver.reconcileChronicleLedger()
   1638         }
   1639     }
   1640 
   1641     /// Runs `work` to completion under a `UIApplication` background-execution
   1642     /// assertion, so a flush or enqueue that begins as the app heads to the
   1643     /// background still reaches durable state before iOS suspends us. The
   1644     /// assertion is taken **synchronously** — before `work`'s first await — and
   1645     /// released exactly once: when `work` returns, or when iOS signals imminent
   1646     /// expiration, whichever comes first. Best-effort: a force-quit before
   1647     /// `work` lands is the one case this can't cover, so callers pair it with a
   1648     /// foreground reconcile sweep that re-runs anything that didn't finish.
   1649     ///
   1650     /// The assertion owns its own lifetime — no instance slot — so overlapping
   1651     /// calls each hold an independent assertion that self-releases. Correct
   1652     /// only on the main actor: the expiration handler and the completion both
   1653     /// run there, so the `released` latch serialises into a single
   1654     /// `endBackgroundTask` (a double release is a UIKit fault). `name` is the
   1655     /// debug label iOS shows for the assertion.
   1656     func ensureInBackground(_ name: String, _ work: @escaping () async -> Void) {
   1657         var token = UIBackgroundTaskIdentifier.invalid
   1658         var released = false
   1659         func release() {
   1660             guard !released, token != .invalid else { return }
   1661             released = true
   1662             UIApplication.shared.endBackgroundTask(token)
   1663         }
   1664         token = UIApplication.shared.beginBackgroundTask(withName: name, expirationHandler: release)
   1665         Task {
   1666             await work()
   1667             release()
   1668         }
   1669     }
   1670 
   1671     /// Flush buffered cell edits on the way to the background. Held under a
   1672     /// background assertion so the persist + CKSyncEngine enqueue completes
   1673     /// even if the scene is suspended immediately; whatever doesn't land is
   1674     /// recovered by the next foreground's `enqueueUnconfirmedMoves`.
   1675     func syncOnBackground() {
   1676         ensureInBackground("moves-flush") { [weak self] in
   1677             await self?.movesUpdater.flush()
   1678         }
   1679         ensureInBackground("event-log-flush") { [weak self] in
   1680             await self?.eventLog.flush()
   1681         }
   1682     }
   1683 
   1684     /// Pull-to-refresh action for the library. Discovers any zones the
   1685     /// device hasn't seen yet on both database scopes, then runs the normal
   1686     /// engine fetch so any in-flight changes also catch up. Bypasses
   1687     /// CKSyncEngine's database-scope change delivery, which can lag behind
   1688     /// reality when the engine has been idle.
   1689     func refreshLibrary() async {
   1690         await freshenGameList(reason: .manual)
   1691         guard await ensureICloudSyncStarted() else { return }
   1692         await syncMonitor.run("library refresh: engine fetch") {
   1693             try await syncEngine.fetchChanges(source: "library refresh")
   1694         }
   1695         await refreshSnapshot()
   1696     }
   1697 
   1698     func freshenGameList(reason: FreshenReason) async {
   1699         guard await ensureICloudSyncStarted() else { return }
   1700         if let task = gameListFreshenTask {
   1701             syncMonitor.note(
   1702                 "freshen game list \(reason.diagnosticLabel): coalesced into in-flight freshen"
   1703             )
   1704             await task.value
   1705             return
   1706         }
   1707 
   1708         let task = Task { @MainActor in
   1709             await self.runFreshenGameList(reason: reason)
   1710         }
   1711         gameListFreshenTask = task
   1712         await task.value
   1713         gameListFreshenTask = nil
   1714     }
   1715 
   1716     private func runFreshenGameList(reason: FreshenReason) async {
   1717         // The game list is a foreground-visible freshness path, not the live
   1718         // collaboration path. Keep the two database scopes serialized so list
   1719         // appearance and foreground transitions do not create a read burst.
   1720         await freshenGameListScope(
   1721             .private,
   1722             label: "private",
   1723             reason: reason
   1724         )
   1725         await freshenGameListScope(
   1726             .shared,
   1727             label: "shared",
   1728             reason: reason
   1729         )
   1730         await refreshSnapshot()
   1731         // Now that Core Data unread reflects server ground truth, prune any
   1732         // badge-ledger entry no longer backed by unread state or a delivered
   1733         // notification. Reconciling here (rather than once at startup, before
   1734         // the freshen settles) clears orphans like a game whose push stamped
   1735         // the ledger but was since opened — the divergence that otherwise pins
   1736         // the badge above the count the library list shows.
   1737         await badge.reconcileBadgeLedgerWithDeliveredNotifications()
   1738         await badge.refreshAppBadge(reason: "game list freshen")
   1739         await reconcilePendingJournalUploads()
   1740         if shouldRunColdLaunchArchiveReconcile {
   1741             shouldRunColdLaunchArchiveReconcile = false
   1742             // Startup-only backstop for the private-DB archive: re-attempts any
   1743             // completed participant game whose archive never landed, without
   1744             // repeating the scan on every foreground/manual/remote refresh.
   1745             await gameArchiver.reconcileUnarchived()
   1746         }
   1747     }
   1748 
   1749     /// Level-triggered backstop for replay journal uploads. The upload is
   1750     /// normally fired edge-style — once, at local completion or when an inbound
   1751     /// sync reveals the completion. But the local-completion enqueue is async
   1752     /// (journal flush → prefs check → `enqueueJournalUpload`), so a solver who
   1753     /// swipes the app away the instant they win can be suspended before the save
   1754     /// reaches CKSyncEngine's durable state; nothing re-fires it, and replay's
   1755     /// strict completeness then waits on that contributor forever. This sweep
   1756     /// re-enqueues any completed game whose journal hasn't been confirmed
   1757     /// uploaded. A re-send is a benign no-op, and `journalUploaded` (set on the
   1758     /// confirmed save, here for games this device never contributed to) makes it
   1759     /// converge to a no-op rather than re-enqueuing every freshen.
   1760     private func reconcilePendingJournalUploads() async {
   1761         guard let authorID = identity.currentID, !authorID.isEmpty else { return }
   1762         let ctx = persistence.container.newBackgroundContext()
   1763         ctx.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump
   1764         let candidates: [UUID] = await ctx.perform {
   1765             let req = NSFetchRequest<GameEntity>(entityName: "GameEntity")
   1766             req.predicate = NSPredicate(format: "completedAt != nil AND journalUploaded == NO")
   1767             return ((try? ctx.fetch(req)) ?? []).compactMap(\.id)
   1768         }
   1769         guard !candidates.isEmpty else { return }
   1770 
   1771         var nothingToUpload: [UUID] = []
   1772         for gameID in candidates {
   1773             if store.localJournalEntries(for: gameID).isEmpty {
   1774                 nothingToUpload.append(gameID)
   1775             } else {
   1776                 await syncEngine.enqueueJournalUpload(gameID: gameID, authorID: authorID)
   1777             }
   1778         }
   1779         guard !nothingToUpload.isEmpty else { return }
   1780 
   1781         // No local journal for these — this device never played them, so there
   1782         // is nothing to publish. Mark them done so the sweep stops reconsidering.
   1783         let toMark = nothingToUpload
   1784         await ctx.perform {
   1785             let req = NSFetchRequest<GameEntity>(entityName: "GameEntity")
   1786             req.predicate = NSPredicate(format: "id IN %@", toMark)
   1787             for game in (try? ctx.fetch(req)) ?? [] {
   1788                 game.journalUploaded = true
   1789             }
   1790             if ctx.hasChanges { try? ctx.save() }
   1791         }
   1792     }
   1793 
   1794     /// Edge-triggered, immediate companion to `reconcilePendingJournalUploads`:
   1795     /// fired synchronously on completion (`GameStore.onJournalComplete`). Runs
   1796     /// under a background assertion so the journal flush and the CKSyncEngine
   1797     /// enqueue reach durable state even if the user backgrounds the app the
   1798     /// instant they finish; CKSyncEngine then completes the send. The flush runs
   1799     /// regardless of iCloud (it persists local journal entries that would
   1800     /// otherwise be lost on termination); the enqueue is gated on sync being
   1801     /// enabled. A force-quit before the work completes is the one case this
   1802     /// can't cover — that falls to the foreground sweep.
   1803     func beginCompletionJournalUpload(gameID: UUID, authorID: String) {
   1804         ensureInBackground("journal-upload-\(gameID.uuidString)") { [weak self] in
   1805             guard let self else { return }
   1806             // Flush the cell buffer and journal queue first, so both the journal
   1807             // upload and the archive snapshot see the finished grid and full log
   1808             // (the winning move is still in flight when completion fires). The
   1809             // flush persists local entries regardless of iCloud; the cloud-bound
   1810             // steps below are gated on it.
   1811             await self.store.flushCompletionWrites()
   1812             guard self.preferences.isICloudSyncEnabled else { return }
   1813             await self.shareController.closeTicketForCompletedGame(gameID: gameID)
   1814             await self.syncEngine.enqueueJournalUpload(gameID: gameID, authorID: authorID)
   1815             // Snapshot finished participant games to this user's private DB for
   1816             // cross-device durability. A no-op for owned games (already durable)
   1817             // and ones already archived; sequenced after the flush so it can't
   1818             // capture a stale grid or miss the final journal rows.
   1819             await self.gameArchiver.archiveIfNeeded(gameID: gameID)
   1820         }
   1821     }
   1822 
   1823     private func freshenGameList(
   1824         scope: CKDatabase.Scope,
   1825         reason: FreshenReason
   1826     ) async {
   1827         let label: String
   1828         switch scope {
   1829         case .private:
   1830             label = "private"
   1831         case .shared:
   1832             label = "shared"
   1833         case .public:
   1834             return
   1835         @unknown default:
   1836             return
   1837         }
   1838         guard await ensureICloudSyncStarted() else { return }
   1839         if let task = gameListFreshenTask {
   1840             syncMonitor.note(
   1841                 "freshen game list \(reason.diagnosticLabel): \(label) coalesced into in-flight freshen"
   1842             )
   1843             await task.value
   1844             return
   1845         }
   1846         await freshenGameListScope(scope, label: label, reason: reason)
   1847         await refreshSnapshot()
   1848     }
   1849 
   1850     private func freshenGameListScope(
   1851         _ scope: CKDatabase.Scope,
   1852         label: String,
   1853         reason: FreshenReason
   1854     ) async {
   1855         let reasonLabel = reason.diagnosticLabel
   1856         if !shouldRunGameListFreshen(scope: scope, reason: reason, label: label) {
   1857             return
   1858         }
   1859         guard beginGameListFreshen(scope: scope, label: label, reason: reasonLabel) else {
   1860             return
   1861         }
   1862         defer { endGameListFreshen(scope: scope) }
   1863 
   1864         await syncMonitor.run("freshen game list \(reasonLabel): \(label) discovery") {
   1865             _ = try await self.syncEngine.discoverNewZonesDirect(scope: scope)
   1866         }
   1867         let catchUpResult: Int? = await syncMonitor.run("freshen game list \(reasonLabel): \(label) game/moves") {
   1868             try await self.syncEngine.fetchKnownGameMovesDirect(scope: scope)
   1869         }
   1870         let inviteResult: Int? = await syncMonitor.run("freshen game list \(reasonLabel): \(label) invites") {
   1871             try await self.syncEngine.fetchFriendInvitesDirect(scope: scope)
   1872         }
   1873         if inviteResult != nil {
   1874             await badge.refreshAppBadge(reason: "invite freshen")
   1875         }
   1876         if catchUpResult != nil {
   1877             noteGameListFreshenCompleted(scope: scope)
   1878         }
   1879     }
   1880 
   1881     /// Decides whether a game-list freshen should run for this scope. The
   1882     /// freshen polls each active zone for new records and enumerates
   1883     /// database zones for newly-shared games; between inbound pushes it
   1884     /// can't surface anything new, so we skip when the push signal hasn't
   1885     /// moved since the last successful run and the staleness budget hasn't
   1886     /// been exhausted. `.manual` (pull-to-refresh) always runs because the
   1887     /// user explicitly asked.
   1888     private func shouldRunGameListFreshen(
   1889         scope: CKDatabase.Scope,
   1890         reason: FreshenReason,
   1891         label: String
   1892     ) -> Bool {
   1893         if reason == .manual {
   1894             return true
   1895         }
   1896         guard let last = lastGameListFreshenAt(scope: scope) else {
   1897             return true
   1898         }
   1899         if let pushAt = lastRemoteNotificationAt, pushAt > last {
   1900             return true
   1901         }
   1902         let elapsed = Date().timeIntervalSince(last)
   1903         if elapsed >= gameListFreshenCooldown {
   1904             return true
   1905         }
   1906         let elapsedSeconds = Int(elapsed.rounded())
   1907         syncMonitor.note(
   1908             "freshen game list \(reason.diagnosticLabel): \(label) skipped (cooldown, last \(elapsedSeconds)s ago)"
   1909         )
   1910         return false
   1911     }
   1912 
   1913     private func lastGameListFreshenAt(scope: CKDatabase.Scope) -> Date? {
   1914         switch scope {
   1915         case .private:
   1916             return lastPrivateGameListFreshenAt
   1917         case .shared:
   1918             return lastSharedGameListFreshenAt
   1919         case .public:
   1920             return nil
   1921         @unknown default:
   1922             return nil
   1923         }
   1924     }
   1925 
   1926     private func noteGameListFreshenCompleted(scope: CKDatabase.Scope) {
   1927         let now = Date()
   1928         switch scope {
   1929         case .private:
   1930             lastPrivateGameListFreshenAt = now
   1931         case .shared:
   1932             lastSharedGameListFreshenAt = now
   1933         case .public:
   1934             return
   1935         @unknown default:
   1936             return
   1937         }
   1938     }
   1939 
   1940     private func beginGameListFreshen(
   1941         scope: CKDatabase.Scope,
   1942         label: String,
   1943         reason: String
   1944     ) -> Bool {
   1945         switch scope {
   1946         case .private:
   1947             guard !isFresheningPrivateGameList else {
   1948                 syncMonitor.note("freshen game list \(reason): \(label) coalesced into in-flight freshen")
   1949                 return false
   1950             }
   1951             isFresheningPrivateGameList = true
   1952             return true
   1953         case .shared:
   1954             guard !isFresheningSharedGameList else {
   1955                 syncMonitor.note("freshen game list \(reason): \(label) coalesced into in-flight freshen")
   1956                 return false
   1957             }
   1958             isFresheningSharedGameList = true
   1959             return true
   1960         case .public:
   1961             return false
   1962         @unknown default:
   1963             return false
   1964         }
   1965     }
   1966 
   1967     private func endGameListFreshen(scope: CKDatabase.Scope) {
   1968         switch scope {
   1969         case .private:
   1970             isFresheningPrivateGameList = false
   1971         case .shared:
   1972             isFresheningSharedGameList = false
   1973         case .public:
   1974             return
   1975         @unknown default:
   1976             return
   1977         }
   1978     }
   1979 
   1980     func freshenPuzzleGrid(
   1981         gameID: UUID,
   1982         scope: CKDatabase.Scope,
   1983         reason: FreshenReason
   1984     ) async {
   1985         await movesUpdater.flush()
   1986         guard await ensureICloudSyncStarted() else { return }
   1987         let label = reason.diagnosticLabel
   1988         if reason == .remote,
   1989            shouldSkipRecentRemotePuzzleGridFreshen(
   1990                gameID: gameID,
   1991                scope: scope,
   1992                label: label
   1993            ) {
   1994             return
   1995         }
   1996         guard beginPuzzleGridFreshen(gameID: gameID, scope: scope, reason: label) else {
   1997             return
   1998         }
   1999         defer {
   2000             endPuzzleGridFreshen(gameID: gameID, scope: scope)
   2001             if reason == .remote {
   2002                 noteRemotePuzzleGridFreshenCompleted(gameID: gameID, scope: scope)
   2003             }
   2004         }
   2005 
   2006         await syncMonitor.run("freshen puzzle grid \(label)") {
   2007             let handled = try await syncEngine.fetchGameDirect(
   2008                 scope: scope,
   2009                 gameID: gameID
   2010             )
   2011             if !handled {
   2012                 try await syncEngine.fetchChanges(source: "puzzle grid \(label)")
   2013             }
   2014         }
   2015         await refreshSnapshot()
   2016     }
   2017 
   2018     private func beginPuzzleGridFreshen(
   2019         gameID: UUID,
   2020         scope: CKDatabase.Scope,
   2021         reason: String
   2022     ) -> Bool {
   2023         let key = puzzleGridFreshenKey(gameID: gameID, scope: scope)
   2024         guard !fresheningPuzzleGridKeys.contains(key) else {
   2025             syncMonitor.note(
   2026                 "freshen puzzle grid \(reason): \(scopeLabel(scope)) \(gameID.uuidString.prefix(8)) coalesced into in-flight freshen"
   2027             )
   2028             return false
   2029         }
   2030         fresheningPuzzleGridKeys.insert(key)
   2031         return true
   2032     }
   2033 
   2034     private func endPuzzleGridFreshen(gameID: UUID, scope: CKDatabase.Scope) {
   2035         fresheningPuzzleGridKeys.remove(puzzleGridFreshenKey(gameID: gameID, scope: scope))
   2036     }
   2037 
   2038     private func shouldSkipRecentRemotePuzzleGridFreshen(
   2039         gameID: UUID,
   2040         scope: CKDatabase.Scope,
   2041         label: String
   2042     ) -> Bool {
   2043         // The debounce only suppresses refreshes that the live channel already
   2044         // covers. When the engagement websocket is live for this game, grid
   2045         // deltas arrive over it and the push-driven fetch is redundant, so
   2046         // collapsing a burst of pushes is harmless. When it is not live, the
   2047         // CK-push path is the only thing converging the grid — never skip it,
   2048         // or convergence stalls exactly when the live overlay is down.
   2049         guard engagementStatus.isLive(gameID: gameID) else { return false }
   2050         let key = puzzleGridFreshenKey(gameID: gameID, scope: scope)
   2051         guard let last = lastRemotePuzzleGridFreshenAt[key] else { return false }
   2052         let elapsed = Date().timeIntervalSince(last)
   2053         guard elapsed < remotePuzzleGridFreshenDebounce else { return false }
   2054         syncMonitor.note(
   2055             "freshen puzzle grid \(label): \(scopeLabel(scope)) \(gameID.uuidString.prefix(8)) skipped (recent remote refresh \(Int(elapsed.rounded()))s ago)"
   2056         )
   2057         return true
   2058     }
   2059 
   2060     private func noteRemotePuzzleGridFreshenCompleted(gameID: UUID, scope: CKDatabase.Scope) {
   2061         lastRemotePuzzleGridFreshenAt[puzzleGridFreshenKey(gameID: gameID, scope: scope)] = Date()
   2062     }
   2063 
   2064     private func puzzleGridFreshenKey(gameID: UUID, scope: CKDatabase.Scope) -> String {
   2065         "\(scopeLabel(scope)):\(gameID.uuidString)"
   2066     }
   2067 
   2068     private func scopeLabel(_ scope: CKDatabase.Scope) -> String {
   2069         switch scope {
   2070         case .private:
   2071             return "private"
   2072         case .shared:
   2073             return "shared"
   2074         case .public:
   2075             return "public"
   2076         @unknown default:
   2077             return "unknown"
   2078         }
   2079     }
   2080 
   2081     func makePlayerRoster(for gameID: UUID, preferences: PlayerPreferences) -> PlayerRoster {
   2082         PlayerRoster(
   2083             gameID: gameID,
   2084             authorIdentity: identity,
   2085             preferences: preferences,
   2086             persistence: persistence,
   2087             container: ckContainer,
   2088             engagementStore: engagementStore,
   2089             tracer: { [syncMonitor] message in syncMonitor.note(message) }
   2090         )
   2091     }
   2092 
   2093     private func handleRemoteNotification(
   2094         summary: String,
   2095         scope: CKDatabase.Scope?,
   2096         event: PushPayload.Event?,
   2097         gameID: UUID?,
   2098         kind: String?,
   2099         senderDeviceID: String?,
   2100         presenceUntil: Date?,
   2101         isBackground: Bool
   2102     ) async {
   2103         // Authoritative foreground correction. A content-available push is, by
   2104         // definition, not the user looking at this app, so when the OS reports
   2105         // we're backgrounded, pull the cached `isAppForeground` flag false now.
   2106         // `scenePhase`'s `.onChange` — the flag's only other writer — never
   2107         // fires for the *initial* phase of a process launched or woken straight
   2108         // into the background, so the flag otherwise keeps its optimistic `true`
   2109         // default and every background wake slips past the presence and
   2110         // engagement foreground gates: re-arming a departed peer's read lease
   2111         // (the ghost) and re-dialling the live socket it can't sustain. Only
   2112         // ever downgrade here — a genuine foreground is restored by the
   2113         // `.active` scenePhase transition, never by a push.
   2114         if isBackground {
   2115             noteAppForeground(false)
   2116         }
   2117         guard preferences.isICloudSyncEnabled else {
   2118             syncMonitor.note("remote notification ignored while iCloud sync is disabled")
   2119             return
   2120         }
   2121         guard await ensureICloudSyncStarted() else { return }
   2122         lastRemoteNotificationAt = Date()
   2123         syncMonitor.note("remote notification: \(summary)")
   2124 
   2125         if await handleAccountControlPush(
   2126             kind: kind,
   2127             gameID: gameID,
   2128             senderDeviceID: senderDeviceID,
   2129             presenceUntil: presenceUntil
   2130         ) {
   2131             return
   2132         }
   2133 
   2134         if event == .replay {
   2135             let label = gameID.map { String($0.uuidString.prefix(8)) } ?? "unknown"
   2136             syncMonitor.note("push(replay): syncing game \(label)")
   2137             await syncMonitor.run("replay push fetch") {
   2138                 try await syncEngine.fetchChanges(source: "replay push")
   2139             }
   2140             await refreshSnapshot()
   2141             await reconcilePendingJournalUploads()
   2142             return
   2143         }
   2144 
   2145         guard let scope, scope != .public else {
   2146             await syncMonitor.run("remote-notification fetch") {
   2147                 try await syncEngine.fetchChanges(source: "push")
   2148             }
   2149             await refreshSnapshot()
   2150             return
   2151         }
   2152 
   2153         guard beginRemoteNotificationHandling(scope: scope) else { return }
   2154         defer { endRemoteNotificationHandling(scope: scope) }
   2155 
   2156         cancelBackgroundPushCatchUp(scope: scope)
   2157 
   2158         // A share accept is downloading the puzzle asset on the joining screen.
   2159         // Don't fan out a session scan, zone discovery, or fetchChanges against
   2160         // the shared database while it does — the deferred catch-up runs once
   2161         // the burst (and the join) settle.
   2162         if scope == .shared, isAcceptingSharedGame {
   2163             syncMonitor.note("shared remote notification deferred during share acceptance")
   2164             scheduleBackgroundPushCatchUp(scope: scope)
   2165             await refreshSnapshot()
   2166             return
   2167         }
   2168 
   2169         if isBackground {
   2170             scheduleBackgroundSessionScan(scope: scope)
   2171             scheduleBackgroundPushCatchUp(scope: scope)
   2172             await refreshSnapshot()
   2173             return
   2174         }
   2175 
   2176         if isGameListVisible {
   2177             syncMonitor.note("remote notification: game list visible, refreshing list only")
   2178             await freshenGameList(scope: scope, reason: .remote)
   2179             scheduleBackgroundPushCatchUp(scope: scope)
   2180             await refreshSnapshot()
   2181             return
   2182         }
   2183 
   2184         if let activeGameID = activeGameID(in: scope) {
   2185             // Hot path: collaborator activity on the open puzzle. The Puzzle
   2186             // Grid surface owns the direct Game/Moves/Player fetch so push
   2187             // handling and open-puzzle polling coalesce instead of duplicating
   2188             // the same active-zone query.
   2189             syncMonitor.note("remote notification: active puzzle visible, refreshing game only")
   2190             await freshenPuzzleGrid(
   2191                 gameID: activeGameID,
   2192                 scope: scope,
   2193                 reason: .remote
   2194             )
   2195         } else {
   2196             // Cold path: no puzzle open. Discover any zones this device
   2197             // hasn't seen yet (e.g. a freshly-accepted share or a game
   2198             // started on another device of the same iCloud user). The broader
   2199             // game/moves catch-up is delayed below so a cold push doesn't fan
   2200             // out multiple immediate CloudKit read paths.
   2201             await syncMonitor.run("remote-notification zone discovery") {
   2202                 _ = try await self.syncEngine.discoverNewZonesDirect(scope: scope)
   2203             }
   2204             scheduleBackgroundPushCatchUp(scope: scope)
   2205         }
   2206 
   2207         await refreshSnapshot()
   2208     }
   2209 
   2210     private func handleAccountControlPush(
   2211         kind: String?,
   2212         gameID: UUID?,
   2213         senderDeviceID: String?,
   2214         presenceUntil: Date?
   2215     ) async -> Bool {
   2216         guard let kind,
   2217               kind == AccountPushCoordinator.accountJoinedPushKind || kind == AccountPushCoordinator.accountSeenPushKind
   2218         else { return false }
   2219         if senderDeviceID == RecordSerializer.localDeviceID {
   2220             syncMonitor.note("push(\(kind)): ignored self-send")
   2221             return true
   2222         }
   2223         guard let gameID else {
   2224             syncMonitor.note("push(\(kind)): ignored (no gameID)")
   2225             return true
   2226         }
   2227 
   2228         switch kind {
   2229         case AccountPushCoordinator.accountJoinedPushKind:
   2230             syncMonitor.note("push(accountJoined): sibling joined \(gameID.uuidString.prefix(8))")
   2231             await syncMonitor.run("account-joined shared discovery") {
   2232                 try await syncEngine.fetchChanges(source: "account joined")
   2233             }
   2234             await freshenGameList(scope: .shared, reason: .remote)
   2235             await accountPush.reconcilePushRegistration()
   2236             await refreshSnapshot()
   2237         case AccountPushCoordinator.accountSeenPushKind:
   2238             guard let presenceUntil else {
   2239                 syncMonitor.note("push(accountSeen): ignored (no presenceUntil)")
   2240                 return true
   2241             }
   2242             let (previous, adopted) = store.noteIncomingReadCursor(gameID: gameID, presenceUntil: presenceUntil)
   2243             if store.isCompletedGameFamily(gameID: gameID) {
   2244                 // A completed game is immutable. Treat the sibling's explicit
   2245                 // open as a read receipt immediately, including when retirement
   2246                 // has already removed the live row and only the Chronicle
   2247                 // projection remains. The Player.readThrough echo is still the
   2248                 // durable convergence path while the live zone exists.
   2249                 store.advanceReadThrough(
   2250                     gameID: gameID,
   2251                     through: min(presenceUntil, Date())
   2252                 )
   2253             }
   2254             syncMonitor.note(
   2255                 "push(accountSeen): sibling saw \(gameID.uuidString.prefix(8)) " +
   2256                 "presenceUntil=\(presenceUntil.ISO8601Format()) " +
   2257                 "was=\(previous?.ISO8601Format() ?? "—")" +
   2258                 (adopted ? "" : " (no-op)")
   2259             )
   2260             // The catch-up baseline is no longer recomputed here — it arrives,
   2261             // accurate, on the sibling's `Player.viewedAt` via the
   2262             // record sync this push's companion DB change triggers. This fast
   2263             // push is now only the cross-device notification-dismissal signal.
   2264             // A forward-dated presenceUntil is an active presence lease: the sibling is
   2265             // in this game right now and has seen its events live, so retract
   2266             // even the unread-marking notifications (win/resign/pause) from this
   2267             // device — the "soon-swept" half of sending to every device. A past
   2268             // presenceUntil is a plain read watermark (the sibling left), where we still
   2269             // preserve genuinely-unread alerts.
   2270             let siblingPresent = presenceUntil > Date()
   2271             await badge.dismissDeliveredNotifications(
   2272                 for: gameID,
   2273                 seenAt: presenceUntil,
   2274                 publishAccountSeen: false,
   2275                 preserveUnread: !siblingPresent
   2276             )
   2277         default:
   2278             break
   2279         }
   2280         return true
   2281     }
   2282 
   2283     /// Retracts a friend's invite-failure banner once that invitation is back
   2284     /// in flight, so a successful retry never leaves the old failure standing.
   2285     private func dismissInviteFailureAnnouncement(gameID: UUID, friendAuthorID: String) {
   2286         announcements.dismiss(
   2287             id: InviteDeliveryStore.failureAnnouncementID(
   2288                 gameID: gameID,
   2289                 friendAuthorID: friendAuthorID
   2290             )
   2291         )
   2292     }
   2293 
   2294     private func activePuzzleGridTarget() -> (UUID, CKDatabase.Scope)? {
   2295         guard let entity = store.currentEntity,
   2296               let gameID = entity.id,
   2297               !store.isGameArchived(entity)
   2298         else { return nil }
   2299         switch entity.databaseScope {
   2300         case 0:
   2301             return (gameID, .private)
   2302         case 1:
   2303             return (gameID, .shared)
   2304         default:
   2305             return nil
   2306         }
   2307     }
   2308 
   2309     private func beginRemoteNotificationHandling(scope: CKDatabase.Scope) -> Bool {
   2310         switch scope {
   2311         case .private:
   2312             guard !isHandlingPrivateRemoteNotification else {
   2313                 syncMonitor.note("private remote notification coalesced into in-flight handler")
   2314                 return false
   2315             }
   2316             isHandlingPrivateRemoteNotification = true
   2317             return true
   2318         case .shared:
   2319             guard !isHandlingSharedRemoteNotification else {
   2320                 syncMonitor.note("shared remote notification coalesced into in-flight handler")
   2321                 return false
   2322             }
   2323             isHandlingSharedRemoteNotification = true
   2324             return true
   2325         case .public:
   2326             return false
   2327         @unknown default:
   2328             return false
   2329         }
   2330     }
   2331 
   2332     private func endRemoteNotificationHandling(scope: CKDatabase.Scope) {
   2333         switch scope {
   2334         case .private:
   2335             isHandlingPrivateRemoteNotification = false
   2336         case .shared:
   2337             isHandlingSharedRemoteNotification = false
   2338         case .public:
   2339             return
   2340         @unknown default:
   2341             return
   2342         }
   2343     }
   2344 
   2345     private func cancelBackgroundPushCatchUp(scope: CKDatabase.Scope) {
   2346         switch scope {
   2347         case .private:
   2348             privatePushCatchUpTask?.cancel()
   2349             privatePushCatchUpTask = nil
   2350         case .shared:
   2351             sharedPushCatchUpTask?.cancel()
   2352             sharedPushCatchUpTask = nil
   2353         case .public:
   2354             return
   2355         @unknown default:
   2356             return
   2357         }
   2358     }
   2359 
   2360     private func scheduleBackgroundPushCatchUp(scope: CKDatabase.Scope) {
   2361         switch scope {
   2362         case .private:
   2363             privatePushCatchUpTask?.cancel()
   2364             privatePushCatchUpTask = makeBackgroundPushCatchUpTask(scope: scope, label: "private")
   2365         case .shared:
   2366             sharedPushCatchUpTask?.cancel()
   2367             sharedPushCatchUpTask = makeBackgroundPushCatchUpTask(scope: scope, label: "shared")
   2368         case .public:
   2369             return
   2370         @unknown default:
   2371             return
   2372         }
   2373     }
   2374 
   2375     /// Trailing-edge window over which a burst of background pushes is collapsed
   2376     /// into a single session scan. A collaborator playing live writes a record
   2377     /// every second or two; running the full-zone scan per record turned a
   2378     /// backgrounded join into minutes of back-to-back fetches.
   2379     private static let backgroundSessionScanDebounce: UInt64 = 5_000_000_000
   2380 
   2381     /// Coalesces background-push session scans. Unlike the catch-up scheduler
   2382     /// this does *not* cancel a pending scan — under sustained activity a
   2383     /// cancel-and-reschedule would push the scan out indefinitely and starve
   2384     /// presence. The first push arms a scan; later pushes within the window are
   2385     /// no-ops; the task clears its own handle when it runs.
   2386     private func scheduleBackgroundSessionScan(scope: CKDatabase.Scope) {
   2387         switch scope {
   2388         case .private:
   2389             guard privateSessionScanTask == nil else { return }
   2390             privateSessionScanTask = makeBackgroundSessionScanTask(scope: scope)
   2391         case .shared:
   2392             guard sharedSessionScanTask == nil else { return }
   2393             sharedSessionScanTask = makeBackgroundSessionScanTask(scope: scope)
   2394         case .public:
   2395             return
   2396         @unknown default:
   2397             return
   2398         }
   2399     }
   2400 
   2401     private func clearBackgroundSessionScanTask(scope: CKDatabase.Scope) {
   2402         switch scope {
   2403         case .private:
   2404             privateSessionScanTask = nil
   2405         case .shared:
   2406             sharedSessionScanTask = nil
   2407         case .public:
   2408             return
   2409         @unknown default:
   2410             return
   2411         }
   2412     }
   2413 
   2414     private func makeBackgroundSessionScanTask(scope: CKDatabase.Scope) -> Task<Void, Never> {
   2415         Task { @MainActor in
   2416             defer { clearBackgroundSessionScanTask(scope: scope) }
   2417             do {
   2418                 try await Task.sleep(nanoseconds: Self.backgroundSessionScanDebounce)
   2419             } catch {
   2420                 return
   2421             }
   2422             guard !Task.isCancelled else { return }
   2423             guard await ensureICloudSyncStarted() else { return }
   2424             let result = await syncMonitor.run("remote-notification background session scan") {
   2425                 try await syncEngine.fetchBackgroundSessionsDirect(scope: scope)
   2426             }
   2427             if let result {
   2428                 // The receiver-side `presentBegins` path is no longer wired
   2429                 // up. The catch-up banner that summarises peer adds/clears
   2430                 // still consumes the SessionMonitor buckets via `consumeOnOpen`
   2431                 // — see `handlePuzzleOpened`.
   2432                 if result.isEmpty {
   2433                     syncMonitor.note("remote-notification background session scan: no active sessions")
   2434                 }
   2435             }
   2436             await refreshSnapshot()
   2437         }
   2438     }
   2439 
   2440     private func makeBackgroundPushCatchUpTask(
   2441         scope: CKDatabase.Scope,
   2442         label: String
   2443     ) -> Task<Void, Never> {
   2444         syncMonitor.note("\(label) game/moves catch-up scheduled")
   2445         return Task { @MainActor in
   2446             let shortMoveCount = await runBackgroundPushCatchUp(
   2447                 scope: scope,
   2448                 label: label,
   2449                 delayNanoseconds: 5_000_000_000,
   2450                 phaseSuffix: "short"
   2451             )
   2452             guard !Task.isCancelled else { return }
   2453             guard shortMoveCount == 0 else {
   2454                 syncMonitor.note(
   2455                     "\(label) game/moves catch-up long skipped after short fetched \(shortMoveCount) move record(s)"
   2456                 )
   2457                 return
   2458             }
   2459             _ = await runBackgroundPushCatchUp(
   2460                 scope: scope,
   2461                 label: label,
   2462                 delayNanoseconds: 15_000_000_000,
   2463                 phaseSuffix: "long"
   2464             )
   2465         }
   2466     }
   2467 
   2468     private func runBackgroundPushCatchUp(
   2469         scope: CKDatabase.Scope,
   2470         label: String,
   2471         delayNanoseconds: UInt64,
   2472         phaseSuffix: String
   2473     ) async -> Int {
   2474         do {
   2475             try await Task.sleep(nanoseconds: delayNanoseconds)
   2476         } catch {
   2477             return 0
   2478         }
   2479         guard !Task.isCancelled else { return 0 }
   2480         guard await ensureICloudSyncStarted() else { return 0 }
   2481         guard beginGameListFreshen(
   2482             scope: scope,
   2483             label: label,
   2484             reason: "remote \(phaseSuffix) catch-up"
   2485         ) else {
   2486             return 0
   2487         }
   2488         defer { endGameListFreshen(scope: scope) }
   2489 
   2490         let moveCount = await syncMonitor.run("remote-notification \(label) game/moves catch-up \(phaseSuffix)") {
   2491             try await syncEngine.fetchKnownGameMovesDirect(scope: scope)
   2492         }
   2493         await refreshSnapshot()
   2494         return moveCount ?? 0
   2495     }
   2496 
   2497     private func activeGameID(in scope: CKDatabase.Scope) -> UUID? {
   2498         guard let target = activePuzzleGridTarget() else { return nil }
   2499         return target.1 == scope ? target.0 : nil
   2500     }
   2501 
   2502     private func ensureICloudSyncStarted() async -> Bool {
   2503         guard preferences.isICloudSyncEnabled else { return false }
   2504         guard !syncStarted else { return true }
   2505         if let inFlight = syncStartTask { return await inFlight.value }
   2506 
   2507         let task = Task { @MainActor in
   2508             await identity.refresh(using: ckContainer)
   2509             pushClient?.updateAuthorID(identity.currentID)
   2510             await syncEngine.start()
   2511             syncStarted = true
   2512 
   2513             let recoveredMoveCount = await syncEngine.enqueueUnconfirmedMoves()
   2514             if recoveredMoveCount > 0 {
   2515                 syncMonitor.note("recovered \(recoveredMoveCount) unconfirmed move(s) for CloudKit enqueue")
   2516             }
   2517             isReadyForShareAcceptance = true
   2518             await processPendingShareAcceptances()
   2519             // Only when this device has nothing cached to derive from is
   2520             // `reconcilePushRegistration` about to *mint* a fresh secret/address
   2521             // — and minting before a sibling's already-published value has been
   2522             // fetched is what enqueues divergent per-game addresses that briefly
   2523             // clobber the converged set. `start()` only constructs the engines,
   2524             // so fetch the account zone first in exactly that case, letting the
   2525             // inbound path (`onAccountPushSecret`) adopt and cache the winner so
   2526             // reconcile derives from it instead of minting. On every later
   2527             // launch the value is already cached, so this is skipped and startup
   2528             // is unchanged. If the fetch throws, `run` swallows it and reconcile
   2529             // still runs (no worse than before).
   2530             if let authorID = identity.currentID, !authorID.isEmpty,
   2531                !accountPush.hasCachedAccountPushCredentials(authorID: authorID) {
   2532                 await syncMonitor.run("startup account sync") {
   2533                     try await syncEngine.fetchChanges(source: "startup")
   2534                 }
   2535             }
   2536             await accountPush.reconcilePushRegistration()
   2537             return true
   2538         }
   2539         syncStartTask = task
   2540         let result = await task.value
   2541         syncStartTask = nil
   2542         return result
   2543     }
   2544 
   2545     /// Parses the silent-push payload into a short, human-readable summary
   2546     /// (database scope, notification type, subscription ID, pruned flag).
   2547     /// Used by the diagnostics log to confirm whether shared-DB pushes are
   2548     /// actually being delivered to the device.
   2549     static func describePush(userInfo: [AnyHashable: Any]) -> String {
   2550         guard let note = CKNotification(fromRemoteNotificationDictionary: userInfo) else {
   2551             let kind = (userInfo["kind"] as? String) ?? "<nil>"
   2552             let gameID = (userInfo["gameID"] as? String) ?? "<nil>"
   2553             return "custom kind=\(kind) gameID=\(gameID)"
   2554         }
   2555         let kind: String
   2556         let scope: CKDatabase.Scope?
   2557         switch note {
   2558         case let n as CKDatabaseNotification:
   2559             kind = "database"
   2560             scope = n.databaseScope
   2561         case let n as CKRecordZoneNotification:
   2562             kind = "recordZone"
   2563             scope = n.databaseScope
   2564         case let n as CKQueryNotification:
   2565             kind = "query(\(n.queryNotificationReason.rawValue))"
   2566             scope = n.databaseScope
   2567         default:
   2568             kind = "type(\(note.notificationType.rawValue))"
   2569             scope = nil
   2570         }
   2571         let scopeLabel: String
   2572         switch scope {
   2573         case .private: scopeLabel = "private"
   2574         case .shared: scopeLabel = "shared"
   2575         case .public: scopeLabel = "public"
   2576         case .none: scopeLabel = "n/a"
   2577         case .some(let other): scopeLabel = "scope(\(other.rawValue))"
   2578         }
   2579         let sub = note.subscriptionID ?? "<nil>"
   2580         return "scope=\(scopeLabel) kind=\(kind) sub=\(sub) pruned=\(note.isPruned)"
   2581     }
   2582 
   2583     static func databaseScope(fromPush userInfo: [AnyHashable: Any]) -> CKDatabase.Scope? {
   2584         guard let note = CKNotification(fromRemoteNotificationDictionary: userInfo) else {
   2585             return nil
   2586         }
   2587         switch note {
   2588         case let n as CKDatabaseNotification:
   2589             return n.databaseScope
   2590         case let n as CKRecordZoneNotification:
   2591             return n.databaseScope
   2592         case let n as CKQueryNotification:
   2593             return n.databaseScope
   2594         default:
   2595             return nil
   2596         }
   2597     }
   2598 
   2599     private func processPendingShareAcceptances() async {
   2600         guard isReadyForShareAcceptance, !isProcessingShareAcceptanceQueue else { return }
   2601         isProcessingShareAcceptanceQueue = true
   2602         isAcceptingSharedGame = true
   2603         defer {
   2604             isProcessingShareAcceptanceQueue = false
   2605             isAcceptingSharedGame = false
   2606         }
   2607 
   2608         while !pendingShareMetadatas.isEmpty {
   2609             let metadata = pendingShareMetadatas.removeFirst()
   2610             do {
   2611                 let outcome = try await cloudService.acceptShare(metadata: metadata)
   2612                 // Accepted but the puzzle hasn't synced in yet — reassure the
   2613                 // user it's coming, mirroring the link-tap path.
   2614                 if case .pendingSync = outcome {
   2615                     announcements.post(.puzzleStillSyncing())
   2616                 }
   2617             } catch {
   2618                 // The CloudService already recorded the detailed CloudKit
   2619                 // failure. The OS route has no caller to present it, so surface
   2620                 // the accepted-but-unavailable distinction here and keep
   2621                 // draining the queue.
   2622                 if error is AcceptedShareError {
   2623                     let copy = CloudFailureCopy.joinFailure(for: error)
   2624                     announcements.post(Announcement(
   2625                         id: "os-share-joined-unavailable",
   2626                         scope: .global,
   2627                         severity: .error,
   2628                         title: copy.title,
   2629                         body: copy.body,
   2630                         dismissal: .manual
   2631                     ))
   2632                 }
   2633             }
   2634         }
   2635     }
   2636 
   2637     private func refreshSnapshot() async {
   2638         let snapshot = await syncEngine.diagnosticSnapshot()
   2639         syncMonitor.updateSnapshot(snapshot)
   2640     }
   2641 
   2642     /// Publishes this account's read horizon for other-author moves by
   2643     /// updating `GameEntity.lastReadOtherMoveAt` and re-enqueuing its Player
   2644     /// record. Active puzzle sessions write a future lease and refresh it
   2645     /// only when less than `readLeaseRefreshFloor` remains; exits/background
   2646     /// write the current time, which can intentionally close that lease.
   2647     /// Records the app's foreground/active state. The one place the "user is
   2648     /// actively using the app" fact is set; `publishReadCursor` reads it to
   2649     /// decide whether a presence-lease renewal is legitimate.
   2650     func noteAppForeground(_ foreground: Bool) {
   2651         isAppForeground = foreground
   2652     }
   2653 
   2654     func publishReadCursor(
   2655         for gameID: UUID,
   2656         mode: ReadCursorPublishMode = .activeLease,
   2657         requireActivePuzzle: Bool = false
   2658     ) async {
   2659         guard let authorID = identity.currentID, !authorID.isEmpty else { return }
   2660         // A read lease asserts "the user is actively present on this puzzle," so
   2661         // only a foregrounded app may advance it. A background CKSyncEngine wake
   2662         // must never re-arm presence — that is what resurrected a departed
   2663         // peer's cursor and held the engagement room open. The `.currentTime`
   2664         // collapse is always allowed: we must be able to *end* the lease on the
   2665         // way to the background.
   2666         if case .activeLease = mode, !isAppForeground {
   2667             syncMonitor.note("lease(activeLease) skipped for \(gameID.uuidString): backgrounded")
   2668             return
   2669         }
   2670         // A re-assert triggered by an inbound sibling close (`requireActivePuzzle`)
   2671         // is decided before an `await`, so the user may have left the puzzle in
   2672         // the gap. Re-check here, synchronously in the write's critical section,
   2673         // that this is still the puzzle on screen. The strict `activePuzzleID`
   2674         // (no leave-grace tail) flips synchronously on `.onDisappear`/`.background`,
   2675         // so a concurrent leave deterministically wins and we never strand a
   2676         // future lease on a puzzle no device is actually viewing.
   2677         if case .activeLease = mode,
   2678            requireActivePuzzle,
   2679            NotificationState.activePuzzleID() != gameID {
   2680             syncMonitor.note("lease(activeLease) skipped for \(gameID.uuidString): not active puzzle")
   2681             return
   2682         }
   2683         let now = Date()
   2684         let didUpdate: Bool
   2685         switch mode {
   2686         case .activeLease:
   2687             let presenceUntil = now.addingTimeInterval(Self.readLeaseDuration)
   2688             didUpdate = store.setReadCursor(
   2689                 gameID: gameID,
   2690                 presenceUntil: presenceUntil,
   2691                 minimumExistingPresenceUntil: now.addingTimeInterval(Self.readLeaseRefreshFloor)
   2692             )
   2693             if didUpdate {
   2694                 // Ghost-peer probe: every active-session lease passes through
   2695                 // here. `suppressed` is "is this device actually viewing this
   2696                 // puzzle right now." A mint with suppressed=false is a presence
   2697                 // lease asserted while the user isn't looking — the resurrection
   2698                 // — and the foreground flag tells us which gate let it through.
   2699                 syncMonitor.note(
   2700                     "lease MINT[\(gameID.uuidString.prefix(8))] " +
   2701                     "presenceUntil=\(presenceUntil.ISO8601Format()) foreground=\(isAppForeground) " +
   2702                     "suppressed=\(NotificationState.isSuppressed(gameID: gameID))"
   2703                 )
   2704                 // Mirror the renewed lease into the badge ledger so an NSE
   2705                 // push landing mid-session stays suppressed past the open's
   2706                 // initial horizon (the open stamps one via
   2707                 // `dismissDeliveredNotifications`; this covers the refreshes).
   2708                 BadgeState.adoptReadHorizon(gameID: gameID, horizon: presenceUntil)
   2709                 await accountPush.publishAccountSeenPush(gameID: gameID, presenceUntil: presenceUntil)
   2710             }
   2711         case .currentTime:
   2712             // Leaving / backgrounding: collapse the presence lease to now and,
   2713             // in lockstep, advance the read watermark to now — the user was
   2714             // looking right up to here, so they've seen everything through now.
   2715             // The watermark write is what stops a peer re-summarising moves we
   2716             // saw live just before leaving; it never reaches into the future.
   2717             let collapsed = store.setReadCursor(gameID: gameID, presenceUntil: now)
   2718             let advanced = store.advanceReadThrough(gameID: gameID, through: now)
   2719             // Collapse the badge ledger's suppression horizon in the same
   2720             // lockstep. This write is local (App Group defaults), so it lands
   2721             // even when the CloudKit lease collapse doesn't — an NSE push
   2722             // arriving a minute after leave badges instead of being swallowed
   2723             // for the rest of the lease window.
   2724             BadgeState.markSeen(gameID: gameID, at: now)
   2725             BadgeState.collapseSuppression(gameID: gameID, to: now)
   2726             didUpdate = collapsed || advanced
   2727         }
   2728         guard didUpdate else { return }
   2729         let reason: String
   2730         let drain: Bool
   2731         switch mode {
   2732         case .activeLease:
   2733             reason = "lease(activeLease)"
   2734             drain = true
   2735         case .currentTime:
   2736             // Exit/background cursor: enqueue durably but don't force a send.
   2737             // Live presence rides the engagement socket; CloudKit carries the
   2738             // cursor on its own schedule, off the scarce suspension budget.
   2739             reason = "lease(currentTime)"
   2740             drain = false
   2741         }
   2742         await syncEngine.enqueuePlayer(
   2743             gameID: gameID,
   2744             authorID: authorID,
   2745             reason: reason,
   2746             drain: drain
   2747         )
   2748     }
   2749 
   2750     /// Diagnostic: logs each participant's effective `Player.presenceUntil` lease
   2751     /// for `gameID` at open, so a lingering peer cursor or engagement room can be
   2752     /// reasoned about from the device log alone. Peer leases come from their
   2753     /// received Player rows; the local lease comes from `GameEntity`, matching the
   2754     /// source `RecordBuilder` writes into the outgoing Player record. One line per
   2755     /// player: self/peer, author prefix, name, the raw `presenceUntil` (UTC), and
   2756     /// whether it currently reads as present (`+Ns` until expiry) or lapsed
   2757     /// (`Ns ago`).
   2758     func logPlayerLeaseSnapshot(gameID: UUID) async {
   2759         let localAuthorID = identity.currentID
   2760         let context = persistence.container.newBackgroundContext()
   2761         let lines: [String] = await withCheckedContinuation { continuation in
   2762             context.perform {
   2763                 let req = NSFetchRequest<PlayerEntity>(entityName: "PlayerEntity")
   2764                 req.predicate = NSPredicate(format: "game.id == %@", gameID as CVarArg)
   2765                 let now = Date()
   2766                 let players = (try? context.fetch(req)) ?? []
   2767                 let lines = players.map { player -> String in
   2768                     let author = player.authorID ?? "?"
   2769                     let isLocal = author == CKCurrentUserDefaultName
   2770                         || (localAuthorID.map { author == $0 } ?? false)
   2771                     let tag = isLocal ? "self" : "peer"
   2772                     let name = (player.name?.isEmpty == false) ? player.name! : "—"
   2773                     let presenceUntil = isLocal
   2774                         ? player.game?.lastReadOtherMoveAt
   2775                         : player.presenceUntil
   2776                     guard let presenceUntil else {
   2777                         return "\(tag) \(author.prefix(8)) [\(name)] presenceUntil=nil"
   2778                     }
   2779                     let delta = Int(presenceUntil.timeIntervalSince(now))
   2780                     let state = delta > 0 ? "present, +\(delta)s" : "absent, \(-delta)s ago"
   2781                     return "\(tag) \(author.prefix(8)) [\(name)] presenceUntil=\(presenceUntil.ISO8601Format()) (\(state))"
   2782                 }
   2783                 continuation.resume(returning: lines)
   2784             }
   2785         }
   2786         syncMonitor.note("open lease snapshot \(gameID.uuidString.prefix(8)): \(lines.count) player(s)")
   2787         for line in lines {
   2788             syncMonitor.note("  \(line)")
   2789         }
   2790     }
   2791 
   2792     /// Builds the `GameStore.onGameDeleted` callback. Extracted so tests can
   2793     /// drive the exact same closure that production wires up — keeps the
   2794     /// cursor-cleanup branch from drifting silently. (Friend colours need no
   2795     /// cleanup: they are derived on the fly, never persisted per game.)
   2796     static func makeOnGameDeleted(
   2797         syncEngine: SyncEngine,
   2798         cursorStore: GameCursorStore? = nil,
   2799         viewedStore: GameViewedStore? = nil
   2800     ) -> (GameCloudDeletion) -> Void {
   2801         { deletion in
   2802             cursorStore?.clearCursor(forGame: deletion.gameID)
   2803             viewedStore?.clearLastViewed(forGame: deletion.gameID)
   2804             Task { await syncEngine.enqueueDeleteGame(deletion) }
   2805         }
   2806     }
   2807 
   2808     /// True iff some non-local participant in `gameID` currently holds a
   2809     /// valid read lease (`presenceUntil` in the future). The active-lease cursor —
   2810     /// set ~10 minutes ahead while the puzzle is open and collapsed to `now`
   2811     /// on leave — is the presence signal: it survives think-time without
   2812     /// cursor movement and self-expires if a peer vanishes uncleanly, so a
   2813     /// solo solver in a shared puzzle stops treating a departed peer as
   2814     /// present within the lease window rather than on every paused minute.
   2815     static func hasPresentPeer(
   2816         persistence: PersistenceController,
   2817         gameID: UUID,
   2818         localAuthorID: String?
   2819     ) async -> Bool {
   2820         let context = persistence.container.newBackgroundContext()
   2821         return await withCheckedContinuation { continuation in
   2822             context.perform {
   2823                 let now = Date()
   2824                 let req = NSFetchRequest<PlayerEntity>(entityName: "PlayerEntity")
   2825                 req.predicate = NSPredicate(
   2826                     format: "game.id == %@ AND presenceUntil > %@",
   2827                     gameID as CVarArg,
   2828                     PeerPresence.presenceCutoff(asOf: now) as NSDate
   2829                 )
   2830                 let players = (try? context.fetch(req)) ?? []
   2831                 let hasPeer = players.contains { player in
   2832                     guard let authorID = player.authorID, !authorID.isEmpty else { return false }
   2833                     if authorID == CKCurrentUserDefaultName { return false }
   2834                     if let localAuthorID, !localAuthorID.isEmpty, authorID == localAuthorID { return false }
   2835                     return PeerPresence.isPresent(presenceUntil: player.presenceUntil, asOf: now)
   2836                 }
   2837                 continuation.resume(returning: hasPeer)
   2838             }
   2839         }
   2840     }
   2841 
   2842     /// The earliest future `presenceUntil` among non-local participants in `gameID` —
   2843     /// i.e. when the soonest peer lease lapses — or `nil` if no peer is
   2844     /// present. `nil` is the same condition `hasPresentPeer` reports as absent,
   2845     /// so callers can derive presence from this and also schedule against the
   2846     /// expiry instant.
   2847     static func soonestPeerLease(
   2848         persistence: PersistenceController,
   2849         gameID: UUID,
   2850         localAuthorID: String?
   2851     ) async -> Date? {
   2852         let context = persistence.container.newBackgroundContext()
   2853         return await withCheckedContinuation { continuation in
   2854             context.perform {
   2855                 let now = Date()
   2856                 let req = NSFetchRequest<PlayerEntity>(entityName: "PlayerEntity")
   2857                 req.predicate = NSPredicate(
   2858                     format: "game.id == %@ AND presenceUntil > %@",
   2859                     gameID as CVarArg,
   2860                     PeerPresence.presenceCutoff(asOf: now) as NSDate
   2861                 )
   2862                 var soonest: Date?
   2863                 for player in (try? context.fetch(req)) ?? [] {
   2864                     guard let authorID = player.authorID, !authorID.isEmpty else { continue }
   2865                     if authorID == CKCurrentUserDefaultName { continue }
   2866                     if let localAuthorID, !localAuthorID.isEmpty, authorID == localAuthorID { continue }
   2867                     guard let presenceUntil = player.presenceUntil,
   2868                           PeerPresence.isPresent(presenceUntil: presenceUntil, asOf: now) else { continue }
   2869                     if soonest == nil || presenceUntil < soonest! { soonest = presenceUntil }
   2870                 }
   2871                 continuation.resume(returning: soonest)
   2872             }
   2873         }
   2874     }
   2875 
   2876     static func presentPeers(
   2877         persistence: PersistenceController,
   2878         gameIDs: Set<UUID>?,
   2879         localAuthorID: String?
   2880     ) async -> [UUID: [String]] {
   2881         let context = persistence.container.newBackgroundContext()
   2882         return await withCheckedContinuation { continuation in
   2883             context.perform {
   2884                 let now = Date()
   2885                 let req = NSFetchRequest<PlayerEntity>(entityName: "PlayerEntity")
   2886                 var predicates = [
   2887                     NSPredicate(format: "presenceUntil > %@", PeerPresence.presenceCutoff(asOf: now) as NSDate)
   2888                 ]
   2889                 if let gameIDs, !gameIDs.isEmpty {
   2890                     predicates.append(NSPredicate(format: "game.id IN %@", Array(gameIDs)))
   2891                 }
   2892                 if let localAuthorID, !localAuthorID.isEmpty {
   2893                     predicates.append(NSPredicate(format: "authorID != %@", localAuthorID))
   2894                 }
   2895                 req.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: predicates)
   2896 
   2897                 var result: [UUID: Set<String>] = [:]
   2898                 for player in (try? context.fetch(req)) ?? [] {
   2899                     guard let gameID = player.game?.id,
   2900                           let authorID = player.authorID,
   2901                           !authorID.isEmpty,
   2902                           authorID != CKCurrentUserDefaultName,
   2903                           PeerPresence.isPresent(presenceUntil: player.presenceUntil, asOf: now) else { continue }
   2904                     result[gameID, default: []].insert(authorID)
   2905                 }
   2906                 continuation.resume(returning: result.mapValues { Array($0) })
   2907             }
   2908         }
   2909     }
   2910 
   2911 }