crossmate

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

AppServices.swift (127530B)


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