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 }