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 }