crossmate

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

CrossmateApp.swift (56147B)


      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 
    428     private var pendingGameIDs: [UUID] = []
    429     private var pendingGameListOpen = false
    430     private var pendingInviteGameIDs: [UUID] = []
    431 
    432     private init() {}
    433 
    434     func openGame(_ gameID: UUID) {
    435         guard let onOpenGame else {
    436             pendingGameIDs.append(gameID)
    437             return
    438         }
    439         onOpenGame(gameID)
    440     }
    441 
    442     func openGameList(inviteGameID: UUID? = nil) {
    443         if let inviteGameID {
    444             guard let onOpenInviteInGameList else {
    445                 pendingInviteGameIDs.append(inviteGameID)
    446                 return
    447             }
    448             onOpenInviteInGameList(inviteGameID)
    449             return
    450         }
    451         guard let onOpenGameList else {
    452             pendingGameListOpen = true
    453             return
    454         }
    455         onOpenGameList()
    456     }
    457 
    458     private func flushPendingGameIDs() {
    459         guard let onOpenGame, !pendingGameIDs.isEmpty else { return }
    460         let gameIDs = pendingGameIDs
    461         pendingGameIDs.removeAll()
    462         for gameID in gameIDs {
    463             onOpenGame(gameID)
    464         }
    465     }
    466 
    467     private func flushPendingGameListOpen() {
    468         guard let onOpenGameList, pendingGameListOpen else { return }
    469         pendingGameListOpen = false
    470         onOpenGameList()
    471     }
    472 
    473     private func flushPendingInviteGameListOpen() {
    474         guard let onOpenInviteInGameList, !pendingInviteGameIDs.isEmpty else { return }
    475         let gameIDs = pendingInviteGameIDs
    476         pendingInviteGameIDs.removeAll()
    477         for gameID in gameIDs {
    478             onOpenInviteInGameList(gameID)
    479         }
    480     }
    481 }
    482 
    483 @MainActor
    484 final class CloudShareAcceptanceBroker {
    485     static let shared = CloudShareAcceptanceBroker()
    486 
    487     var onAcceptShare: ((CKShare.Metadata) async -> Void)? {
    488         didSet { flushPendingAcceptedShares() }
    489     }
    490 
    491     private var pendingAcceptedShares: [CKShare.Metadata] = []
    492 
    493     private init() {}
    494 
    495     func acceptCloudKitShare(_ metadata: CKShare.Metadata) {
    496         guard let onAcceptShare else {
    497             pendingAcceptedShares.append(metadata)
    498             return
    499         }
    500         Task { await onAcceptShare(metadata) }
    501     }
    502 
    503     private func flushPendingAcceptedShares() {
    504         guard let onAcceptShare, !pendingAcceptedShares.isEmpty else { return }
    505         let metadatas = pendingAcceptedShares
    506         pendingAcceptedShares.removeAll()
    507         for metadata in metadatas {
    508             Task { await onAcceptShare(metadata) }
    509         }
    510     }
    511 }
    512 
    513 /// Bridges a tapped Crossmate universal link from the `SceneDelegate` to
    514 /// `RootView`. A custom scene delegate is installed for the OS CKShare-accept
    515 /// callback, and once that exists, `NSUserActivityTypeBrowsingWeb` activities
    516 /// are delivered to *it* rather than SwiftUI's `.onContinueUserActivity` — so
    517 /// they must be forwarded explicitly. Buffers links that arrive before
    518 /// `RootView` wires up its handler (a cold launch delivers the activity in
    519 /// `scene(_:willConnectTo:)`, before the root `.task` runs), mirroring
    520 /// `CloudShareAcceptanceBroker`.
    521 @MainActor
    522 final class ShareLinkBroker {
    523     static let shared = ShareLinkBroker()
    524 
    525     var onOpenShareLink: ((URL) -> Void)? {
    526         didSet { flushPendingLinks() }
    527     }
    528 
    529     private var pendingURLs: [URL] = []
    530 
    531     private init() {}
    532 
    533     func openShareLink(_ url: URL) {
    534         guard let onOpenShareLink else {
    535             pendingURLs.append(url)
    536             return
    537         }
    538         onOpenShareLink(url)
    539     }
    540 
    541     private func flushPendingLinks() {
    542         guard let onOpenShareLink, !pendingURLs.isEmpty else { return }
    543         let urls = pendingURLs
    544         pendingURLs.removeAll()
    545         for url in urls { onOpenShareLink(url) }
    546     }
    547 }
    548 
    549 final class SceneDelegate: NSObject, UIWindowSceneDelegate {
    550     func scene(
    551         _ scene: UIScene,
    552         willConnectTo session: UISceneSession,
    553         options connectionOptions: UIScene.ConnectionOptions
    554     ) {
    555         // SwiftUI owns the window in this lifecycle — only read the launch
    556         // options here, never create a window. A universal link (or a CKShare)
    557         // that cold-launches the app arrives via `connectionOptions`, not
    558         // through the `continue` / `userDidAcceptCloudKitShareWith` callbacks.
    559         for activity in connectionOptions.userActivities {
    560             handle(userActivity: activity)
    561         }
    562         if let metadata = connectionOptions.cloudKitShareMetadata {
    563             CloudShareAcceptanceBroker.shared.acceptCloudKitShare(metadata)
    564         }
    565     }
    566 
    567     func scene(_ scene: UIScene, continue userActivity: NSUserActivity) {
    568         handle(userActivity: userActivity)
    569     }
    570 
    571     func windowScene(
    572         _ windowScene: UIWindowScene,
    573         userDidAcceptCloudKitShareWith metadata: CKShare.Metadata
    574     ) {
    575         CloudShareAcceptanceBroker.shared.acceptCloudKitShare(metadata)
    576     }
    577 
    578     /// Forwards a tapped Crossmate universal link to `RootView` via
    579     /// `ShareLinkBroker`. Non-web activities (and web activities without a URL)
    580     /// are ignored.
    581     private func handle(userActivity: NSUserActivity) {
    582         guard userActivity.activityType == NSUserActivityTypeBrowsingWeb,
    583               let url = userActivity.webpageURL else { return }
    584         ShareLinkBroker.shared.openShareLink(url)
    585     }
    586 }
    587 
    588 // MARK: - Root View
    589 
    590 /// Drives the join placeholder overlay while a tapped share link is being
    591 /// accepted; `shape` is the silhouette decoded from the link, if any.
    592 struct PendingJoinPlaceholder: Identifiable {
    593     let id = UUID()
    594     let shape: GridSilhouette.Grid?
    595 }
    596 
    597 struct RootView: View {
    598     let services: AppServices
    599     let appDelegate: AppDelegate
    600 
    601     @Environment(\.scenePhase) private var scenePhase
    602     @State private var navigationPath = NavigationPath()
    603     @State private var pendingJoin: PendingJoinPlaceholder?
    604     @State private var pendingInviteNotificationGameID: UUID?
    605     /// The in-flight share-accept driven by a tapped link, retained so the
    606     /// joining screen's Cancel can stop it (its poll unwinds on cancellation).
    607     @State private var joinTask: Task<Void, Error>?
    608 
    609     var body: some View {
    610         NavigationStack(path: $navigationPath) {
    611             GameListView(
    612                 store: services.store,
    613                 shareController: services.shareController,
    614                 authorIdentity: services.identity,
    615                 onRefresh: { await services.refreshLibrary() },
    616                 onAppear: { await services.gameListAppeared() },
    617                 onLoadRecentCompleted: { cutoff in
    618                     await services.loadRecentCompleted(since: cutoff)
    619                 },
    620                 onLoadMoreCompleted: {
    621                     await services.loadMoreCompleted()
    622                 },
    623                 onDisappear: { services.gameListDisappeared() },
    624                 onAcceptInvite: { shareURL, pingRecordName, shape in
    625                     try await acceptInviteFromGameList(
    626                         shareURL: shareURL,
    627                         pingRecordName: pingRecordName,
    628                         shape: shape
    629                     )
    630                 },
    631                 pendingInviteNotificationGameID: $pendingInviteNotificationGameID,
    632                 navigationPath: $navigationPath
    633             )
    634             .navigationDestination(for: UUID.self) { gameID in
    635                 PuzzleDisplayView(
    636                     gameID: gameID,
    637                     store: services.store,
    638                     shareController: services.shareController,
    639                     services: services
    640                 )
    641                 .id(gameID)
    642             }
    643         }
    644         .environment(services.preferences)
    645         .overlay {
    646             if let join = pendingJoin {
    647                 JoiningPuzzleView(shape: join.shape, onCancel: {
    648                     joinTask?.cancel()
    649                     withAnimation { pendingJoin = nil }
    650                 })
    651                 .transition(.opacity)
    652                 .zIndex(1)
    653             }
    654         }
    655         .task {
    656             NotificationState.setActivePuzzleID(nil)
    657             NotificationNavigationBroker.shared.onOpenGame = { gameID in
    658                 UIApplication.shared.dismissPresentedViewControllers()
    659                 navigationPath = NavigationPath()
    660                 navigationPath.append(gameID)
    661             }
    662             NotificationNavigationBroker.shared.onOpenGameList = {
    663                 UIApplication.shared.dismissPresentedViewControllers()
    664                 navigationPath = NavigationPath()
    665                 pendingInviteNotificationGameID = nil
    666             }
    667             NotificationNavigationBroker.shared.onOpenInviteInGameList = { gameID in
    668                 UIApplication.shared.dismissPresentedViewControllers()
    669                 navigationPath = NavigationPath()
    670                 pendingInviteNotificationGameID = gameID
    671             }
    672             // A tapped Crossmate share link (universal link), routed here by the
    673             // `SceneDelegate` through `ShareLinkBroker` — `.onContinueUserActivity`
    674             // never fires once a custom scene delegate is installed. Show the
    675             // placeholder immediately off the silhouette in the URL, then accept
    676             // the share (the iCloud token is reconstructed locally, so there's no
    677             // Safari → iCloud bounce). `.cloudShareAcceptanceCompleted` clears the
    678             // placeholder and navigates to the joined game.
    679             ShareLinkBroker.shared.onOpenShareLink = { url in
    680                 guard let route = ShareLinkRoute(shortLink: url) else { return }
    681                 withAnimation { pendingJoin = PendingJoinPlaceholder(shape: route.shape) }
    682                 joinTask = Task<Void, Error> {
    683                     do {
    684                         let outcome = try await services.cloudService.acceptShare(url: route.iCloudShareURL)
    685                         // The share was accepted but its puzzle hasn't synced in
    686                         // yet — the joining screen timed out. The game still
    687                         // arrives in the list shortly, so reassure rather than
    688                         // leave the user wondering why nothing opened.
    689                         if case .pendingSync = outcome {
    690                             services.announcements.post(.puzzleStillSyncing())
    691                         }
    692                     } catch {
    693                         // A Cancel tap returns without throwing, so reaching
    694                         // here is a genuine failure to join. The common one is a
    695                         // dead link — the inviter deleted or left the game, so
    696                         // its share is gone, which the metadata fetch reports as
    697                         // `.unknownItem`/`.zoneNotFound`. Surface it on the Game
    698                         // List rather than bouncing the user back in silence.
    699                         guard !Task.isCancelled else { return }
    700                         withAnimation { pendingJoin = nil }
    701                         let code = CloudService.cloudErrorCode(error)
    702                         let gone = (error as? AcceptedShareError)?.kind == .removed
    703                             || code == .unknownItem
    704                             || code == .zoneNotFound
    705                         let versionMismatch = (error as? CloudServiceError) == .containerVersionMismatch
    706                         // Both of these are the user's world being different to
    707                         // what the link assumed, not something broken: warn and
    708                         // explain rather than reporting a failure.
    709                         let expected = gone || versionMismatch
    710                         let copy: (title: String, body: String)
    711                         if versionMismatch {
    712                             copy = ("Update Needed", error.localizedDescription)
    713                         } else if gone {
    714                             copy = ("Puzzle Removed", "This puzzle was removed.")
    715                         } else {
    716                             copy = CloudFailureCopy.joinFailure(for: error)
    717                         }
    718                         services.eventLog.note(
    719                             "share link join failed: \(error.localizedDescription)",
    720                             level: expected ? "info" : "error"
    721                         )
    722                         services.announcements.post(Announcement(
    723                             id: "share-link-join-failed",
    724                             scope: .global,
    725                             severity: expected ? .warning : .error,
    726                             title: copy.title,
    727                             body: copy.body,
    728                             dismissal: .manual
    729                         ))
    730                     }
    731                 }
    732             }
    733             await services.start(appDelegate: appDelegate)
    734         }
    735         .onOpenURL { url in
    736             if let id = services.importService.importGame(from: url) {
    737                 navigationPath.append(id)
    738             }
    739         }
    740         .onReceive(NotificationCenter.default.publisher(for: .cloudShareAcceptanceStarted)) { _ in
    741             UIApplication.shared.dismissPresentedViewControllers()
    742         }
    743         .onReceive(NotificationCenter.default.publisher(for: .cloudShareAcceptanceCompleted)) { notification in
    744             withAnimation { pendingJoin = nil }
    745             guard let gameID = notification.userInfo?["gameID"] as? UUID else { return }
    746             // A join driven from inside the puzzle view (an `.invite`
    747             // notification tap) is already showing this game — appending
    748             // again would stack a duplicate screen. Navigate only when the
    749             // accept came from elsewhere, e.g. the Invited list.
    750             guard NotificationState.activePuzzleID() != gameID else { return }
    751             navigationPath.append(gameID)
    752         }
    753         .onChange(of: scenePhase) { _, newPhase in
    754             switch newPhase {
    755             case .active:
    756                 services.noteAppForeground(true)
    757                 Task { await services.syncOnForeground() }
    758             case .background, .inactive:
    759                 services.noteAppForeground(false)
    760                 NotificationState.setActivePuzzleID(nil)
    761                 // Synchronous: takes a background-execution assertion before the
    762                 // flush Task suspends, so buffered edits persist + enqueue even
    763                 // if the scene is suspended immediately.
    764                 services.syncOnBackground()
    765             @unknown default:
    766                 break
    767             }
    768         }
    769     }
    770 
    771     private func acceptInviteFromGameList(
    772         shareURL: String,
    773         pingRecordName: String,
    774         shape: GridSilhouette.Grid?
    775     ) async throws {
    776         joinTask?.cancel()
    777         withAnimation { pendingJoin = PendingJoinPlaceholder(shape: shape) }
    778         let task = Task<Void, Error> {
    779             let outcome = try await services.invites.acceptInvite(
    780                 shareURL: shareURL,
    781                 pingRecordName: pingRecordName
    782             )
    783             guard !Task.isCancelled else { return }
    784             if case .pendingSync = outcome {
    785                 services.announcements.post(.puzzleStillSyncing())
    786             }
    787         }
    788         joinTask = task
    789         do {
    790             try await task.value
    791         } catch {
    792             if task.isCancelled { return }
    793             withAnimation { pendingJoin = nil }
    794             throw error
    795         }
    796     }
    797 }
    798 
    799 private extension UIApplication {
    800     func dismissPresentedViewControllers() {
    801         for scene in connectedScenes {
    802             guard let windowScene = scene as? UIWindowScene else { continue }
    803             for window in windowScene.windows where window.isKeyWindow {
    804                 window.rootViewController?.dismiss(animated: true)
    805             }
    806         }
    807     }
    808 }
    809 
    810 // MARK: - Game Destination
    811 
    812 /// Loads a game when navigated to.
    813 private struct PuzzleDisplayView: View {
    814     private var syncedID: UUID? {
    815         guard preferences.isICloudSyncEnabled,
    816               let mutator = session?.mutator,
    817               !mutator.isArchived
    818         else { return nil }
    819         return gameID
    820     }
    821 
    822     private var syncedScope: CKDatabase.Scope? {
    823         guard preferences.isICloudSyncEnabled,
    824               let mutator = session?.mutator,
    825               !mutator.isArchived
    826         else { return nil }
    827         return mutator.isOwned ? .private : .shared
    828     }
    829 
    830     let gameID: UUID
    831     let store: GameStore
    832     let shareController: ShareController
    833     let services: AppServices
    834 
    835     @Environment(PlayerPreferences.self) private var preferences
    836     @Environment(\.scenePhase) private var scenePhase
    837     @State private var session: PlayerSession?
    838     @State private var roster: PlayerRoster?
    839     @State private var loadError: String?
    840     @State private var loadingMessage = "Loading puzzle…"
    841     @State private var openPuzzleFollowUpTask: Task<Void, Never>?
    842 
    843     var body: some View {
    844         Group {
    845             if let session, let roster {
    846                 PuzzleView(
    847                     session: session,
    848                     shareController: shareController,
    849                     roster: roster,
    850                     onComplete: { notifyPeers in
    851                         guard !session.mutator.isArchived else { return }
    852                         do {
    853                             let changed = try notifyPeers
    854                                 ? store.markCompleted(id: gameID)
    855                                 : store.markCompletedFromObservedSolvedState(id: gameID)
    856                             if changed {
    857                                 // Seal the solve clock at the finish so peers and
    858                                 // sibling devices get the final time at once, not
    859                                 // only when this device next leaves the puzzle.
    860                                 services.sessions.noteClockCompleted(gameID: gameID)
    861                             }
    862                             // The game is done — drop the other player's cursor
    863                             // and tear the live room down. Idempotent, so the
    864                             // repeated observed/on-appear completions are safe.
    865                             Task { await services.engagement.endEngagement(gameID: gameID) }
    866                         } catch {
    867                             services.announcements.post(Announcement(
    868                                 id: "mark-completed-error-\(gameID.uuidString)",
    869                                 scope: .game(gameID),
    870                                 severity: .error,
    871                                 title: "Saving Failed",
    872                                 body: error.localizedDescription,
    873                                 dismissal: .manual
    874                             ))
    875                         }
    876                     },
    877                     onResign: {
    878                         try store.resignGame(id: gameID)
    879                         services.sessions.noteClockCompleted(gameID: gameID)
    880                     },
    881                     onDelete: { try store.deleteGame(id: gameID) },
    882                     onNudge: { await services.sessions.nudge(gameID: gameID) },
    883                     nudgeReadyAt: { services.sessions.nudgeReadyAt(gameID: gameID) },
    884                     loadReplay: {
    885                         let short = gameID.uuidString.prefix(8)
    886                         // Finished-game timelines are immutable (edit-lockout),
    887                         // so a cached assembly is reused verbatim on re-entry —
    888                         // this is what stops rapid nav from re-running the merge
    889                         // each time a fresh `ReplayControls` instance asks for it.
    890                         return await ReplayAssembler.memoised(
    891                             cached: services.replays.cachedReplayTimeline(gameID: gameID),
    892                             onHit: { cached in
    893                                 services.syncMonitor.note(
    894                                     "replay[\(short)]: served from timeline memo " +
    895                                     "(steps=\(cached.count))"
    896                                 )
    897                             },
    898                             store: { services.replays.cacheReplayTimeline($0, gameID: gameID) }
    899                         ) {
    900                             // Local-first only for unshared games: this device's
    901                             // journal is the whole history, so replay needs no
    902                             // CloudKit. Shared games always use the merged
    903                             // loader, even if their Moves rows have not caught
    904                             // up locally yet, so replay can wait for every
    905                             // contributing device's journal instead of caching
    906                             // an incomplete local timeline.
    907                             let entries = store.localJournalEntries(for: gameID)
    908                             if !store.isGameShared(gameID: gameID),
    909                                !store.isGameArchived(gameID: gameID) {
    910                                 services.syncMonitor.note(
    911                                     "replay[\(short)]: local-only path " +
    912                                     "(unshared game), localEntries=\(entries.count)"
    913                                 )
    914                                 return .ready(ReplayTimeline(merging: [entries]))
    915                             }
    916                             services.syncMonitor.note(
    917                                 "replay[\(short)]: shared merged path, " +
    918                                 "localEntries=\(entries.count)"
    919                             )
    920                             return await services.replays.loadReplay(gameID: gameID)
    921                         }
    922                     },
    923                     loadRecentChanges: {
    924                         // Cells a peer changed since this device last viewed the
    925                         // game. A missing timestamp means a first-ever open —
    926                         // establish the baseline silently rather than flag the
    927                         // whole board (the leave/background path below stamps it).
    928                         guard let since = services.gameViewedStore.lastViewed(forGame: gameID)
    929                         else { return [:] }
    930                         return store.recentlyChangedCells(forGame: gameID, since: since)
    931                     },
    932                     markPuzzleViewed: { stampPuzzleViewed() }
    933                 )
    934             } else if let loadError {
    935                 ContentUnavailableView(
    936                     "Couldn't load puzzle",
    937                     systemImage: "exclamationmark.triangle",
    938                     description: Text(loadError)
    939                 )
    940             } else {
    941                 ProgressView(loadingMessage)
    942                     .frame(maxWidth: .infinity, maxHeight: .infinity)
    943             }
    944         }
    945         .navigationTitle("")
    946         .navigationBarTitleDisplayMode(.inline)
    947         .task(id: syncedID) {
    948             guard let scope = syncedScope else { return }
    949             await services.freshenPuzzleGrid(gameID: gameID, scope: scope, reason: .appeared)
    950         }
    951         .task(id: gameID) {
    952             openPuzzleFollowUpTask?.cancel()
    953             openPuzzleFollowUpTask = nil
    954             session = nil
    955             roster = nil
    956             loadError = nil
    957             loadingMessage = "Loading puzzle…"
    958             let canonicalGameID = store.canonicalGameID(for: gameID)
    959             Task {
    960                 await services.badge.dismissDeliveredNotifications(
    961                     for: canonicalGameID
    962                 )
    963             }
    964 
    965             do {
    966                 if let plan = NYTPuzzleUpgrader.plan(for: gameID, store: store) {
    967                     loadingMessage = "Updating puzzle…"
    968                     let fetcher = services.nytFetcher
    969                     let outcome = await NYTPuzzleUpgrader.apply(plan: plan, store: store) { date in
    970                         try await fetcher.fetchPuzzle(for: date)
    971                     }
    972                     switch outcome {
    973                     case .upgraded:
    974                         services.eventLog.note("[upgrade NYT \(gameID.uuidString.prefix(8))] applied")
    975                     case .mismatched(let reason):
    976                         services.eventLog.note("[upgrade NYT \(gameID.uuidString.prefix(8))] structural mismatch — \(reason)", level: "warn")
    977                     case .failed(let error):
    978                         services.eventLog.note("[upgrade NYT \(gameID.uuidString.prefix(8))] fetch failed: \(error)", level: "error")
    979                     }
    980                 }
    981                 let (game, mutator) = try store.loadGame(id: gameID)
    982                 let newSession = PlayerSession(
    983                     game: game,
    984                     mutator: mutator,
    985                     cursorStore: services.cursorStore,
    986                     preferences: preferences
    987                 )
    988                 let newRoster = services.makePlayerRoster(for: gameID, preferences: preferences)
    989                 await newRoster.preload()
    990                 guard !Task.isCancelled else { return }
    991                 roster = newRoster
    992                 session = newSession
    993                 noteSessionPhase(scenePhase)
    994                 openPuzzleFollowUpTask = Task { @MainActor in
    995                     await finishOpeningPuzzle(
    996                         session: newSession,
    997                         roster: newRoster,
    998                         isShared: mutator.isShared
    999                     )
   1000                 }
   1001             } catch {
   1002                 loadError = String(describing: error)
   1003             }
   1004         }
   1005         .task(id: session?.mutator.isShared == true) {
   1006             // Solve-clock liveness heartbeat. Only for a shared game on screen:
   1007             // a co-solver extrapolates this device's open session toward now only
   1008             // as far as its last beat, so a continuous sitting must keep beating
   1009             // or it would be briefly capped on their clock. Solo games skip it —
   1010             // the local clock already extrapolates to now and no peer is watching.
   1011             // Cancelled on leave or gameID change.
   1012             guard session?.mutator.isShared == true else { return }
   1013             while !Task.isCancelled {
   1014                 try? await Task.sleep(for: .seconds(SessionCoordinator.clockHeartbeatInterval))
   1015                 guard !Task.isCancelled else { break }
   1016                 services.sessions.noteClockHeartbeat(gameID: gameID)
   1017             }
   1018         }
   1019         .onChange(of: session?.mutator.isShared) { oldValue, newValue in
   1020             // Fire only on a definite `false → true` transition — that's the
   1021             // mid-session share-create case. Initial loads of an already-shared
   1022             // game go `nil → true` and are handled inline in `task(id: gameID)`.
   1023             guard oldValue == false, newValue == true,
   1024                   let session,
   1025                   preferences.isICloudSyncEnabled
   1026             else { return }
   1027             Task { await activateSharing(for: session) }
   1028         }
   1029         .onChange(of: scenePhase) { _, newPhase in
   1030             guard session?.mutator.isArchived == false else { return }
   1031             noteSessionPhase(newPhase)
   1032             // Only act on settled transitions. `.inactive` is transient (lock
   1033             // animation, app switcher, Control Center, banners), so a write
   1034             // there would thrash the Player record on every lock/unlock.
   1035             // `.background` publishes the cursor so sibling devices catch up
   1036             // promptly; `.active` republishes on resume in case moves arrived
   1037             // (and were marked seen in lockstep) while we were foregrounded.
   1038             let id = gameID
   1039             switch newPhase {
   1040             case .active:
   1041                 Task {
   1042                     await services.publishReadCursor(for: id, mode: .activeLease)
   1043                     // Backgrounding tears the engagement socket down without
   1044                     // rebuilding it, so a live session that dropped while we
   1045                     // were away never comes back on its own. Re-offer on
   1046                     // resume; this is a no-op when the channel is still live
   1047                     // (the coordinator only acts from an idle state).
   1048                     await services.engagement.startEngagementIfPossible(gameID: id)
   1049                 }
   1050                 // Reveal any peer changes that landed while we were away on the
   1051                 // same resume the catch-up banner re-derives on, not only on a
   1052                 // fresh navigation into the puzzle.
   1053                 Task { await recaptureRecentChanges() }
   1054             case .background:
   1055                 // Stop the engagement reconnect loop so it doesn't keep
   1056                 // re-dialling the live socket on background CKSyncEngine wakes.
   1057                 // (Re-leasing `presenceUntil` in the background is now prevented
   1058                 // centrally by publishReadCursor's foreground gate, not here.)
   1059                 // `.active` re-arms the loop via `startEngagementIfPossible`.
   1060                 services.engagement.cancelEngagementReconnectRetry(gameID: id)
   1061                 Task { await services.publishReadCursor(for: id, mode: .currentTime) }
   1062                 // Backgrounding counts as leaving for the away-change baseline:
   1063                 // anything a peer does after this should flag on the next open.
   1064                 stampPuzzleViewed()
   1065             case .inactive:
   1066                 break
   1067             @unknown default:
   1068                 break
   1069             }
   1070         }
   1071         .onDisappear {
   1072             openPuzzleFollowUpTask?.cancel()
   1073             openPuzzleFollowUpTask = nil
   1074             guard session?.mutator.isArchived == false else { return }
   1075             let selectionPublisher = services.playerSelectionPublisher
   1076             let movesUpdater = services.movesUpdater
   1077             let id = gameID
   1078             // Navigating away is a leave: clear the active-puzzle ID and commit
   1079             // the catch-up baseline (idempotent with the .background path).
   1080             services.sessions.notePuzzleClosed(gameID: id)
   1081             services.engagement.scheduleEngagementEnd(gameID: id)
   1082             // Navigating away is a leave: stamp the away-change baseline so the
   1083             // next open diffs against now.
   1084             stampPuzzleViewed()
   1085             Task {
   1086                 await movesUpdater.flush()
   1087                 // The clear-cursor and close-lease writes both enqueue without
   1088                 // forcing a drain (see `enqueuePlayer`'s `drain` flag), so there
   1089                 // are no sends for a burst to collapse — CKSyncEngine ships both
   1090                 // Player-record changes on its own schedule.
   1091                 await selectionPublisher.clear()
   1092                 await services.publishReadCursor(for: id, mode: .currentTime)
   1093                 // The pause self-gates on content (no letter changes reaches
   1094                 // no one) and supersedes any pending grace-window timer, so the
   1095                 // close-after-background case never fires a second push.
   1096                 await services.sessions.publishSessionEndPush(gameID: id)
   1097             }
   1098         }
   1099     }
   1100 
   1101     private func finishOpeningPuzzle(
   1102         session loadedSession: PlayerSession,
   1103         roster loadedRoster: PlayerRoster,
   1104         isShared: Bool
   1105     ) async {
   1106         await loadedRoster.refresh()
   1107         guard !Task.isCancelled, session === loadedSession else { return }
   1108 
   1109         // Re-derive banners that hang off persisted game state (e.g. the
   1110         // access-revoked banner). They are otherwise posted only on the live
   1111         // sync transition that first produces them, which a puzzle opened in
   1112         // a later process never re-fires.
   1113         let openState = OpenPuzzleState(
   1114             gameID: gameID,
   1115             isAccessRevoked: loadedSession.mutator.isAccessRevoked,
   1116             isSyncSupported: loadedSession.mutator.isSyncSupported
   1117         )
   1118         for announcement in OpenPuzzleBanner.announcements(for: openState) {
   1119             services.announcements.post(announcement)
   1120         }
   1121         if loadedSession.mutator.isArchived {
   1122             services.syncMonitor.note(
   1123                 "PuzzleDisplay[\(gameID.uuidString.prefix(8))]: loaded Chronicle roster " +
   1124                 "(live lifecycle disabled)"
   1125             )
   1126             // The Chronicle is a local projection with a derived storage ID.
   1127             // Publish the read watermark through the retained live game's
   1128             // original identity so sibling devices clear the same Completed
   1129             // tile and app-icon badge.
   1130             await services.publishReadCursor(
   1131                 for: store.canonicalGameID(for: gameID),
   1132                 mode: .currentTime
   1133             )
   1134             return
   1135         }
   1136         if isShared && preferences.isICloudSyncEnabled {
   1137             services.syncMonitor.note(
   1138                 "PuzzleDisplay[\(gameID.uuidString.prefix(8))]: loaded shared roster"
   1139             )
   1140             await services.logPlayerLeaseSnapshot(gameID: gameID)
   1141             await activateSharing(for: loadedSession, refreshRoster: false)
   1142         } else {
   1143             services.syncMonitor.note(
   1144                 "PuzzleDisplay[\(gameID.uuidString.prefix(8))]: loaded local roster"
   1145             )
   1146             await services.publishReadCursor(for: gameID, mode: .activeLease)
   1147             await services.playerSelectionPublisher.clear()
   1148         }
   1149     }
   1150 
   1151     /// Forwards settled scene phases to the session controller, which owns
   1152     /// the begin/end/grace choreography (active-puzzle ID, deferred play and
   1153     /// pause pushes, catch-up banner).
   1154     private func noteSessionPhase(_ phase: ScenePhase) {
   1155         guard session?.mutator.isArchived == false else { return }
   1156         switch phase {
   1157         case .active:
   1158             services.sessions.notePuzzleActive(gameID: gameID)
   1159         case .background:
   1160             services.sessions.notePuzzleBackgrounded(gameID: gameID)
   1161         case .inactive:
   1162             // Transient (lock animation, app switcher, Control Center,
   1163             // banners) — the user is still on the puzzle. Toggling the
   1164             // active-puzzle ID or firing a pause push here would thrash
   1165             // both on every interruption.
   1166             break
   1167         @unknown default:
   1168             break
   1169         }
   1170     }
   1171 
   1172     /// Records that this device has now viewed the game up to the current
   1173     /// moment, the baseline the next open diffs against for "changed while you
   1174     /// were away" borders. Device-local; only shared games are tracked (solo
   1175     /// games have no peers to surface). Called on the player's first
   1176     /// interaction (via `PuzzleView`'s acknowledgement) and on leave/background.
   1177     private func stampPuzzleViewed() {
   1178         guard session?.mutator.isShared == true else { return }
   1179         services.gameViewedStore.advance(Date(), forGame: gameID)
   1180     }
   1181 
   1182     /// Recaptures the "changed while you were away" borders against the current
   1183     /// view baseline. The `.task`-driven capture only runs on a fresh open, so a
   1184     /// background→foreground resume of the same open puzzle would otherwise leave
   1185     /// the borders stale (or absent) even as the catch-up banner re-derives.
   1186     /// Mirrors the open beat's settle so the diff reflects the freshened grid;
   1187     /// idempotent — `recentChanges` is `Equatable`, and the baseline only
   1188     /// advances on leave.
   1189     private func recaptureRecentChanges() async {
   1190         try? await Task.sleep(for: .milliseconds(750))
   1191         guard let session, session.mutator.isShared,
   1192               let since = services.gameViewedStore.lastViewed(forGame: gameID)
   1193         else { return }
   1194         session.recentChanges = store.recentlyChangedCells(forGame: gameID, since: since)
   1195     }
   1196 
   1197     /// Initialises shared-game state (roster, selection publishing, name broadcast) for
   1198     /// the open session. Called when the puzzle first appears as shared, and
   1199     /// again if a previously-solo game becomes shared mid-session.
   1200     private func activateSharing(for session: PlayerSession, refreshRoster: Bool = true) async {
   1201         Task { await AppDelegate.requestNotificationAuthorizationIfNeeded() }
   1202         let activeRoster: PlayerRoster
   1203         if let roster {
   1204             activeRoster = roster
   1205         } else {
   1206             let newRoster = services.makePlayerRoster(for: gameID, preferences: preferences)
   1207             roster = newRoster
   1208             activeRoster = newRoster
   1209         }
   1210         if refreshRoster {
   1211             await activeRoster.refresh()
   1212         }
   1213         guard let authorID = services.identity.currentID else { return }
   1214         let selectionPublisher = services.playerSelectionPublisher
   1215         // Fan out read-cursor lease, display name, and the initial cursor
   1216         // track inside one Player-record send burst so they ship in a single
   1217         // CKSyncEngine drain. Name publish lands before the selection so the
   1218         // partner never sees a "Player" placeholder; the burst close then
   1219         // issues exactly one `sendChanges`. Subsequent selection edits go
   1220         // through `PlayerSelectionPublisher`'s trailing-edge debounce and
   1221         // each fires its own drain — same shape as Moves.
   1222         let syncEngine = services.syncEngine
   1223         let burstScope = await syncEngine.beginPlayerSendBurst(gameID: gameID)
   1224         // Stamp this game's derived push address inside the burst so it ships on
   1225         // the same Player-record write as the read-cursor lease; registration of
   1226         // this device under it happens just after the burst.
   1227         _ = services.accountPush.setDerivedPushAddress(gameID: gameID, authorID: authorID)
   1228         await services.publishReadCursor(for: gameID, mode: .activeLease)
   1229         await services.playerNamePublisher?.publishName(for: gameID)
   1230         await selectionPublisher.begin(
   1231             gameID: gameID,
   1232             authorID: authorID,
   1233             currentName: preferences.name
   1234         )
   1235         if let track = session.currentCursorTrack {
   1236             await selectionPublisher.publishImmediately(track)
   1237             await services.engagement.noteLocalSelection(track, gameID: gameID)
   1238         }
   1239         if let burstScope {
   1240             await syncEngine.endPlayerSendBurst(scope: burstScope)
   1241         }
   1242         // Register this device under the current address set. The open burst
   1243         // already stamped this game's Player row, so avoid a full repair sweep.
   1244         await services.accountPush.refreshPushRegistration()
   1245         await services.engagement.startEngagementIfPossible(gameID: gameID)
   1246         let services = self.services
   1247         let eventGameID = gameID
   1248         session.onSelectionChanged = { selection in
   1249             Task {
   1250                 await selectionPublisher.publish(selection)
   1251                 await services.engagement.noteLocalSelection(selection, gameID: eventGameID)
   1252             }
   1253         }
   1254         // check/reveal no longer ping peers; cell state propagates through
   1255         // Moves (the cell's `CellMark` carries the check/reveal result).
   1256     }
   1257 
   1258 }