crossmate

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

CrossmateApp.swift (52143B)


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