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