crossmate

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

CrossmateApp.swift (58161B)


      1 import CloudKit
      2 import SwiftUI
      3 import UserNotifications
      4 
      5 @main
      6 struct CrossmateApp: App {
      7     @UIApplicationDelegateAdaptor private var appDelegate: AppDelegate
      8 
      9     @State private var services: AppServices
     10 
     11     init() {
     12         AppDefaultsMigrator.run()
     13         let services = AppServices()
     14         self._services = State(initialValue: services)
     15         AppServices.current = services
     16     }
     17 
     18     var body: some Scene {
     19         WindowGroup {
     20             #if DEBUG
     21             if MarketingLaunch.isImportScene {
     22                 // The import scene drives the real Game List + New Puzzle sheet
     23                 // (seeded via `--crossmate-seed-demo`); `GameListView` opens the
     24                 // sheet and seeds the Imported tab when it detects this mode.
     25                 rootContent
     26             } else if MarketingLaunch.isScreenshot {
     27                 MarketingScreenshotView(services: services)
     28                     .environment(services.preferences)
     29                     .environment(services.inputMonitor)
     30                     .environment(services.announcements)
     31                     .environment(\.engagementStatus, services.engagementStatus)
     32             } else {
     33                 rootContent
     34             }
     35             #else
     36             rootContent
     37             #endif
     38         }
     39         .commands {
     40             PuzzleCommands()
     41         }
     42     }
     43 
     44     private var rootContent: some View {
     45         RootView(
     46             services: services,
     47             appDelegate: appDelegate
     48         )
     49         .environment(\.managedObjectContext, services.persistence.viewContext)
     50         .environment(services.driveMonitor)
     51         .environment(services.inputMonitor)
     52         .environment(services.announcements)
     53         .environment(services.tips)
     54         .environment(services.syncMonitor)
     55         .environment(services.eventLog)
     56         .environment(\.syncEngine, services.syncEngine)
     57         .environment(\.engagementStatus, services.engagementStatus)
     58         .environment(services.nytAuth)
     59         .environment(\.nytPuzzleFetcher, services.nytFetcher)
     60         .environment(\.appActions, services.appActions)
     61     }
     62 }
     63 
     64 // MARK: - App Delegate
     65 
     66 final class AppDelegate: UIResponder, UIApplicationDelegate, @preconcurrency UNUserNotificationCenterDelegate, @unchecked Sendable {
     67     /// Trims the iPad/Mac menu bar — and the hold-⌘ discoverability overlay it
     68     /// drives — to the menus Crossmate actually uses. The app has no File or
     69     /// View surface, so both standard menus are removed; Edit stays (system
     70     /// undo/copy/paste), and the puzzle's Entry/Hints menus come from
     71     /// `PuzzleCommands`. Only touch the main system menu, never contextual ones.
     72     override func buildMenu(with builder: UIMenuBuilder) {
     73         super.buildMenu(with: builder)
     74         guard builder.system == .main else { return }
     75         builder.remove(menu: .file)
     76         builder.remove(menu: .view)
     77     }
     78 
     79     /// The handlers below are installed by `AppServices.start`, which runs
     80     /// from the root view's asynchronous startup task — and UIKit can deliver
     81     /// the APNs token or a remote notification before that task gets there.
     82     /// Each callback therefore buffers what arrives early, and each handler
     83     /// replays its buffer on assignment (the same cold-launch handoff the
     84     /// notification-navigation and share-acceptance brokers provide), so a
     85     /// callback that wins the race against SwiftUI startup is deferred rather
     86     /// than lost.
     87     var onRemoteNotification: ((
     88         String,
     89         CKDatabase.Scope?,
     90         PushPayload.Event?,
     91         UUID?,
     92         String?,
     93         String?,
     94         Date?,
     95         Bool
     96     ) async -> Void)? {
     97         didSet { drainBufferedRemoteNotifications() }
     98     }
     99     /// Reports the outcome of `registerForRemoteNotifications`. Surfaced in
    100     /// the diagnostics log so a missing APNs token (e.g. an aps-environment
    101     /// mismatch between the entitlements and the TestFlight distribution
    102     /// channel) is visible rather than silently degrading sync to the
    103     /// CKSyncEngine poll cadence.
    104     var onAPNsRegistrationResult: ((String) -> Void)? {
    105         didSet {
    106             guard let handler = onAPNsRegistrationResult,
    107                   let message = bufferedAPNsRegistrationResult else { return }
    108             bufferedAPNsRegistrationResult = nil
    109             handler(message)
    110         }
    111     }
    112     /// Delivers the raw APNs token to `PushClient` so it can register with the
    113     /// Crossmate push worker. Fires on every successful APNs registration —
    114     /// the worker dedupes unchanged triples server-side.
    115     var onAPNsToken: ((Data) -> Void)? {
    116         didSet {
    117             guard let handler = onAPNsToken,
    118                   let token = bufferedAPNsToken else { return }
    119             bufferedAPNsToken = nil
    120             handler(token)
    121         }
    122     }
    123     /// Tells the app that visible notification receipts may be waiting in the
    124     /// App Group ring buffer written by the Notification Service Extension.
    125     /// Deliberately unbuffered: `AppServices.start` imports the ring buffer
    126     /// unconditionally, so an early call is covered by startup itself.
    127     var onVisibleNotificationReceiptsAvailable: (() -> Void)?
    128 
    129     /// A remote notification that arrived before `onRemoteNotification` was
    130     /// installed, captured with its already-derived fields — including the
    131     /// arrival-time background flag, so the replay preserves the state the
    132     /// push actually arrived in.
    133     private struct BufferedRemoteNotification {
    134         let summary: String
    135         let scope: CKDatabase.Scope?
    136         let event: PushPayload.Event?
    137         let gameID: UUID?
    138         let kind: String?
    139         let senderDeviceID: String?
    140         let presenceUntil: Date?
    141         let isBackground: Bool
    142     }
    143 
    144     private var bufferedAPNsRegistrationResult: String?
    145     private var bufferedAPNsToken: Data?
    146     private var bufferedRemoteNotifications: [BufferedRemoteNotification] = []
    147     /// Bounds a pathological pre-start push burst. Oldest entries drop first —
    148     /// pushes are wake signals, and the newest reflects current server state.
    149     private static let bufferedRemoteNotificationCap = 8
    150 
    151     private func bufferRemoteNotification(_ push: BufferedRemoteNotification) {
    152         // A pre-start push is a wake signal, not a work item: a second push
    153         // from the same source supersedes the queued one (carrying the newer
    154         // presence deadline) instead of queueing duplicate fetch work for the
    155         // drain.
    156         if let index = bufferedRemoteNotifications.firstIndex(where: {
    157             $0.scope == push.scope
    158                 && $0.event == push.event
    159                 && $0.gameID == push.gameID
    160                 && $0.kind == push.kind
    161                 && $0.senderDeviceID == push.senderDeviceID
    162         }) {
    163             bufferedRemoteNotifications[index] = push
    164         } else {
    165             bufferedRemoteNotifications.append(push)
    166             if bufferedRemoteNotifications.count > Self.bufferedRemoteNotificationCap {
    167                 bufferedRemoteNotifications.removeFirst()
    168             }
    169         }
    170     }
    171 
    172     /// The in-flight drain, kept so a background wake that drove startup can
    173     /// await the buffered work before completing its fetch handler. Chained:
    174     /// a new drain awaits its predecessor, so replays never interleave.
    175     private var remoteNotificationDrain: Task<Void, Never>?
    176 
    177     /// Test seams for the process-global UIApplication/AppServices lookup in
    178     /// the background-only startup path. Production leaves both nil.
    179     var isInstalledApplicationDelegateForTesting: Bool?
    180     var startServicesForTesting: (() async -> Void)?
    181 
    182     private func drainBufferedRemoteNotifications() {
    183         guard onRemoteNotification != nil, !bufferedRemoteNotifications.isEmpty else { return }
    184         let previous = remoteNotificationDrain
    185         remoteNotificationDrain = Task { @MainActor in
    186             await previous?.value
    187             while !bufferedRemoteNotifications.isEmpty {
    188                 let push = bufferedRemoteNotifications.removeFirst()
    189                 await onRemoteNotification?(
    190                     push.summary,
    191                     push.scope,
    192                     push.event,
    193                     push.gameID,
    194                     push.kind,
    195                     push.senderDeviceID,
    196                     push.presenceUntil,
    197                     push.isBackground
    198                 )
    199             }
    200         }
    201     }
    202 
    203     private func deliverAPNsRegistrationResult(_ message: String) {
    204         if let onAPNsRegistrationResult {
    205             onAPNsRegistrationResult(message)
    206         } else {
    207             bufferedAPNsRegistrationResult = message
    208         }
    209     }
    210 
    211     func application(
    212         _ application: UIApplication,
    213         didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
    214     ) -> Bool {
    215         application.registerForRemoteNotifications()
    216         UNUserNotificationCenter.current().delegate = self
    217         return true
    218     }
    219 
    220     func application(
    221         _ application: UIApplication,
    222         didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data
    223     ) {
    224         let hex = deviceToken.map { String(format: "%02x", $0) }.joined()
    225         let prefix = hex.prefix(12)
    226         deliverAPNsRegistrationResult("APNs registered token=\(prefix)… (\(deviceToken.count) bytes)")
    227         if let onAPNsToken {
    228             onAPNsToken(deviceToken)
    229         } else {
    230             bufferedAPNsToken = deviceToken
    231         }
    232     }
    233 
    234     func application(
    235         _ application: UIApplication,
    236         didFailToRegisterForRemoteNotificationsWithError error: Error
    237     ) {
    238         let nsError = error as NSError
    239         deliverAPNsRegistrationResult(
    240             "APNs registration FAILED — domain=\(nsError.domain) code=\(nsError.code) " +
    241             "\(nsError.localizedDescription)"
    242         )
    243     }
    244 
    245     /// Foreground notification arrival. If the user is currently viewing the
    246     /// puzzle the ping refers to, hide it entirely (`[]`); otherwise show it
    247     /// as a banner with sound.
    248     func userNotificationCenter(
    249         _ center: UNUserNotificationCenter,
    250         willPresent notification: UNNotification,
    251         withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void
    252     ) {
    253         let userInfo = notification.request.content.userInfo
    254         guard let gameID = Self.gameID(from: userInfo) else {
    255             logForegroundVisibleNotification(notification, source: "foreground")
    256             completionHandler([.banner, .list, .sound])
    257             return
    258         }
    259         // Invite rows are populated by an async sync pass after the push
    260         // arrives. Present the foreground notification so the invite is still
    261         // visible while that row catches up.
    262         if NotificationState.isSuppressed(gameID: gameID) {
    263             completionHandler([])
    264         } else {
    265             logForegroundVisibleNotification(notification, source: "foreground")
    266             completionHandler([.banner, .list, .sound])
    267         }
    268     }
    269 
    270     func userNotificationCenter(
    271         _ center: UNUserNotificationCenter,
    272         didReceive response: UNNotificationResponse,
    273         withCompletionHandler completionHandler: @escaping () -> Void
    274     ) {
    275         let userInfo = response.notification.request.content.userInfo
    276         guard let gameID = Self.gameID(from: userInfo) else {
    277             completionHandler()
    278             return
    279         }
    280 
    281         Task { @MainActor in
    282             if Self.isInviteNotification(userInfo) {
    283                 NotificationNavigationBroker.shared.openGameList(inviteGameID: gameID)
    284             } else {
    285                 NotificationNavigationBroker.shared.openGame(gameID)
    286             }
    287             completionHandler()
    288         }
    289     }
    290 
    291     private static func gameID(from userInfo: [AnyHashable: Any]) -> UUID? {
    292         if let id = userInfo["gameID"] as? String,
    293            let uuid = UUID(uuidString: id) {
    294             return uuid
    295         }
    296         guard let ck = userInfo["ck"] as? [AnyHashable: Any],
    297               let qry = ck["qry"] as? [AnyHashable: Any],
    298               let zoneName = qry["zid"] as? String,
    299               zoneName.hasPrefix("game-")
    300         else { return nil }
    301         return UUID(uuidString: String(zoneName.dropFirst("game-".count)))
    302     }
    303 
    304     private static func isInviteNotification(_ userInfo: [AnyHashable: Any]) -> Bool {
    305         (userInfo["pingKind"] as? String) == PingKind.invite.rawValue
    306             || (userInfo["kind"] as? String) == PingKind.invite.rawValue
    307     }
    308 
    309     private func logForegroundVisibleNotification(
    310         _ notification: UNNotification,
    311         source: String
    312     ) {
    313         if (notification.request.content.userInfo["crossmateNSELogged"] as? Bool) != true {
    314             VisibleNotificationReceiptLog.record(
    315                 body: notification.request.content.body,
    316                 source: source
    317             )
    318         }
    319         onVisibleNotificationReceiptsAvailable?()
    320     }
    321 
    322     /// Asks the user for notification permission only if they haven't yet
    323     /// answered the prompt. Idempotent — once the user has decided either
    324     /// way, this is a no-op.
    325     static func requestNotificationAuthorizationIfNeeded() async {
    326         let center = UNUserNotificationCenter.current()
    327         let settings = await center.notificationSettings()
    328         guard settings.authorizationStatus == .notDetermined else { return }
    329         _ = try? await center.requestAuthorization(options: [.alert, .sound, .badge])
    330     }
    331 
    332     func application(
    333         _ application: UIApplication,
    334         didReceiveRemoteNotification userInfo: [AnyHashable: Any]
    335     ) async -> UIBackgroundFetchResult {
    336         let summary = AppServices.describePush(userInfo: userInfo)
    337         let scope = AppServices.databaseScope(fromPush: userInfo)
    338         let payload = PushPayload.decode(from: userInfo["payload"] as? String)
    339         let gameID = Self.gameID(from: userInfo)
    340         let kind = userInfo["kind"] as? String
    341         let senderDeviceID = userInfo["senderDeviceID"] as? String
    342         let presenceUntil = Self.date(from: userInfo["presenceUntil"] as? String)
    343         let isBackground = application.applicationState != .active
    344         guard let onRemoteNotification else {
    345             // Pre-start arrival: buffer the wake, then drive startup — this
    346             // push may be the only driver the process gets, because on a
    347             // background-only launch no scene activates and the root view's
    348             // startup task never runs. `start` is one-shot and shares its
    349             // in-flight task, so racing the root task waits for the same
    350             // handler-ready boundary. Awaiting the drain then keeps the fetch
    351             // completion honest — it reports after the buffered work ran,
    352             // inside the background execution budget.
    353             //
    354             // Only the installed delegate may do this: a delegate the system
    355             // is not using (unit tests construct their own) must not capture
    356             // the real handlers by starting services itself.
    357             bufferRemoteNotification(BufferedRemoteNotification(
    358                 summary: summary,
    359                 scope: scope,
    360                 event: payload?.event,
    361                 gameID: gameID,
    362                 kind: kind,
    363                 senderDeviceID: senderDeviceID,
    364                 presenceUntil: presenceUntil,
    365                 isBackground: isBackground
    366             ))
    367             let isInstalledDelegate = isInstalledApplicationDelegateForTesting
    368                 ?? (application.delegate === self)
    369             if isInstalledDelegate, let startServicesForTesting {
    370                 await startServicesForTesting()
    371                 await remoteNotificationDrain?.value
    372             } else if isInstalledDelegate, let services = AppServices.current {
    373                 await services.start(appDelegate: self)
    374                 await remoteNotificationDrain?.value
    375             }
    376             return .newData
    377         }
    378         await onRemoteNotification(
    379             summary,
    380             scope,
    381             payload?.event,
    382             gameID,
    383             kind,
    384             senderDeviceID,
    385             presenceUntil,
    386             isBackground
    387         )
    388         return .newData
    389     }
    390 
    391     private static func date(from raw: String?) -> Date? {
    392         guard let raw else { return nil }
    393         let formatter = ISO8601DateFormatter()
    394         formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
    395         if let date = formatter.date(from: raw) { return date }
    396         formatter.formatOptions = [.withInternetDateTime]
    397         return formatter.date(from: raw)
    398     }
    399 
    400     func application(
    401         _ application: UIApplication,
    402         configurationForConnecting connectingSceneSession: UISceneSession,
    403         options: UIScene.ConnectionOptions
    404     ) -> UISceneConfiguration {
    405         let configuration = UISceneConfiguration(
    406             name: nil,
    407             sessionRole: connectingSceneSession.role
    408         )
    409         configuration.delegateClass = SceneDelegate.self
    410         return configuration
    411     }
    412 }
    413 
    414 @MainActor
    415 final class NotificationNavigationBroker {
    416     static let shared = NotificationNavigationBroker()
    417 
    418     var onOpenGame: ((UUID) -> Void)? {
    419         didSet { flushPendingGameIDs() }
    420     }
    421     var onOpenGameList: (() -> Void)? {
    422         didSet { flushPendingGameListOpen() }
    423     }
    424     var onOpenInviteInGameList: ((UUID) -> Void)? {
    425         didSet { flushPendingInviteGameListOpen() }
    426     }
    427     /// Swaps the puzzle on top of the stack for another row representing the
    428     /// same puzzle — the live game → Chronicle handover. Deliberately unbuffered,
    429     /// unlike the open handlers above: with no handler installed there is no
    430     /// navigation stack showing that puzzle, so there is nothing to correct.
    431     var onReplaceOpenGame: ((_ from: UUID, _ to: UUID) -> Void)?
    432 
    433     private var pendingGameIDs: [UUID] = []
    434     private var pendingGameListOpen = false
    435     private var pendingInviteGameIDs: [UUID] = []
    436 
    437     private init() {}
    438 
    439     func openGame(_ gameID: UUID) {
    440         guard let onOpenGame else {
    441             pendingGameIDs.append(gameID)
    442             return
    443         }
    444         onOpenGame(gameID)
    445     }
    446 
    447     func replaceOpenGame(_ from: UUID, with to: UUID) {
    448         onReplaceOpenGame?(from, to)
    449     }
    450 
    451     func openGameList(inviteGameID: UUID? = nil) {
    452         if let inviteGameID {
    453             guard let onOpenInviteInGameList else {
    454                 pendingInviteGameIDs.append(inviteGameID)
    455                 return
    456             }
    457             onOpenInviteInGameList(inviteGameID)
    458             return
    459         }
    460         guard let onOpenGameList else {
    461             pendingGameListOpen = true
    462             return
    463         }
    464         onOpenGameList()
    465     }
    466 
    467     private func flushPendingGameIDs() {
    468         guard let onOpenGame, !pendingGameIDs.isEmpty else { return }
    469         let gameIDs = pendingGameIDs
    470         pendingGameIDs.removeAll()
    471         for gameID in gameIDs {
    472             onOpenGame(gameID)
    473         }
    474     }
    475 
    476     private func flushPendingGameListOpen() {
    477         guard let onOpenGameList, pendingGameListOpen else { return }
    478         pendingGameListOpen = false
    479         onOpenGameList()
    480     }
    481 
    482     private func flushPendingInviteGameListOpen() {
    483         guard let onOpenInviteInGameList, !pendingInviteGameIDs.isEmpty else { return }
    484         let gameIDs = pendingInviteGameIDs
    485         pendingInviteGameIDs.removeAll()
    486         for gameID in gameIDs {
    487             onOpenInviteInGameList(gameID)
    488         }
    489     }
    490 }
    491 
    492 @MainActor
    493 final class CloudShareAcceptanceBroker {
    494     static let shared = CloudShareAcceptanceBroker()
    495 
    496     var onAcceptShare: ((CKShare.Metadata) async -> Void)? {
    497         didSet { flushPendingAcceptedShares() }
    498     }
    499 
    500     private var pendingAcceptedShares: [CKShare.Metadata] = []
    501 
    502     private init() {}
    503 
    504     func acceptCloudKitShare(_ metadata: CKShare.Metadata) {
    505         guard let onAcceptShare else {
    506             pendingAcceptedShares.append(metadata)
    507             return
    508         }
    509         Task { await onAcceptShare(metadata) }
    510     }
    511 
    512     private func flushPendingAcceptedShares() {
    513         guard let onAcceptShare, !pendingAcceptedShares.isEmpty else { return }
    514         let metadatas = pendingAcceptedShares
    515         pendingAcceptedShares.removeAll()
    516         for metadata in metadatas {
    517             Task { await onAcceptShare(metadata) }
    518         }
    519     }
    520 }
    521 
    522 /// Bridges a tapped Crossmate universal link from the `SceneDelegate` to
    523 /// `RootView`. A custom scene delegate is installed for the OS CKShare-accept
    524 /// callback, and once that exists, `NSUserActivityTypeBrowsingWeb` activities
    525 /// are delivered to *it* rather than SwiftUI's `.onContinueUserActivity` — so
    526 /// they must be forwarded explicitly. Buffers links that arrive before
    527 /// `RootView` wires up its handler (a cold launch delivers the activity in
    528 /// `scene(_:willConnectTo:)`, before the root `.task` runs), mirroring
    529 /// `CloudShareAcceptanceBroker`.
    530 @MainActor
    531 final class ShareLinkBroker {
    532     static let shared = ShareLinkBroker()
    533 
    534     var onOpenShareLink: ((URL) -> Void)? {
    535         didSet { flushPendingLinks() }
    536     }
    537 
    538     private var pendingURLs: [URL] = []
    539 
    540     private init() {}
    541 
    542     func openShareLink(_ url: URL) {
    543         guard let onOpenShareLink else {
    544             pendingURLs.append(url)
    545             return
    546         }
    547         onOpenShareLink(url)
    548     }
    549 
    550     private func flushPendingLinks() {
    551         guard let onOpenShareLink, !pendingURLs.isEmpty else { return }
    552         let urls = pendingURLs
    553         pendingURLs.removeAll()
    554         for url in urls { onOpenShareLink(url) }
    555     }
    556 }
    557 
    558 final class SceneDelegate: NSObject, UIWindowSceneDelegate {
    559     func scene(
    560         _ scene: UIScene,
    561         willConnectTo session: UISceneSession,
    562         options connectionOptions: UIScene.ConnectionOptions
    563     ) {
    564         // SwiftUI owns the window in this lifecycle — only read the launch
    565         // options here, never create a window. A universal link (or a CKShare)
    566         // that cold-launches the app arrives via `connectionOptions`, not
    567         // through the `continue` / `userDidAcceptCloudKitShareWith` callbacks.
    568         for activity in connectionOptions.userActivities {
    569             handle(userActivity: activity)
    570         }
    571         if let metadata = connectionOptions.cloudKitShareMetadata {
    572             CloudShareAcceptanceBroker.shared.acceptCloudKitShare(metadata)
    573         }
    574     }
    575 
    576     func scene(_ scene: UIScene, continue userActivity: NSUserActivity) {
    577         handle(userActivity: userActivity)
    578     }
    579 
    580     func windowScene(
    581         _ windowScene: UIWindowScene,
    582         userDidAcceptCloudKitShareWith metadata: CKShare.Metadata
    583     ) {
    584         CloudShareAcceptanceBroker.shared.acceptCloudKitShare(metadata)
    585     }
    586 
    587     /// Forwards a tapped Crossmate universal link to `RootView` via
    588     /// `ShareLinkBroker`. Non-web activities (and web activities without a URL)
    589     /// are ignored.
    590     private func handle(userActivity: NSUserActivity) {
    591         guard userActivity.activityType == NSUserActivityTypeBrowsingWeb,
    592               let url = userActivity.webpageURL else { return }
    593         ShareLinkBroker.shared.openShareLink(url)
    594     }
    595 }
    596 
    597 // MARK: - Root View
    598 
    599 /// Drives the join placeholder overlay while a tapped share link is being
    600 /// accepted; `shape` is the silhouette decoded from the link, if any.
    601 struct PendingJoinPlaceholder: Identifiable {
    602     let id = UUID()
    603     let shape: GridSilhouette.Grid?
    604 }
    605 
    606 struct RootView: View {
    607     let services: AppServices
    608     let appDelegate: AppDelegate
    609 
    610     @Environment(\.scenePhase) private var scenePhase
    611     @State private var navigationPath: [UUID] = []
    612     @State private var pendingJoin: PendingJoinPlaceholder?
    613     @State private var pendingInviteNotificationGameID: UUID?
    614     /// The in-flight share-accept driven by a tapped link, retained so the
    615     /// joining screen's Cancel can stop it (its poll unwinds on cancellation).
    616     @State private var joinTask: Task<Void, Error>?
    617 
    618     var body: some View {
    619         NavigationStack(path: $navigationPath) {
    620             GameListView(
    621                 store: services.store,
    622                 shareController: services.shareController,
    623                 authorIdentity: services.identity,
    624                 onRefresh: { await services.refreshLibrary() },
    625                 onAppear: { await services.gameListAppeared() },
    626                 onLoadRecentCompleted: { cutoff in
    627                     await services.loadRecentCompleted(since: cutoff)
    628                 },
    629                 onLoadMoreCompleted: {
    630                     await services.loadMoreCompleted()
    631                 },
    632                 onDisappear: { services.gameListDisappeared() },
    633                 onAcceptInvite: { shareURL, pingRecordName, shape in
    634                     try await acceptInviteFromGameList(
    635                         shareURL: shareURL,
    636                         pingRecordName: pingRecordName,
    637                         shape: shape
    638                     )
    639                 },
    640                 pendingInviteNotificationGameID: $pendingInviteNotificationGameID,
    641                 navigationPath: $navigationPath
    642             )
    643             .navigationDestination(for: UUID.self) { gameID in
    644                 PuzzleDisplayView(
    645                     gameID: gameID,
    646                     store: services.store,
    647                     shareController: services.shareController,
    648                     services: services
    649                 )
    650                 .id(gameID)
    651             }
    652         }
    653         .environment(services.preferences)
    654         .overlay {
    655             if let join = pendingJoin {
    656                 JoiningPuzzleView(shape: join.shape, onCancel: {
    657                     joinTask?.cancel()
    658                     withAnimation { pendingJoin = nil }
    659                 })
    660                 .transition(.opacity)
    661                 .zIndex(1)
    662             }
    663         }
    664         .task {
    665             NotificationState.setActivePuzzleID(nil)
    666             NotificationNavigationBroker.shared.onOpenGame = { gameID in
    667                 UIApplication.shared.dismissPresentedViewControllers()
    668                 navigationPath = []
    669                 navigationPath.append(gameID)
    670             }
    671             NotificationNavigationBroker.shared.onOpenGameList = {
    672                 UIApplication.shared.dismissPresentedViewControllers()
    673                 navigationPath = []
    674                 pendingInviteNotificationGameID = nil
    675             }
    676             NotificationNavigationBroker.shared.onOpenInviteInGameList = { gameID in
    677                 UIApplication.shared.dismissPresentedViewControllers()
    678                 navigationPath = []
    679                 pendingInviteNotificationGameID = gameID
    680             }
    681             NotificationNavigationBroker.shared.onReplaceOpenGame = { from, to in
    682                 // Only the puzzle actually on top is swapped; anything deeper (or
    683                 // a stack the user has since left) is left alone.
    684                 guard navigationPath.last == from else { return }
    685                 // One assignment, so the stack goes straight from the live game
    686                 // to its Chronicle rather than popping and pushing. Animations
    687                 // off for the same reason: this is a substitution of one
    688                 // representation for another, not a navigation the user made.
    689                 var transaction = Transaction()
    690                 transaction.disablesAnimations = true
    691                 withTransaction(transaction) {
    692                     navigationPath[navigationPath.count - 1] = to
    693                 }
    694             }
    695             // A tapped Crossmate share link (universal link), routed here by the
    696             // `SceneDelegate` through `ShareLinkBroker` — `.onContinueUserActivity`
    697             // never fires once a custom scene delegate is installed. Show the
    698             // placeholder immediately off the silhouette in the URL, then accept
    699             // the share (the iCloud token is reconstructed locally, so there's no
    700             // Safari → iCloud bounce). `.cloudShareAcceptanceCompleted` clears the
    701             // placeholder and navigates to the joined game.
    702             ShareLinkBroker.shared.onOpenShareLink = { url in
    703                 guard let route = ShareLinkRoute(shortLink: url) else { return }
    704                 withAnimation { pendingJoin = PendingJoinPlaceholder(shape: route.shape) }
    705                 joinTask = Task<Void, Error> {
    706                     do {
    707                         let outcome = try await services.cloudService.acceptShare(url: route.iCloudShareURL)
    708                         // The share was accepted but its puzzle hasn't synced in
    709                         // yet — the joining screen timed out. The game still
    710                         // arrives in the list shortly, so reassure rather than
    711                         // leave the user wondering why nothing opened.
    712                         if case .pendingSync = outcome {
    713                             services.announcements.post(.puzzleStillSyncing())
    714                         }
    715                     } catch {
    716                         // A Cancel tap returns without throwing, so reaching
    717                         // here is a genuine failure to join. The common one is a
    718                         // dead link — the inviter deleted or left the game, so
    719                         // its share is gone, which the metadata fetch reports as
    720                         // `.unknownItem`/`.zoneNotFound`. Surface it on the Game
    721                         // List rather than bouncing the user back in silence.
    722                         guard !Task.isCancelled else { return }
    723                         withAnimation { pendingJoin = nil }
    724                         let code = CloudService.cloudErrorCode(error)
    725                         let gone = (error as? AcceptedShareError)?.kind == .removed
    726                             || code == .unknownItem
    727                             || code == .zoneNotFound
    728                         let versionMismatch = (error as? CloudServiceError) == .containerVersionMismatch
    729                         // Both of these are the user's world being different to
    730                         // what the link assumed, not something broken: warn and
    731                         // explain rather than reporting a failure.
    732                         let expected = gone || versionMismatch
    733                         let copy: (title: String, body: String)
    734                         if versionMismatch {
    735                             copy = ("Update Needed", error.localizedDescription)
    736                         } else if gone {
    737                             copy = ("Puzzle Removed", "This puzzle was removed.")
    738                         } else {
    739                             copy = CloudFailureCopy.joinFailure(for: error)
    740                         }
    741                         services.eventLog.note(
    742                             "share link join failed: \(error.localizedDescription)",
    743                             level: expected ? "info" : "error"
    744                         )
    745                         services.announcements.post(Announcement(
    746                             id: "share-link-join-failed",
    747                             scope: .global,
    748                             severity: expected ? .warning : .error,
    749                             title: copy.title,
    750                             body: copy.body,
    751                             dismissal: .manual
    752                         ))
    753                     }
    754                 }
    755             }
    756             await services.start(appDelegate: appDelegate)
    757         }
    758         .onOpenURL { url in
    759             if let id = services.importService.importGame(from: url) {
    760                 navigationPath.append(id)
    761             }
    762         }
    763         .onReceive(NotificationCenter.default.publisher(for: .cloudShareAcceptanceStarted)) { _ in
    764             UIApplication.shared.dismissPresentedViewControllers()
    765         }
    766         .onReceive(NotificationCenter.default.publisher(for: .cloudShareAcceptanceCompleted)) { notification in
    767             withAnimation { pendingJoin = nil }
    768             guard let gameID = notification.userInfo?["gameID"] as? UUID else { return }
    769             // A join driven from inside the puzzle view (an `.invite`
    770             // notification tap) is already showing this game — appending
    771             // again would stack a duplicate screen. Navigate only when the
    772             // accept came from elsewhere, e.g. the Invited list.
    773             guard NotificationState.activePuzzleID() != gameID else { return }
    774             navigationPath.append(gameID)
    775         }
    776         .onChange(of: scenePhase) { _, newPhase in
    777             switch newPhase {
    778             case .active:
    779                 services.noteAppForeground(true)
    780                 Task { await services.syncOnForeground() }
    781             case .background, .inactive:
    782                 services.noteAppForeground(false)
    783                 NotificationState.setActivePuzzleID(nil)
    784                 // Synchronous: takes a background-execution assertion before the
    785                 // flush Task suspends, so buffered edits persist + enqueue even
    786                 // if the scene is suspended immediately.
    787                 services.syncOnBackground()
    788             @unknown default:
    789                 break
    790             }
    791         }
    792     }
    793 
    794     private func acceptInviteFromGameList(
    795         shareURL: String,
    796         pingRecordName: String,
    797         shape: GridSilhouette.Grid?
    798     ) async throws {
    799         joinTask?.cancel()
    800         withAnimation { pendingJoin = PendingJoinPlaceholder(shape: shape) }
    801         let task = Task<Void, Error> {
    802             let outcome = try await services.invites.acceptInvite(
    803                 shareURL: shareURL,
    804                 pingRecordName: pingRecordName
    805             )
    806             guard !Task.isCancelled else { return }
    807             if case .pendingSync = outcome {
    808                 services.announcements.post(.puzzleStillSyncing())
    809             }
    810         }
    811         joinTask = task
    812         do {
    813             try await task.value
    814         } catch {
    815             if task.isCancelled { return }
    816             withAnimation { pendingJoin = nil }
    817             throw error
    818         }
    819     }
    820 }
    821 
    822 private extension UIApplication {
    823     func dismissPresentedViewControllers() {
    824         for scene in connectedScenes {
    825             guard let windowScene = scene as? UIWindowScene else { continue }
    826             for window in windowScene.windows where window.isKeyWindow {
    827                 window.rootViewController?.dismiss(animated: true)
    828             }
    829         }
    830     }
    831 }
    832 
    833 // MARK: - Game Destination
    834 
    835 /// Loads a game when navigated to.
    836 private struct PuzzleDisplayView: View {
    837     private var syncedID: UUID? {
    838         guard preferences.isICloudSyncEnabled,
    839               let mutator = session?.mutator,
    840               !mutator.isArchived
    841         else { return nil }
    842         return gameID
    843     }
    844 
    845     private var syncedScope: CKDatabase.Scope? {
    846         guard preferences.isICloudSyncEnabled,
    847               let mutator = session?.mutator,
    848               !mutator.isArchived
    849         else { return nil }
    850         return mutator.isOwned ? .private : .shared
    851     }
    852 
    853     let gameID: UUID
    854     let store: GameStore
    855     let shareController: ShareController
    856     let services: AppServices
    857 
    858     @Environment(PlayerPreferences.self) private var preferences
    859     @Environment(\.scenePhase) private var scenePhase
    860     @State private var session: PlayerSession?
    861     @State private var roster: PlayerRoster?
    862     @State private var loadError: String?
    863     @State private var loadingMessage = "Loading puzzle…"
    864     @State private var openPuzzleFollowUpTask: Task<Void, Never>?
    865 
    866     var body: some View {
    867         Group {
    868             if let session, let roster {
    869                 PuzzleView(
    870                     session: session,
    871                     shareController: shareController,
    872                     roster: roster,
    873                     onComplete: { notifyPeers in
    874                         guard !session.mutator.isArchived else { return }
    875                         do {
    876                             let changed = try notifyPeers
    877                                 ? store.markCompleted(id: gameID)
    878                                 : store.markCompletedFromObservedSolvedState(id: gameID)
    879                             if changed {
    880                                 // Seal the solve clock at the finish so peers and
    881                                 // sibling devices get the final time at once, not
    882                                 // only when this device next leaves the puzzle.
    883                                 services.sessions.noteClockCompleted(gameID: gameID)
    884                             }
    885                             // The game is done — drop the other player's cursor
    886                             // and tear the live room down. Idempotent, so the
    887                             // repeated observed/on-appear completions are safe.
    888                             Task { await services.engagement.endEngagement(gameID: gameID) }
    889                         } catch {
    890                             services.announcements.post(Announcement(
    891                                 id: "mark-completed-error-\(gameID.uuidString)",
    892                                 scope: .game(gameID),
    893                                 severity: .error,
    894                                 title: "Saving Failed",
    895                                 body: error.localizedDescription,
    896                                 dismissal: .manual
    897                             ))
    898                         }
    899                     },
    900                     onResign: {
    901                         try store.resignGame(id: gameID)
    902                         services.sessions.noteClockCompleted(gameID: gameID)
    903                     },
    904                     onDelete: { try store.deleteGame(id: gameID) },
    905                     onNudge: { await services.sessions.nudge(gameID: gameID) },
    906                     nudgeReadyAt: { services.sessions.nudgeReadyAt(gameID: gameID) },
    907                     loadReplay: {
    908                         let short = gameID.uuidString.prefix(8)
    909                         // Finished-game timelines are immutable (edit-lockout),
    910                         // so a cached assembly is reused verbatim on re-entry —
    911                         // this is what stops rapid nav from re-running the merge
    912                         // each time a fresh `ReplayControls` instance asks for it.
    913                         return await ReplayAssembler.memoised(
    914                             cached: services.replays.cachedReplayTimeline(gameID: gameID),
    915                             onHit: { cached in
    916                                 services.syncMonitor.note(
    917                                     "replay[\(short)]: served from timeline memo " +
    918                                     "(steps=\(cached.count))"
    919                                 )
    920                             },
    921                             store: { services.replays.cacheReplayTimeline($0, gameID: gameID) }
    922                         ) {
    923                             // Local-first only for unshared games: this device's
    924                             // journal is the whole history, so replay needs no
    925                             // CloudKit. Shared games always use the merged
    926                             // loader, even if their Moves rows have not caught
    927                             // up locally yet, so replay can wait for every
    928                             // contributing device's journal instead of caching
    929                             // an incomplete local timeline.
    930                             let entries = store.localJournalEntries(for: gameID)
    931                             if !store.isGameShared(gameID: gameID),
    932                                !store.isGameArchived(gameID: gameID) {
    933                                 services.syncMonitor.note(
    934                                     "replay[\(short)]: local-only path " +
    935                                     "(unshared game), localEntries=\(entries.count)"
    936                                 )
    937                                 return .ready(ReplayTimeline(merging: [entries]))
    938                             }
    939                             services.syncMonitor.note(
    940                                 "replay[\(short)]: shared merged path, " +
    941                                 "localEntries=\(entries.count)"
    942                             )
    943                             return await services.replays.loadReplay(gameID: gameID)
    944                         }
    945                     },
    946                     loadRecentChanges: {
    947                         // Cells a peer changed since this device last viewed the
    948                         // game. A missing timestamp means a first-ever open —
    949                         // establish the baseline silently rather than flag the
    950                         // whole board (the leave/background path below stamps it).
    951                         guard let since = services.gameViewedStore.lastViewed(forGame: gameID)
    952                         else { return [:] }
    953                         return store.recentlyChangedCells(forGame: gameID, since: since)
    954                     },
    955                     markPuzzleViewed: { stampPuzzleViewed() }
    956                 )
    957             } else if let loadError {
    958                 ContentUnavailableView(
    959                     "Couldn't load puzzle",
    960                     systemImage: "exclamationmark.triangle",
    961                     description: Text(loadError)
    962                 )
    963             } else {
    964                 ProgressView(loadingMessage)
    965                     .frame(maxWidth: .infinity, maxHeight: .infinity)
    966             }
    967         }
    968         .navigationTitle("")
    969         .navigationBarTitleDisplayMode(.inline)
    970         .task(id: syncedID) {
    971             guard let scope = syncedScope else { return }
    972             await services.freshenPuzzleGrid(gameID: gameID, scope: scope, reason: .appeared)
    973         }
    974         .task(id: gameID) {
    975             openPuzzleFollowUpTask?.cancel()
    976             openPuzzleFollowUpTask = nil
    977             session = nil
    978             roster = nil
    979             loadError = nil
    980             loadingMessage = "Loading puzzle…"
    981             let canonicalGameID = store.canonicalGameID(for: gameID)
    982             Task {
    983                 await services.badge.dismissDeliveredNotifications(
    984                     for: canonicalGameID
    985                 )
    986             }
    987 
    988             do {
    989                 if let plan = NYTPuzzleUpgrader.plan(for: gameID, store: store) {
    990                     loadingMessage = "Updating puzzle…"
    991                     let fetcher = services.nytFetcher
    992                     let outcome = await NYTPuzzleUpgrader.apply(plan: plan, store: store) { date in
    993                         try await fetcher.fetchPuzzle(for: date)
    994                     }
    995                     switch outcome {
    996                     case .upgraded:
    997                         services.eventLog.note("[upgrade NYT \(gameID.uuidString.prefix(8))] applied")
    998                     case .mismatched(let reason):
    999                         services.eventLog.note("[upgrade NYT \(gameID.uuidString.prefix(8))] structural mismatch — \(reason)", level: "warn")
   1000                     case .failed(let error):
   1001                         services.eventLog.note("[upgrade NYT \(gameID.uuidString.prefix(8))] fetch failed: \(error)", level: "error")
   1002                     }
   1003                 }
   1004                 let (game, mutator) = try store.loadGame(id: gameID)
   1005                 let newSession = PlayerSession(
   1006                     game: game,
   1007                     mutator: mutator,
   1008                     cursorStore: services.cursorStore,
   1009                     preferences: preferences
   1010                 )
   1011                 let newRoster = services.makePlayerRoster(for: gameID, preferences: preferences)
   1012                 await newRoster.preload()
   1013                 guard !Task.isCancelled else { return }
   1014                 roster = newRoster
   1015                 session = newSession
   1016                 noteSessionPhase(scenePhase)
   1017                 openPuzzleFollowUpTask = Task { @MainActor in
   1018                     await finishOpeningPuzzle(
   1019                         session: newSession,
   1020                         roster: newRoster,
   1021                         isShared: mutator.isShared
   1022                     )
   1023                 }
   1024             } catch {
   1025                 loadError = String(describing: error)
   1026             }
   1027         }
   1028         .task(id: session?.mutator.isShared == true) {
   1029             // Solve-clock liveness heartbeat. Only for a shared game on screen:
   1030             // a co-solver extrapolates this device's open session toward now only
   1031             // as far as its last beat, so a continuous sitting must keep beating
   1032             // or it would be briefly capped on their clock. Solo games skip it —
   1033             // the local clock already extrapolates to now and no peer is watching.
   1034             // Cancelled on leave or gameID change.
   1035             guard session?.mutator.isShared == true else { return }
   1036             while !Task.isCancelled {
   1037                 try? await Task.sleep(for: .seconds(SessionCoordinator.clockHeartbeatInterval))
   1038                 guard !Task.isCancelled else { break }
   1039                 services.sessions.noteClockHeartbeat(gameID: gameID)
   1040             }
   1041         }
   1042         .onChange(of: session?.mutator.isShared) { oldValue, newValue in
   1043             // Fire only on a definite `false → true` transition — that's the
   1044             // mid-session share-create case. Initial loads of an already-shared
   1045             // game go `nil → true` and are handled inline in `task(id: gameID)`.
   1046             guard oldValue == false, newValue == true,
   1047                   let session,
   1048                   preferences.isICloudSyncEnabled
   1049             else { return }
   1050             Task { await activateSharing(for: session) }
   1051         }
   1052         .onChange(of: scenePhase) { _, newPhase in
   1053             guard session?.mutator.isArchived == false else { return }
   1054             noteSessionPhase(newPhase)
   1055             // Only act on settled transitions. `.inactive` is transient (lock
   1056             // animation, app switcher, Control Center, banners), so a write
   1057             // there would thrash the Player record on every lock/unlock.
   1058             // `.background` publishes the cursor so sibling devices catch up
   1059             // promptly; `.active` republishes on resume in case moves arrived
   1060             // (and were marked seen in lockstep) while we were foregrounded.
   1061             let id = gameID
   1062             switch newPhase {
   1063             case .active:
   1064                 Task {
   1065                     await services.publishReadCursor(for: id, mode: .activeLease)
   1066                     // Backgrounding tears the engagement socket down without
   1067                     // rebuilding it, so a live session that dropped while we
   1068                     // were away never comes back on its own. Re-offer on
   1069                     // resume; this is a no-op when the channel is still live
   1070                     // (the coordinator only acts from an idle state).
   1071                     await services.engagement.startEngagementIfPossible(gameID: id)
   1072                 }
   1073                 // Reveal any peer changes that landed while we were away on the
   1074                 // same resume the catch-up banner re-derives on, not only on a
   1075                 // fresh navigation into the puzzle.
   1076                 Task { await recaptureRecentChanges() }
   1077             case .background:
   1078                 // Stop the engagement reconnect loop so it doesn't keep
   1079                 // re-dialling the live socket on background CKSyncEngine wakes.
   1080                 // (Re-leasing `presenceUntil` in the background is now prevented
   1081                 // centrally by publishReadCursor's foreground gate, not here.)
   1082                 // `.active` re-arms the loop via `startEngagementIfPossible`.
   1083                 services.engagement.cancelEngagementReconnectRetry(gameID: id)
   1084                 Task { await services.publishReadCursor(for: id, mode: .currentTime) }
   1085                 // Backgrounding counts as leaving for the away-change baseline:
   1086                 // anything a peer does after this should flag on the next open.
   1087                 stampPuzzleViewed()
   1088             case .inactive:
   1089                 break
   1090             @unknown default:
   1091                 break
   1092             }
   1093         }
   1094         .onAppear {
   1095             services.puzzleAppeared(gameID: gameID)
   1096         }
   1097         .onDisappear {
   1098             openPuzzleFollowUpTask?.cancel()
   1099             openPuzzleFollowUpTask = nil
   1100             services.puzzleDisappeared(gameID: gameID)
   1101             guard session?.mutator.isArchived == false else { return }
   1102             let selectionPublisher = services.playerSelectionPublisher
   1103             let movesUpdater = services.movesUpdater
   1104             let id = gameID
   1105             // Navigating away is a leave: clear the active-puzzle ID and commit
   1106             // the catch-up baseline (idempotent with the .background path).
   1107             services.sessions.notePuzzleClosed(gameID: id)
   1108             services.engagement.scheduleEngagementEnd(gameID: id)
   1109             // Navigating away is a leave: stamp the away-change baseline so the
   1110             // next open diffs against now.
   1111             stampPuzzleViewed()
   1112             Task {
   1113                 await movesUpdater.flush()
   1114                 // The clear-cursor and close-lease writes both enqueue without
   1115                 // forcing a drain (see `enqueuePlayer`'s `drain` flag), so there
   1116                 // are no sends for a burst to collapse — CKSyncEngine ships both
   1117                 // Player-record changes on its own schedule.
   1118                 await selectionPublisher.clear()
   1119                 await services.publishReadCursor(for: id, mode: .currentTime)
   1120                 // The pause self-gates on content (no letter changes reaches
   1121                 // no one) and supersedes any pending grace-window timer, so the
   1122                 // close-after-background case never fires a second push.
   1123                 await services.sessions.publishSessionEndPush(gameID: id)
   1124             }
   1125         }
   1126     }
   1127 
   1128     private func finishOpeningPuzzle(
   1129         session loadedSession: PlayerSession,
   1130         roster loadedRoster: PlayerRoster,
   1131         isShared: Bool
   1132     ) async {
   1133         await loadedRoster.refresh()
   1134         guard !Task.isCancelled, session === loadedSession else { return }
   1135 
   1136         // Re-derive banners that hang off persisted game state (e.g. the
   1137         // access-revoked banner). They are otherwise posted only on the live
   1138         // sync transition that first produces them, which a puzzle opened in
   1139         // a later process never re-fires.
   1140         let openState = OpenPuzzleState(
   1141             gameID: gameID,
   1142             isAccessRevoked: loadedSession.mutator.isAccessRevoked,
   1143             isSyncSupported: loadedSession.mutator.isSyncSupported
   1144         )
   1145         for announcement in OpenPuzzleBanner.announcements(for: openState) {
   1146             services.announcements.post(announcement)
   1147         }
   1148         if loadedSession.mutator.isArchived {
   1149             services.syncMonitor.note(
   1150                 "PuzzleDisplay[\(gameID.uuidString.prefix(8))]: loaded Chronicle roster " +
   1151                 "(live lifecycle disabled)"
   1152             )
   1153             // The Chronicle is a local projection with a derived storage ID.
   1154             // Publish the read watermark through the retained live game's
   1155             // original identity so sibling devices clear the same Completed
   1156             // tile and app-icon badge.
   1157             await services.publishReadCursor(
   1158                 for: store.canonicalGameID(for: gameID),
   1159                 mode: .currentTime
   1160             )
   1161             return
   1162         }
   1163         if isShared && preferences.isICloudSyncEnabled {
   1164             services.syncMonitor.note(
   1165                 "PuzzleDisplay[\(gameID.uuidString.prefix(8))]: loaded shared roster"
   1166             )
   1167             await services.logPlayerLeaseSnapshot(gameID: gameID)
   1168             await activateSharing(for: loadedSession, refreshRoster: false)
   1169         } else {
   1170             services.syncMonitor.note(
   1171                 "PuzzleDisplay[\(gameID.uuidString.prefix(8))]: loaded local roster"
   1172             )
   1173             await services.publishReadCursor(for: gameID, mode: .activeLease)
   1174             await services.playerSelectionPublisher.clear()
   1175         }
   1176     }
   1177 
   1178     /// Forwards settled scene phases to the session controller, which owns
   1179     /// the begin/end/grace choreography (active-puzzle ID, deferred play and
   1180     /// pause pushes, catch-up banner).
   1181     private func noteSessionPhase(_ phase: ScenePhase) {
   1182         guard session?.mutator.isArchived == false else { return }
   1183         switch phase {
   1184         case .active:
   1185             services.sessions.notePuzzleActive(gameID: gameID)
   1186         case .background:
   1187             services.sessions.notePuzzleBackgrounded(gameID: gameID)
   1188         case .inactive:
   1189             // Transient (lock animation, app switcher, Control Center,
   1190             // banners) — the user is still on the puzzle. Toggling the
   1191             // active-puzzle ID or firing a pause push here would thrash
   1192             // both on every interruption.
   1193             break
   1194         @unknown default:
   1195             break
   1196         }
   1197     }
   1198 
   1199     /// Records that this device has now viewed the game up to the current
   1200     /// moment, the baseline the next open diffs against for "changed while you
   1201     /// were away" borders. Device-local; only shared games are tracked (solo
   1202     /// games have no peers to surface). Called on the player's first
   1203     /// interaction (via `PuzzleView`'s acknowledgement) and on leave/background.
   1204     private func stampPuzzleViewed() {
   1205         guard session?.mutator.isShared == true else { return }
   1206         // Pair the away baseline with a seed request. Sharing activation
   1207         // normally established it already; this also covers a very quick leave
   1208         // and ensures an empty grid is recorded as an intentional snapshot.
   1209         store.ensurePeerChangeLedgerSeeded(for: gameID)
   1210         services.gameViewedStore.advance(Date(), forGame: gameID)
   1211     }
   1212 
   1213     /// Recaptures the "changed while you were away" borders against the current
   1214     /// view baseline. The `.task`-driven capture only runs on a fresh open, so a
   1215     /// background→foreground resume of the same open puzzle would otherwise leave
   1216     /// the borders stale (or absent) even as the catch-up banner re-derives.
   1217     /// Mirrors the open beat's settle so the diff reflects the freshened grid;
   1218     /// idempotent — `recentChanges` is `Equatable`, and the baseline only
   1219     /// advances on leave.
   1220     private func recaptureRecentChanges() async {
   1221         try? await Task.sleep(for: .milliseconds(750))
   1222         guard let session, session.mutator.isShared,
   1223               let since = services.gameViewedStore.lastViewed(forGame: gameID)
   1224         else { return }
   1225         session.recentChanges = store.recentlyChangedCells(forGame: gameID, since: since)
   1226     }
   1227 
   1228     /// Initialises shared-game state (roster, selection publishing, name broadcast) for
   1229     /// the open session. Called when the puzzle first appears as shared, and
   1230     /// again if a previously-solo game becomes shared mid-session.
   1231     private func activateSharing(for session: PlayerSession, refreshRoster: Bool = true) async {
   1232         // Establish the local letter snapshot while the puzzle is visible, so
   1233         // the first inbound Moves record can be compared with it. This also
   1234         // records an intentionally empty snapshot for a puzzle with no entries.
   1235         store.ensurePeerChangeLedgerSeeded(for: gameID)
   1236         Task { await AppDelegate.requestNotificationAuthorizationIfNeeded() }
   1237         let activeRoster: PlayerRoster
   1238         if let roster {
   1239             activeRoster = roster
   1240         } else {
   1241             let newRoster = services.makePlayerRoster(for: gameID, preferences: preferences)
   1242             roster = newRoster
   1243             activeRoster = newRoster
   1244         }
   1245         if refreshRoster {
   1246             await activeRoster.refresh()
   1247         }
   1248         guard let authorID = services.identity.currentID else { return }
   1249         let selectionPublisher = services.playerSelectionPublisher
   1250         // Fan out read-cursor lease, display name, and the initial cursor
   1251         // track inside one Player-record send burst so they ship in a single
   1252         // CKSyncEngine drain. Name publish lands before the selection so the
   1253         // partner never sees a "Player" placeholder; the burst close then
   1254         // issues exactly one `sendChanges`. Subsequent selection edits go
   1255         // through `PlayerSelectionPublisher`'s trailing-edge debounce and
   1256         // each fires its own drain — same shape as Moves.
   1257         let syncEngine = services.syncEngine
   1258         let burstScope = await syncEngine.beginPlayerSendBurst(gameID: gameID)
   1259         // Stamp this game's derived push address inside the burst so it ships on
   1260         // the same Player-record write as the read-cursor lease; registration of
   1261         // this device under it happens just after the burst.
   1262         _ = services.accountPush.setDerivedPushAddress(gameID: gameID, authorID: authorID)
   1263         await services.publishReadCursor(for: gameID, mode: .activeLease)
   1264         await services.playerNamePublisher?.publishName(for: gameID)
   1265         await selectionPublisher.begin(
   1266             gameID: gameID,
   1267             authorID: authorID,
   1268             currentName: preferences.name
   1269         )
   1270         if let track = session.currentCursorTrack {
   1271             await selectionPublisher.publishImmediately(track)
   1272             await services.engagement.noteLocalSelection(track, gameID: gameID)
   1273         }
   1274         if let burstScope {
   1275             await syncEngine.endPlayerSendBurst(scope: burstScope)
   1276         }
   1277         // Register this device under the current address set. The open burst
   1278         // already stamped this game's Player row, so avoid a full repair sweep.
   1279         await services.accountPush.refreshPushRegistration()
   1280         await services.engagement.startEngagementIfPossible(gameID: gameID)
   1281         let services = self.services
   1282         let eventGameID = gameID
   1283         session.onSelectionChanged = { selection in
   1284             Task {
   1285                 await selectionPublisher.publish(selection)
   1286                 await services.engagement.noteLocalSelection(selection, gameID: eventGameID)
   1287             }
   1288         }
   1289         // check/reveal no longer ping peers; cell state propagates through
   1290         // Moves (the cell's `CellMark` carries the check/reveal result).
   1291     }
   1292 
   1293 }