AppServices.swift (127530B)
1 import CloudKit 2 import CoreData 3 import Foundation 4 import UIKit 5 import UserNotifications 6 7 /// Fills a throwaway in-memory store with a handful of shared, in-progress 8 /// games and friends so the Game List colour strips and the friends list can 9 /// be inspected in the Simulator without an iCloud account or a real opponent. 10 /// Driven solely by the `--crossmate-seed-demo` launch argument; a normal 11 /// launch never reaches this. The same friend authorIDs are reused across 12 /// both games on purpose, so one friend visibly takes a *different* colour in 13 /// each game — the per-game colour derivation made visible. 14 enum DemoSeed { 15 /// Which library to seed. The two differ only in the collaborative 16 /// showcase game's roster: development wants four players (you + three 17 /// friends) so all four attribution tints can be eyeballed at once, while 18 /// marketing must never depict more than three players. 19 enum Profile { 20 case development 21 case marketing 22 } 23 24 /// The local user's authorID in demo mode, injected into `AuthorIdentity` 25 /// so the seeded friends classify as remote players. Kept distinct from 26 /// every friend id below. 27 static let localAuthorID = "_demo-you" 28 29 private static let alice = "_demo-alice" 30 private static let bob = "_demo-bob" 31 private static let carol = "_demo-carol" 32 33 private static let friends: [(id: String, name: String)] = [ 34 (alice, "Alice"), 35 (bob, "Bob"), 36 (carol, "Carol"), 37 ] 38 39 /// One seeded game backed by a real bundled 15×15 starter. `fillAuthors` 40 /// are the authors given filled cells (drives the desaturated attribution 41 /// tints and, together with `participants`, the Game List colour strip); 42 /// empty leaves the grid blank (or fully solved, when `completed`). 43 private struct GameSpec { 44 let title: String 45 let resourceID: String 46 let participants: [String] 47 let fillAuthors: [String] 48 let completed: Bool 49 } 50 51 /// The demo library for `profile`, backed by real bundled 15×15 starters so 52 /// the Game List shows full grids rather than toy puzzles. Each game draws a 53 /// distinct puzzle so the row thumbnails differ; the mix spans shared/solo 54 /// and in-progress/completed to exercise every card state. 55 private static func games(for profile: Profile) -> [GameSpec] { 56 // The only per-profile difference: how many players share (and have 57 // filled cells in) the in-progress showcase game. 58 let showcase: GameSpec = switch profile { 59 case .development: 60 GameSpec( 61 title: "Tuesday Crossword", 62 resourceID: "cm-starter-0001", 63 participants: [alice, bob, carol], 64 fillAuthors: [localAuthorID, alice, bob, carol], 65 completed: false 66 ) 67 case .marketing: 68 GameSpec( 69 title: "Tuesday Crossword", 70 resourceID: "cm-starter-0001", 71 participants: [alice, bob], 72 fillAuthors: [localAuthorID, alice, bob], 73 completed: false 74 ) 75 } 76 return [ 77 showcase, 78 GameSpec( 79 title: "Sunday Special", 80 resourceID: "cm-starter-0002", 81 participants: [alice, bob], 82 fillAuthors: [localAuthorID, alice, bob], 83 completed: false 84 ), 85 GameSpec( 86 title: "Coffee Break", 87 resourceID: "cm-starter-0003", 88 participants: [], 89 fillAuthors: [], 90 completed: true 91 ), 92 GameSpec( 93 title: "Weekend Challenge", 94 resourceID: "cm-starter-0004", 95 participants: [alice, carol], 96 fillAuthors: [], 97 completed: true 98 ), 99 ] 100 } 101 102 @MainActor 103 static func populate( 104 persistence: PersistenceController, 105 preferences: PlayerPreferences, 106 profile: Profile 107 ) { 108 let ctx = persistence.viewContext 109 110 // Keep the Game List out of its "set your profile name" empty state. 111 if !preferences.hasName { 112 preferences.name = "You" 113 } 114 115 for friend in friends { 116 seedFriend(friend, in: ctx) 117 } 118 119 // Reuse the catalog the new-game picker uses; it resolves each bundled 120 // `.xd` for us and reads its source on demand. 121 for spec in games(for: profile) { 122 guard let entry = PuzzleCatalog.source(matchingResourceID: spec.resourceID, title: nil), 123 let source = try? entry.loadSource(), 124 let xd = try? XD.parse(source) else { continue } 125 let puzzle = Puzzle(xd: xd) 126 let game = seedGame( 127 title: spec.title, 128 resourceID: spec.resourceID, 129 participants: spec.participants, 130 puzzle: puzzle, 131 source: source, 132 completed: spec.completed, 133 in: ctx 134 ) 135 if !spec.fillAuthors.isEmpty { 136 seedFilledLetters(in: game, puzzle: puzzle, authors: spec.fillAuthors, in: ctx) 137 } 138 } 139 140 try? ctx.save() 141 } 142 143 private static func seedFriend( 144 _ seed: (id: String, name: String), 145 in ctx: NSManagedObjectContext 146 ) { 147 let friend = FriendEntity(context: ctx) 148 friend.authorID = seed.id 149 friend.createdAt = Date() 150 friend.displayName = seed.name 151 friend.displayNameVersion = 0 152 friend.isBlocked = false 153 friend.nickname = "" 154 friend.nicknameVersion = 0 155 friend.pairKey = "demo-pair-\(seed.id)" 156 } 157 158 @discardableResult 159 private static func seedGame( 160 title: String, 161 resourceID: String, 162 participants: [String], 163 puzzle: Puzzle, 164 source: String, 165 completed: Bool, 166 in ctx: NSManagedObjectContext 167 ) -> GameEntity { 168 let now = Date() 169 let game = GameEntity(context: ctx) 170 game.id = UUID() 171 game.title = title 172 game.puzzleSource = source 173 game.puzzleParserVersion = Int64(XD.currentParserVersion) 174 game.puzzleResourceID = resourceID 175 game.createdAt = now 176 game.updatedAt = now 177 game.syncVersion = GameSyncVersion.current 178 // A non-nil share record name is what marks the game as shared, which is 179 // the gate for the Game List participant colour strip; a solo game (no 180 // participants) leaves it nil. 181 if !participants.isEmpty { 182 game.ckShareRecordName = "demo-share-\(title)" 183 } 184 // A completed game is terminal: the Game List files it under "Completed" 185 // and renders it as a fully-solved board. 186 if completed { 187 game.completedAt = now 188 game.completedBy = participants.first ?? localAuthorID 189 } 190 game.populateCachedSummaryFields(from: puzzle) 191 192 for authorID in participants { 193 let player = PlayerEntity(context: ctx) 194 player.game = game 195 player.authorID = authorID 196 player.name = friends.first { $0.id == authorID }?.name 197 player.ckRecordName = "demo-player-\(title)-\(authorID)" 198 player.updatedAt = now 199 } 200 return game 201 } 202 203 /// Fills a realistic share of `game`'s grid with correct letters, handing 204 /// each cell to one of `authors` in small diagonal patches and leaving 205 /// scattered gaps so the puzzle reads as in-progress. One `MovesEntity` per 206 /// author carries that author's cells, exactly as a real co-solve would, so 207 /// `GridStateMerger` rebuilds the attributed grid — and each filled cell 208 /// renders that player's faint attribution tint. The author set also caps 209 /// how many colours the Game List strip shows for this game. 210 private static func seedFilledLetters( 211 in game: GameEntity, 212 puzzle: Puzzle, 213 authors: [String], 214 in ctx: NSManagedObjectContext 215 ) { 216 guard !authors.isEmpty else { return } 217 let now = Date() 218 var cellsByAuthor: [String: [GridPosition: TimestampedCell]] = [:] 219 220 for r in 0..<puzzle.height { 221 for c in 0..<puzzle.width { 222 let cell = puzzle.cells[r][c] 223 guard !cell.isBlock, 224 let solution = cell.solution, 225 !solution.isEmpty, 226 !solution.allSatisfy(\.isWhitespace) 227 else { continue } 228 // Leave roughly one cell in seven blank for an in-progress look. 229 if (r * 5 + c) % 7 == 0 { continue } 230 let author = authors[((r / 2) + (c / 2)) % authors.count] 231 cellsByAuthor[author, default: [:]][GridPosition(row: r, col: c)] = 232 TimestampedCell( 233 letter: solution.uppercased(), 234 mark: .none, 235 updatedAt: now, 236 authorID: author 237 ) 238 } 239 } 240 241 for (author, cells) in cellsByAuthor { 242 let entity = MovesEntity(context: ctx) 243 entity.game = game 244 entity.authorID = author 245 entity.deviceID = "demo-device-\(author)" 246 entity.ckRecordName = "demo-moves-\(game.id?.uuidString ?? "")-\(author)" 247 entity.cells = (try? MovesCodec.encode(cells)) ?? Data() 248 entity.updatedAt = now 249 } 250 } 251 } 252 253 @MainActor 254 final class AppServices { 255 /// The process's one services instance, installed by `CrossmateApp.init` 256 /// and owned by the App's `@State`. Exists for the app delegate's push 257 /// path: on a background-only launch no scene ever activates, so the root 258 /// view's startup task never calls `start` — the delegate drives startup 259 /// through this reference instead. Weak because the App owns the 260 /// instance's lifetime; this is a lookup, not a retain. 261 static weak var current: AppServices? 262 263 enum ReadCursorPublishMode { 264 case activeLease 265 case currentTime 266 } 267 268 private static let readLeaseDuration: TimeInterval = 10 * 60 269 private static let readLeaseRefreshFloor: TimeInterval = 5 * 60 270 271 enum FreshenReason { 272 case appeared 273 case foreground 274 case manual 275 case remote 276 277 var diagnosticLabel: String { 278 switch self { 279 case .appeared: return "appeared" 280 case .foreground: return "foreground" 281 case .manual: return "manual" 282 case .remote: return "remote" 283 } 284 } 285 } 286 287 let persistence: PersistenceController 288 let store: GameStore 289 let syncEngine: SyncEngine 290 let eventLog: EventLog 291 let syncMonitor: SyncMonitor 292 let nytAuth: NYTAuthService 293 let driveMonitor: DriveMonitor 294 let nytFetcher: NYTPuzzleFetcher 295 let inputMonitor: InputMonitor 296 let movesUpdater: MovesUpdater 297 let sessionMonitor: SessionMonitor 298 let announcements: AnnouncementCenter 299 let playerSelectionPublisher: PlayerSelectionPublisher 300 let identity: AuthorIdentity 301 let pushClient: PushClient? 302 /// Per-game play-session lifecycle: begin/end grace timers, sender-side 303 /// session pushes, and the catch-up banner. See `SessionCoordinator`. 304 let sessions: SessionCoordinator 305 /// Account-scoped push credentials (secret/address mint, rotation, 306 /// inbound adoption) + push-worker registration; see 307 /// `AccountPushCoordinator`. 308 let accountPush: AccountPushCoordinator 309 /// Finished-game replay loading and the per-session timeline cache; see 310 /// `ReplayLoader`. 311 let replays: ReplayLoader 312 let shareController: ShareController 313 let friendController: FriendController 314 let gameArchiver: GameArchiver 315 let cursorStore: GameCursorStore 316 /// Device-local most-recently-used ordering for direct friend invites. 317 let friendInviteRecency: FriendInviteRecencyStore 318 /// Device-local record of when each game was last viewed; drives the 319 /// "changed while you were away" cell borders. Never synced. 320 let gameViewedStore: GameViewedStore 321 /// Device-local onboarding-tip state: which tips have been dismissed and 322 /// whether tips are turned off. Drives the Game List tip banner and the 323 /// Settings tips archive. Never synced. 324 let tips: TipStore 325 let engagementStore: EngagementStore 326 let cloudService: CloudService 327 let importService: ImportService 328 let engagementHost: EngagementHost 329 let engagementStatus = EngagementStatus() 330 let inviteDeliveries = InviteDeliveryStore() 331 private(set) lazy var appActions = AppActions(services: self) 332 /// Live-channel lifecycle (room reconcile/mint, teardown/reconnect/ 333 /// lease-expiry timers, inbound channel events); see `EngagementLifecycle`. 334 /// Lazy so its callbacks into the read-cursor and sync-start paths can 335 /// capture `self`. 336 private(set) lazy var engagement = EngagementLifecycle( 337 preferences: preferences, 338 persistence: persistence, 339 store: store, 340 identity: identity, 341 syncMonitor: syncMonitor, 342 engagementHost: engagementHost, 343 engagementStatus: engagementStatus, 344 engagementStore: engagementStore, 345 isAppForeground: { [weak self] in self?.isAppForeground ?? false }, 346 renewReadLease: { [weak self] gameID in 347 await self?.publishReadCursor(for: gameID, mode: .activeLease) 348 }, 349 ensureICloudSyncStarted: { [weak self] in 350 await self?.ensureICloudSyncStarted() ?? false 351 } 352 ) 353 /// App-icon badge + delivered-notification reconciliation; see 354 /// `BadgeCoordinator`. Lazy so the account-seen fan-out can capture `self`. 355 private(set) lazy var badge = BadgeCoordinator( 356 store: store, 357 syncMonitor: syncMonitor, 358 readLeaseDuration: Self.readLeaseDuration, 359 publishAccountSeenPush: { [weak self] gameID, presenceUntil in 360 await self?.accountPush.publishAccountSeenPush(gameID: gameID, presenceUntil: presenceUntil) 361 } 362 ) 363 /// Friend-zone traffic — outbound invites, inbound ping handling, durable 364 /// invite rows, friendship bootstrap, blocking; see `InviteCoordinator`. 365 /// Lazy so the badge refresh can capture `self`. 366 private(set) lazy var invites = InviteCoordinator( 367 persistence: persistence, 368 identity: identity, 369 preferences: preferences, 370 syncMonitor: syncMonitor, 371 eventLog: eventLog, 372 store: store, 373 syncEngine: syncEngine, 374 announcements: announcements, 375 shareController: shareController, 376 friendController: friendController, 377 cloudService: cloudService, 378 refreshAppBadge: { [weak self] reason in 379 await self?.badge.refreshAppBadge(reason: reason) 380 }, 381 publishInvitePush: { [weak self] friendAuthorID, gameID, title, inviterName in 382 await self?.accountPush.publishInvitePush( 383 to: friendAuthorID, 384 gameID: gameID, 385 puzzleTitle: title, 386 inviterName: inviterName 387 ) 388 } 389 ) 390 391 let preferences: PlayerPreferences 392 393 private let ckContainer = CloudContainer.container 394 /// The process-wide startup operation. Retaining the task makes `start` 395 /// both one-shot and awaitable: a caller that arrives while startup is in 396 /// progress waits for the same readiness boundary instead of treating 397 /// "startup entered" as "startup complete." 398 private var startupTask: Task<Void, Never>? 399 private var syncStarted = false 400 /// In-flight `ensureICloudSyncStarted()` work, shared by concurrent 401 /// callers so the cold-launch race between `services.start()` and a 402 /// near-simultaneous `syncOnForeground()` doesn't admit two parallel 403 /// `SyncEngine.start()` runs. 404 private var syncStartTask: Task<Bool, Never>? 405 private(set) var playerNamePublisher: PlayerNamePublisher? 406 private var isReadyForShareAcceptance = false 407 private var isProcessingShareAcceptanceQueue = false 408 /// True while `processPendingShareAcceptances` is draining. A share accept 409 /// holds the shared database to download the puzzle asset on the joining 410 /// screen; shared-scope pushes that land in this window defer their heavy 411 /// fan-out so collaborator activity doesn't contend with the join. 412 private var isAcceptingSharedGame = false 413 private var pendingShareMetadatas: [CKShare.Metadata] = [] 414 /// Wall-clock timestamp of the most recent inbound silent push. Bypasses 415 /// the game-list freshen cooldown when a push has arrived since the last 416 /// freshen, so a collaborator burst isn't held off by debounce. 417 private var lastRemoteNotificationAt: Date? 418 private var privatePushCatchUpTask: Task<Void, Never>? 419 private var sharedPushCatchUpTask: Task<Void, Never>? 420 private var privateSessionScanTask: Task<Void, Never>? 421 private var sharedSessionScanTask: Task<Void, Never>? 422 private var isHandlingPrivateRemoteNotification = false 423 private var isHandlingSharedRemoteNotification = false 424 private var gameListFreshenTask: Task<Void, Never>? 425 /// Serialises Chronicle/Game metadata paging. Main-actor methods are 426 /// reentrant across CloudKit awaits, so notification and pull-to-refresh 427 /// requests must not reset the shared cursor during a Load More operation. 428 private var completedPageTask: Task<GameArchiver.CompletedPage, Never>? 429 private var completedPageTaskID: UUID? 430 private var hasStartedRemoteCompletedMigration = false 431 private var isFresheningPrivateGameList = false 432 private var isFresheningSharedGameList = false 433 /// The archive backstop can scan many completed shared games. Run it once 434 /// after the first cold-launch game-list freshen, not on every foreground, 435 /// manual refresh, or remote-triggered refresh. 436 private var shouldRunColdLaunchArchiveReconcile = true 437 /// Wall-clock timestamp of the last successful game-list freshen per 438 /// scope, used to suppress redundant polls when no inbound push has 439 /// arrived since. Pushes own freshness; the freshen (zone discovery + 440 /// game/moves catch-up) is only a backstop for the case where Apple 441 /// drops a silent push or a share-accept notification. 442 private var lastPrivateGameListFreshenAt: Date? 443 private var lastSharedGameListFreshenAt: Date? 444 /// Maximum staleness budget for the game list before an unprompted view 445 /// event re-runs the freshen. Bypassed by `.manual` and by any push that 446 /// arrived after the last successful freshen. 447 private let gameListFreshenCooldown: TimeInterval = 300 448 private var fresheningPuzzleGridKeys: Set<String> = [] 449 private var lastRemotePuzzleGridFreshenAt: [String: Date] = [:] 450 /// Collapses bursts of remote-push grid refreshes, but only while the 451 /// engagement websocket is live for the game (see 452 /// `shouldSkipRecentRemotePuzzleGridFreshen`). When the live channel is 453 /// down, the push path is the sole convergence mechanism and is not 454 /// debounced. 455 private let remotePuzzleGridFreshenDebounce: TimeInterval = 5 456 private var isGameListVisible = false 457 /// Whether the app is foreground-active — the single source of truth for 458 /// "the user is actively using the app." `publishReadCursor(.activeLease)` 459 /// consults it so a background CKSyncEngine wake can never re-arm our 460 /// presence lease. Fed from `RootView`'s scene-phase observer; defaults to 461 /// `true` because the app launches into the foreground and `.onChange` does 462 /// not fire for the initial phase. 463 private(set) var isAppForeground = true 464 465 /// Whether an account-change event warrants purging this device's local 466 /// store. True only when both the previously-known and the freshly-resolved 467 /// author IDs are known and differ — i.e. a real switch to a different 468 /// iCloud account. A first sign-in has no previous ID, and a transient 469 /// sign-out leaves `AuthorIdentity.refresh` a no-op (so the ID is 470 /// unchanged); neither should wipe local data. 471 static func accountSwitchRequiresPurge(previousID: String?, newID: String?) -> Bool { 472 guard let previousID, let newID else { return false } 473 return previousID != newID 474 } 475 476 init() { 477 let eventLog = EventLog() 478 self.eventLog = eventLog 479 // `--crossmate-seed-demo` (set in the Run scheme's arguments) brings the 480 // app up against a throwaway in-memory store pre-filled with a couple of 481 // shared games and a few friends, purely so the Game List colour 482 // strips and the friends list can be eyeballed in the Simulator without 483 // iCloud. It never touches the real on-disk store. `--crossmate-seed- 484 // marketing` seeds the same way but caps every game at three players, 485 // for the import marketing screenshot's backdrop. 486 let arguments = ProcessInfo.processInfo.arguments 487 let seedProfile: DemoSeed.Profile? = 488 if arguments.contains("--crossmate-seed-marketing") { 489 .marketing 490 } else if arguments.contains("--crossmate-seed-demo") { 491 .development 492 } else { 493 nil 494 } 495 let isDemoSeed = seedProfile != nil 496 // The demo seed writes a scratch profile name; give it a throwaway 497 // preferences store so that write can't sync into a real launch via the 498 // shared iCloud key-value store. 499 let preferences = isDemoSeed ? PlayerPreferences.ephemeral() : PlayerPreferences() 500 self.preferences = preferences 501 let persistence = PersistenceController(inMemory: isDemoSeed, eventLog: eventLog) 502 self.persistence = persistence 503 if let seedProfile { 504 DemoSeed.populate(persistence: persistence, preferences: preferences, profile: seedProfile) 505 // Preview the one-time v4 reset notice on demand 506 // (`run-demo.sh --v4-notice`); set explicitly so a normal demo run 507 // clears any leftover flag on the reused demo simulator. 508 UserDefaults.standard.set( 509 ProcessInfo.processInfo.arguments.contains("--crossmate-show-v4-notice"), 510 forKey: Self.showV4NoticeDefaultsKey 511 ) 512 } 513 let syncEngine = SyncEngine(container: self.ckContainer, persistence: persistence) 514 self.syncEngine = syncEngine 515 self.syncMonitor = SyncMonitor(log: eventLog) 516 self.driveMonitor = DriveMonitor() 517 self.nytAuth = NYTAuthService(log: { message in 518 eventLog.note(message) 519 }) 520 self.nytFetcher = NYTPuzzleFetcher { NYTAuthService.currentCookieResult() } 521 self.inputMonitor = InputMonitor() 522 // In demo mode, inject a fixed local authorID so the seeded peers 523 // classify as remote — otherwise, with no iCloud user, the roster comes 524 // up empty and the puzzle scoreboard (and its nudge button) never 525 // populate. A real launch always resolves the ID from CloudKit. 526 let identity = isDemoSeed ? AuthorIdentity(testing: DemoSeed.localAuthorID) : AuthorIdentity() 527 self.identity = identity 528 let pushSyncMonitor = self.syncMonitor 529 self.pushClient = PushClient(log: { message in 530 Task { @MainActor in pushSyncMonitor.note(message) } 531 }) 532 self.pushClient?.updateAuthorID(identity.currentID) 533 534 let movesUpdater = MovesUpdater( 535 debounceInterval: .milliseconds(500), 536 persistence: persistence, 537 writerAuthorIDProvider: { await MainActor.run { identity.currentID } }, 538 sink: { [persistence] gameIDs, drain in 539 // MovesUpdater bumps game.updatedAt on a background context. 540 // viewContext.automaticallyMergesChangesFromParent applies that 541 // change in-memory but doesn't reliably fire the ObjectsDidChange 542 // notification that @FetchRequest's NSFetchedResultsController 543 // listens for, so the library list keeps showing the stale 544 // "last updated" time until something else nudges the context. 545 // The inbound path is masked by noteIncomingMovesUpdate's 546 // explicit viewContext save; the outbound path has no analog. 547 // Refreshing the affected entities re-emits ObjectsDidChange 548 // with refreshedObjects, which NSFRC treats as a per-entity 549 // update — that path runs unconditionally so local-only games 550 // get the same nudge even when iCloud sync is off. 551 await MainActor.run { 552 let viewContext = persistence.viewContext 553 for gameID in gameIDs { 554 let req = NSFetchRequest<GameEntity>(entityName: "GameEntity") 555 req.predicate = NSPredicate(format: "id == %@", gameID as CVarArg) 556 req.fetchLimit = 1 557 guard let entity = try? viewContext.fetch(req).first else { continue } 558 viewContext.refresh(entity, mergeChanges: true) 559 } 560 } 561 let isEnabled = await MainActor.run { preferences.isICloudSyncEnabled } 562 guard isEnabled else { return } 563 await syncEngine.enqueueMoves(gameIDs: gameIDs, drain: drain) 564 } 565 ) 566 self.movesUpdater = movesUpdater 567 568 self.announcements = AnnouncementCenter() 569 570 let cursorStore = GameCursorStore() 571 self.cursorStore = cursorStore 572 self.friendInviteRecency = FriendInviteRecencyStore() 573 let gameViewedStore = GameViewedStore() 574 self.gameViewedStore = gameViewedStore 575 self.tips = TipStore() 576 let engagementStore = EngagementStore() 577 self.engagementStore = engagementStore 578 let onGameDeletedHandler = Self.makeOnGameDeleted( 579 syncEngine: syncEngine, 580 cursorStore: cursorStore, 581 viewedStore: gameViewedStore 582 ) 583 584 let store = GameStore( 585 persistence: persistence, 586 movesUpdater: movesUpdater, 587 authorIDProvider: { identity.currentID }, 588 onGameCreated: { [preferences, syncEngine] ckRecordName in 589 Task { 590 guard await MainActor.run(body: { preferences.isICloudSyncEnabled }) else { return } 591 await syncEngine.enqueueGame(ckRecordName: ckRecordName) 592 } 593 }, 594 onGameUpdated: { [preferences, syncEngine] ckRecordName in 595 Task { 596 guard await MainActor.run(body: { preferences.isICloudSyncEnabled }) else { return } 597 await syncEngine.enqueueGame(ckRecordName: ckRecordName) 598 } 599 }, 600 onGameDeleted: { [preferences] deletion in 601 // Drop the badge ledger entry regardless of sync state — a 602 // deleted game has nothing left to open, so a stale unread 603 // horizon would count forever. `deleteGame` fires 604 // `onUnreadOtherMovesChanged` right after, which refreshes the 605 // app badge. 606 BadgeState.forget(gameID: deletion.gameID) 607 guard preferences.isICloudSyncEnabled else { return } 608 onGameDeletedHandler(deletion) 609 }, 610 eventLog: eventLog 611 ) 612 self.store = store 613 // When an iCloud id first resolves after offline play, realign every 614 // row authored under the device-local fallback to the real id — before 615 // `ensureICloudSyncStarted` lets the sync engine push them. 616 identity.onIdentityResolved = { [weak store] fallbackID, realID in 617 store?.remapAuthorID(from: fallbackID, to: realID) 618 } 619 // Publishes resolve (and mint) the game's shared push credential from 620 // the store so the worker can verify participation. 621 self.pushClient?.gameCredentialResolver = { [weak store] gameID in 622 store?.ensurePushCredentials(for: gameID) 623 } 624 // Publishes encrypt the structured payload under the game's content key, 625 // which rides in the same notification credential the push secret does 626 // (minted/backfilled on first use) so the worker only ever forwards 627 // ciphertext for the personal fields. 628 self.pushClient?.contentKeyResolver = { [weak store] gameID in 629 guard let keyString = store?.ensurePushCredentials(for: gameID)?.contentKey 630 else { return nil } 631 return PushPayloadCipher.key(fromBase64: keyString) 632 } 633 634 let sessionMonitor = SessionMonitor( 635 store: store, 636 localAuthorIDProvider: { identity.currentID } 637 ) 638 self.sessionMonitor = sessionMonitor 639 640 self.sessions = SessionCoordinator( 641 persistence: persistence, 642 store: store, 643 syncEngine: syncEngine, 644 syncMonitor: self.syncMonitor, 645 sessionMonitor: sessionMonitor, 646 gameViewedStore: gameViewedStore, 647 announcements: self.announcements, 648 identity: identity, 649 preferences: preferences, 650 pushClient: self.pushClient 651 ) 652 653 let accountPush = AccountPushCoordinator( 654 identity: identity, 655 preferences: preferences, 656 persistence: persistence, 657 store: store, 658 syncEngine: syncEngine, 659 syncMonitor: self.syncMonitor, 660 pushClient: self.pushClient 661 ) 662 self.accountPush = accountPush 663 store.onPushRegistrationMayNeedRefresh = { [weak accountPush] in 664 guard let accountPush else { return } 665 Task { @MainActor in 666 await accountPush.reconcilePushRegistration() 667 } 668 } 669 670 self.replays = ReplayLoader( 671 store: store, 672 syncEngine: syncEngine, 673 syncMonitor: self.syncMonitor 674 ) 675 676 self.shareController = ShareController( 677 container: self.ckContainer, 678 persistence: persistence, 679 syncEngine: syncEngine, 680 syncMonitor: self.syncMonitor 681 ) 682 self.playerSelectionPublisher = PlayerSelectionPublisher( 683 // While the live room carries the cursor over the websocket, the 684 // durable write is a lagging fallback — throttle it hard. When the 685 // room is down it is the peer's only delivery path, so keep it snappy. 686 debounceInterval: { [engagementStatus] gameID in 687 engagementStatus.isLive(gameID: gameID) ? .milliseconds(2500) : .milliseconds(500) 688 }, 689 persistence: persistence, 690 sink: { gameID, authorID, drain in 691 let isEnabled = await MainActor.run { preferences.isICloudSyncEnabled } 692 guard isEnabled else { return } 693 await syncEngine.enqueuePlayer( 694 gameID: gameID, 695 authorID: authorID, 696 reason: "selection", 697 drain: drain 698 ) 699 }, 700 peerPresent: { [persistence, identity] gameID in 701 let localAuthorID = await MainActor.run { identity.currentID } 702 return await Self.hasPresentPeer( 703 persistence: persistence, 704 gameID: gameID, 705 localAuthorID: localAuthorID 706 ) 707 } 708 ) 709 self.friendController = FriendController( 710 container: self.ckContainer, 711 persistence: persistence, 712 syncEngine: syncEngine, 713 syncMonitor: self.syncMonitor, 714 eventLog: eventLog, 715 publishFriendInvitationKey: { [accountPush] pairKey, zoneID, scope in 716 await accountPush.ensureFriendInvitationKeyPublished( 717 pairKey: pairKey, 718 friendZoneID: zoneID, 719 friendZoneScope: scope 720 ) 721 } 722 ) 723 self.gameArchiver = GameArchiver( 724 container: self.ckContainer, 725 persistence: persistence, 726 syncEngine: syncEngine, 727 syncMonitor: self.syncMonitor, 728 eventLog: eventLog, 729 localIdentity: { [identity, preferences] in 730 guard let authorID = identity.currentID, !authorID.isEmpty else { return nil } 731 return (authorID, preferences.name) 732 } 733 ) 734 self.cloudService = CloudService( 735 container: self.ckContainer, 736 syncEngine: syncEngine, 737 syncMonitor: self.syncMonitor, 738 store: store, 739 shareController: shareController 740 ) 741 self.importService = ImportService(store: store, driveMonitor: self.driveMonitor) 742 self.engagementHost = EngagementHost() 743 self.engagementHost.onEvent = { [weak self] event in 744 self?.engagement.handleEngagementEvent(event) 745 } 746 self.store.onLocalCellEdit = { [weak self] edit in 747 self?.engagement.sendLocalCellEdit(edit) 748 } 749 self.store.onLocalCellEditBatch = { [weak self] edits in 750 self?.engagement.sendLocalCellEdits(edits) 751 } 752 self.store.onJournalComplete = { [weak self] gameID, authorID, resigned, notifyPeers in 753 if notifyPeers { 754 self?.sessions.stageCompletionDelivery(gameID: gameID, resigned: resigned) 755 } 756 self?.beginCompletionJournalUpload(gameID: gameID, authorID: authorID) 757 } 758 } 759 760 private static let cloudGenerationKey = "cloudGeneration" 761 private static let currentCloudGeneration = 4 762 /// UserDefaults flag that drives the one-time v4 reset notice; read by 763 /// `GameListView` via `@AppStorage`. See `NoticeView`. 764 static let showV4NoticeDefaultsKey = "showV4Notice" 765 766 /// Detects a device carrying data from the pre-v4 CloudKit container, wipes 767 /// the local cache of it, and flags the one-time explanatory notice. 768 /// Idempotent via a stored generation marker, so it runs at most once per 769 /// device across the v3→v4 boundary. The wipe is *local only* 770 /// (`purgeLocalData`): the v3 container is abandoned, not deleted — we can't 771 /// reach co-owners' shared zones anyway, and dormant v3 data is harmless. 772 private func migrateOffLegacyContainerIfNeeded() async { 773 // The demo seed brings up a throwaway in-memory store that is not a real 774 // v3→v4 transition — never purge it. (The notice can still be previewed 775 // via the flag set at seed time.) 776 guard !ProcessInfo.processInfo.arguments.contains("--crossmate-seed-demo") 777 else { return } 778 let defaults = UserDefaults.standard 779 guard defaults.integer(forKey: Self.cloudGenerationKey) < Self.currentCloudGeneration 780 else { return } 781 782 if await deviceHasLegacyData() { 783 do { 784 try await cloudService.purgeLocalData() 785 } catch { 786 syncMonitor.note("v4 transition purge failed — \(error)") 787 } 788 defaults.set(true, forKey: Self.showV4NoticeDefaultsKey) 789 syncMonitor.note("Migrated device off legacy CloudKit container — local cache cleared for v4") 790 } 791 defaults.set(Self.currentCloudGeneration, forKey: Self.cloudGenerationKey) 792 } 793 794 /// Pre-v4 data signal: the local store first (offline, instant), then — for 795 /// a reinstalled device with an empty store — a probe of the legacy v3 796 /// container for any custom zone (an `account` or per-game zone). A probe 797 /// failure (signed out / offline) reports `false`, so no spurious notice 798 /// shows. 799 private func deviceHasLegacyData() async -> Bool { 800 if store.hasAnyGames() { return true } 801 do { 802 let zones = try await CloudContainer.legacyContainer 803 .privateCloudDatabase.allRecordZones() 804 let defaultZoneName = CKRecordZone.default().zoneID.zoneName 805 return zones.contains { $0.zoneID.zoneName != defaultZoneName } 806 } catch { 807 return false 808 } 809 } 810 811 func start(appDelegate: AppDelegate) async { 812 if let startupTask { 813 await startupTask.value 814 return 815 } 816 817 let task = Task { @MainActor in 818 await self.performStartup(appDelegate: appDelegate) 819 } 820 startupTask = task 821 await task.value 822 } 823 824 private func performStartup(appDelegate: AppDelegate) async { 825 // One-time transition off the pre-v4 CloudKit container. The v4 build 826 // points at a brand-new, empty container, so any games this device 827 // cached under v3 are orphans that can never sync again — wipe them and 828 // flag the explanatory notice. Runs before sync starts so the library 829 // never shows the stale rows. 830 await migrateOffLegacyContainerIfNeeded() 831 832 // Surface one onboarding tip per cold launch. The in-memory 833 // AnnouncementCenter is empty on a fresh process, so this re-posts the 834 // next undismissed tip on each cold start; a warm resume doesn't re-run 835 // start(), so no tip reappears mid-session. Independent of iCloud sync, 836 // so it runs ahead of the sync-enablement guard below. The launch note 837 // holds tips off for a new user's first couple of visits to the Game 838 // List (see TipStore.launchesBeforeTips), so it must run before the tip 839 // is read. 840 tips.noteColdLaunch() 841 // Keep onboarding tips out of marketing screenshots — the import scene 842 // shows the Game List, and a tip banner is chrome that doesn't belong in 843 // the captured image. 844 #if DEBUG 845 let suppressTips = MarketingLaunch.isScreenshot 846 #else 847 let suppressTips = false 848 #endif 849 if !suppressTips, let tip = tips.currentTip() { 850 announcements.post(tip.liveAnnouncement()) 851 } 852 853 // Hydrate the persisted diagnostics history before live breadcrumbs 854 // flow, so a log collected this morning still carries last night's 855 // session. Ordering against startup notes is by timestamp, so a note 856 // that races ahead of this isn't lost. 857 await eventLog.loadPersisted() 858 859 nytAuth.loadStoredSession() 860 driveMonitor.start() 861 862 store.onUnreadOtherMovesChanged = { [weak self] in 863 guard let self else { return } 864 Task { await self.badge.refreshAppBadge(reason: "unread changed") } 865 } 866 importVisibleNotificationReceipts() 867 await badge.refreshAppBadge(reason: "startup") 868 await badge.logNotificationStartupSnapshot() 869 870 // Heal the App Group nickname directory from Core Data ground truth — 871 // covers the first run after the feature shipped and any rebuild a 872 // crash or extension write skipped. Cheap: one fetch over the (small) 873 // friends table. 874 let nicknameCtx = persistence.container.newBackgroundContext() 875 await nicknameCtx.perform { 876 FriendEntity.rebuildNicknameDirectory(in: nicknameCtx) 877 // Heal the App Group content-key directory from the same context — 878 // covers the first run after the feature shipped and any rebuild a 879 // crash or extension write skipped. Cheap: one fetch over the games. 880 GameEntity.rebuildContentKeyDirectory(in: nicknameCtx) 881 } 882 883 appDelegate.onVisibleNotificationReceiptsAvailable = { [weak self] in 884 Task { @MainActor in 885 self?.importVisibleNotificationReceipts() 886 } 887 } 888 appDelegate.onAPNsRegistrationResult = { [syncMonitor] message in 889 syncMonitor.note(message) 890 } 891 appDelegate.onAPNsToken = { [weak self] data in 892 Task { @MainActor in self?.pushClient?.updateAPNsToken(data) } 893 } 894 CloudShareAcceptanceBroker.shared.onAcceptShare = { metadata in 895 await self.enqueueShareAcceptance(metadata) 896 } 897 898 await syncEngine.setTracer { [syncMonitor] message in 899 syncMonitor.note(message) 900 } 901 902 await syncEngine.setSuccessCheckpoint { [syncMonitor] in 903 syncMonitor.noteSuccess() 904 } 905 906 await syncEngine.setLocalAuthorIDProvider { [identity] in 907 identity.currentID 908 } 909 910 await syncEngine.setOnRemoteMovesUpdated { [weak self, store, identity] gameIDs in 911 store.noteIncomingMovesUpdate( 912 gameIDs: gameIDs, 913 currentAuthorID: identity.currentID 914 ) 915 if let currentID = store.currentEntity?.id, 916 gameIDs.contains(currentID) { 917 store.refreshCurrentGame() 918 // `presenceUntil` doubles as the other-author read cursor: advancing it 919 // marks these incoming peer moves as seen. Gate on `isSuppressed` 920 // — "the user is viewing *this* puzzle right now" — so moves are 921 // marked read only while actually on screen, not merely because 922 // the app is foreground on some other view (`currentEntity` 923 // lingers after navigating away). The background re-lease is 924 // blocked separately by publishReadCursor's foreground gate. 925 if NotificationState.isSuppressed(gameID: currentID) { 926 await self?.publishReadCursor(for: currentID, mode: .activeLease) 927 } 928 } 929 // Maintain the per-cell letter-change ledger that the "changed while 930 // you were away" borders and banner read. Captures peer fills/clears 931 // (never check re-stamps) as they arrive, whether or not the game is 932 // open. Fire-and-forget: the inbound-moves hot path must not wait on 933 // this background write, so the next batch isn't throttled behind it. 934 store.enqueuePeerChangeLedgerUpdate(for: gameIDs) 935 } 936 937 // Friendship bootstrap keys off the *first* sight of a collaborator's 938 // Player record (their identity) — fires once per new collaborator, 939 // not on moves and not on their later name / cursor updates. 940 await syncEngine.setOnRemotePlayersUpdated { [weak self] gameIDs in 941 await self?.invites.reconcileFriendships(forGameIDs: gameIDs) 942 // A newly-arrived shared game means a new address slot to mint and 943 // a token to register under it (so this device can receive the 944 // game's pushes without opening it first). 945 await self?.accountPush.reconcilePushRegistration() 946 } 947 948 // An inbound Player record may have updated a peer's cursor track; 949 // nudge the selection publisher and engagement coordinator to 950 // re-evaluate peer presence. This must fire for existing Player 951 // records too: known collaborators opening a puzzle are the common 952 // live co-solving path. 953 await syncEngine.setOnRemotePlayerPresenceChanged { [weak self] gameIDs in 954 await self?.playerSelectionPublisher.peerPresenceMayHaveChanged(gameIDs: gameIDs) 955 guard let self else { return } 956 for gameID in gameIDs { 957 await self.engagement.reconcileEngagement(gameID: gameID) 958 } 959 } 960 961 // A peer minted or rotated the shared engagement room (the Game 962 // record's `engagement` creds changed). Reconcile so this device joins 963 // — or migrates onto — whatever room the record now advertises. 964 await syncEngine.setOnRemoteEngagementChanged { [weak self] gameIDs in 965 guard let self else { return } 966 for gameID in gameIDs { 967 await self.engagement.reconcileEngagement(gameID: gameID) 968 } 969 } 970 971 // A previously accepted participant vanished from a game's zone-wide 972 // share — they left or were removed. Rotate the game's push 973 // credentials (they hold every field of the old ones) and re-register 974 // so this device binds under the new credID and drops the old binding. 975 await syncEngine.setOnPushCredentialRotationNeeded { [weak self] gameIDs in 976 guard let self else { return } 977 var rotated = false 978 for gameID in gameIDs where self.store.rotatePushCredentials(for: gameID) != nil { 979 self.syncMonitor.note("push credentials rotated for \(gameID.uuidString) after roster shrink") 980 rotated = true 981 } 982 if rotated { 983 await self.accountPush.reconcilePushRegistration() 984 } 985 } 986 987 // A peer minted or rotated a game's push credential and this device 988 // just adopted it. Re-run registration so the device binds under the 989 // new credID (and unregisters the old binding) now, rather than on the 990 // next launch or APNs token delivery — until then, publishes signed 991 // with the new credential could not reach it. 992 await syncEngine.setOnRemoteCredentialsChanged { [weak self] _ in 993 await self?.accountPush.reconcilePushRegistration() 994 } 995 996 await syncEngine.setOnBlockedFriendsChanged { [weak self] authorIDs in 997 let hiddenChanges = await self?.store.reconcileBlockedFriendHiddenGames( 998 forAuthorIDs: authorIDs 999 ) ?? 0 1000 if hiddenChanges > 0 { 1001 self?.syncMonitor.note("block visibility reconcile: updated \(hiddenChanges) game(s)") 1002 } 1003 await self?.refreshSnapshot() 1004 } 1005 1006 await syncEngine.setOnGameVisibilityCandidates { [weak self] gameIDs in 1007 if let currentID = self?.store.currentEntity?.id, 1008 gameIDs.contains(currentID) { 1009 self?.store.refreshCurrentSyncState() 1010 } 1011 let hiddenChanges = await self?.store.reconcileBlockedFriendHiddenGames( 1012 forGameIDs: gameIDs 1013 ) ?? 0 1014 if hiddenChanges > 0 { 1015 self?.syncMonitor.note("block visibility reconcile: updated \(hiddenChanges) game(s)") 1016 await self?.refreshSnapshot() 1017 } 1018 } 1019 1020 // A sibling device of the same iCloud account has published its read 1021 // horizon; apply it directly because SyncEngine has already accepted 1022 // the Player record under last-writer-wins freshness checks. A 1023 // future-dated presenceUntil is an active-session lease — a sibling is in the 1024 // puzzle right now — so withdraw any session notifications we already 1025 // delivered for that game (e.g. "X is solving"); opening it here is no 1026 // longer something to nudge for. A past presenceUntil is just a closed-session 1027 // horizon bump and leaves delivered notifications untouched. 1028 await syncEngine.setOnIncomingReadCursor { [weak self, store, gameViewedStore] pairs in 1029 let now = Date() 1030 for (gameID, presenceUntil, viewedAt) in pairs { 1031 let (previous, adopted) = store.noteIncomingReadCursor(gameID: gameID, presenceUntil: presenceUntil) 1032 self?.syncMonitor.note( 1033 "lease ADOPT[\(gameID.uuidString.prefix(8))] src=sync " + 1034 "presenceUntil=\(presenceUntil.ISO8601Format()) " + 1035 "was=\(previous?.ISO8601Format() ?? "—")" + 1036 (adopted ? "" : " (no-op)") 1037 ) 1038 // A sibling device shipped its "last viewed" cutoff on its own 1039 // `Player.viewedAt`; fold it in monotonically so we converge on 1040 // the latest view time across the account rather than 1041 // recomputing from this device's (possibly stale) local view. 1042 if let viewedAt { 1043 gameViewedStore.advance(viewedAt, forGame: gameID) 1044 } 1045 if presenceUntil > now { 1046 await self?.badge.dismissDeliveredNotifications( 1047 for: gameID, 1048 seenAt: presenceUntil, 1049 publishAccountSeen: false, 1050 preserveUnread: true 1051 ) 1052 } else if NotificationState.activePuzzleID() == gameID { 1053 self?.syncMonitor.note( 1054 "lease ADOPT[\(gameID.uuidString.prefix(8))] past while active; reasserting" 1055 ) 1056 // A past-dated presenceUntil is a sibling closing its session, which 1057 // under last-writer-wins just pulled the shared account 1058 // horizon back to that close time. This device is still 1059 // actively viewing the same puzzle, so it still holds a 1060 // presence lease — re-assert it now instead of waiting up to 1061 // `readLeaseRefreshFloor` (5 min) for the next renewal tick. 1062 // `requireActivePuzzle` re-checks inside the write so a leave 1063 // racing this inbound can't strand a stale future lease. 1064 // The local badge ledger keeps this device's own suppression 1065 // horizon — a sibling's close doesn't mean *we* stopped 1066 // looking. 1067 await self?.publishReadCursor( 1068 for: gameID, 1069 mode: .activeLease, 1070 requireActivePuzzle: true 1071 ) 1072 } else { 1073 // Sibling closed its session and nothing is on screen here: 1074 // the account stopped looking at `presenceUntil`. Pull the badge 1075 // ledger's suppression horizon back to that instant (and 1076 // advance the watermark to it) so a push arriving after the 1077 // close badges here instead of staying swallowed under the 1078 // sibling's old lease, which this device adopted when the 1079 // lease was minted. 1080 BadgeState.markSeen(gameID: gameID, at: presenceUntil) 1081 BadgeState.collapseSuppression(gameID: gameID, to: presenceUntil) 1082 } 1083 } 1084 } 1085 1086 await syncEngine.setOnAccountPushAddress { [weak self] address in 1087 await self?.accountPush.adoptInboundPushAddress(address) 1088 } 1089 1090 await syncEngine.setOnAccountPushSecret { [weak self] secret, version in 1091 await self?.accountPush.adoptInboundPushSecret(secret, version: version) 1092 } 1093 1094 shareController.onParticipantRemoved = { [weak self] gameID in 1095 guard let self else { return } 1096 guard self.store.rotatePushCredentials(for: gameID) != nil else { return } 1097 self.syncMonitor.note( 1098 "push credentials rotated for \(gameID.uuidString) after participant removal" 1099 ) 1100 Task { @MainActor [weak self] in 1101 await self?.accountPush.reconcilePushRegistration() 1102 } 1103 } 1104 1105 shareController.onShareSaved = { [weak self] gameID in 1106 guard let self else { return } 1107 self.store.markShared(gameID: gameID) 1108 // Mint the game's notification content key now, at share time, 1109 // rather than lazily on the first push. The key rides the Game 1110 // record (`setNotification` enqueues its push), so minting here 1111 // gives it time to propagate to participants before any encrypted 1112 // notification is sent. Lazy minting let the first push for a game 1113 // with no prior activity (e.g. an immediate resign on a game with 1114 // no moves) outrun the key's sync, leaving the recipient unable to 1115 // decrypt and falling back to the generic alert. Idempotent. 1116 self.store.ensurePushCredentials(for: gameID) 1117 // Register this device under the newly-shared game's derived push 1118 // address so peers can reach it. 1119 Task { @MainActor [weak self] in 1120 await self?.accountPush.reconcilePushRegistration() 1121 } 1122 // Register the app for notifications now that the user has chosen 1123 // to collaborate. Surfaces the app in Settings > Notifications and 1124 // makes the icon-badge permission available before any inbound 1125 // moves can arrive. 1126 Task { await AppDelegate.requestNotificationAuthorizationIfNeeded() } 1127 } 1128 1129 await syncEngine.setOnPings { [weak self] pings in 1130 guard let self else { return } 1131 await self.invites.presentPings(pings) 1132 } 1133 1134 await syncEngine.setOnPingDeliveryUpdate { [weak self] update in 1135 guard let self else { return } 1136 switch update.state { 1137 case .queued: 1138 self.inviteDeliveries.markQueued( 1139 recordName: update.recordName, 1140 gameID: update.gameID, 1141 friendAuthorID: update.addressee 1142 ) 1143 self.dismissInviteFailureAnnouncement( 1144 gameID: update.gameID, 1145 friendAuthorID: update.addressee 1146 ) 1147 case .sent: 1148 self.inviteDeliveries.markSent( 1149 recordName: update.recordName, 1150 gameID: update.gameID, 1151 friendAuthorID: update.addressee 1152 ) 1153 self.dismissInviteFailureAnnouncement( 1154 gameID: update.gameID, 1155 friendAuthorID: update.addressee 1156 ) 1157 case .failed: 1158 let failure = update.failure ?? .other 1159 self.inviteDeliveries.markFailed( 1160 recordName: update.recordName, 1161 gameID: update.gameID, 1162 friendAuthorID: update.addressee, 1163 failure: failure 1164 ) 1165 // Always post, even with an invite sheet open. The sheet shows 1166 // the same failure inline but only for the send it issued 1167 // itself, so gating on "a sheet is presented" could swallow a 1168 // failure entirely — including the one that arrives after a 1169 // confirmed-send wait has already timed out. The banner is 1170 // scoped to the Game List behind the sheet, and the `.queued` 1171 // and `.sent` cases above retract it, so a retry that succeeds 1172 // never leaves a stale one behind. 1173 self.announcements.post(Announcement( 1174 id: InviteDeliveryStore.failureAnnouncementID( 1175 gameID: update.gameID, 1176 friendAuthorID: update.addressee 1177 ), 1178 scope: .global, 1179 severity: .error, 1180 title: String(localized: failure.title), 1181 body: String(localized: failure.body), 1182 dismissal: .manual 1183 )) 1184 if update.rollbackParticipantOnFailure { 1185 Task { @MainActor [weak self] in 1186 await self?.invites.rollbackUndeliveredInvite( 1187 gameID: update.gameID, 1188 friendAuthorID: update.addressee 1189 ) 1190 } 1191 } 1192 } 1193 } 1194 1195 await syncEngine.setOnAccountChange { [weak self] in 1196 guard let self else { return } 1197 let previousID = self.identity.currentID 1198 await self.identity.refresh(using: self.ckContainer) 1199 let newID = self.identity.currentID 1200 // A switch to a *different* iCloud account: drop this device's 1201 // cache of the previous account's data so it neither lingers in 1202 // the library nor mixes with the new author's rows. Local only — 1203 // the previous account keeps its games in its own CloudKit; this 1204 // device just resyncs as the new account. Gated on the author ID 1205 // actually changing so a first sign-in (no previous) or a 1206 // transient sign-out (`refresh` no-ops, ID unchanged) doesn't 1207 // purge. 1208 if Self.accountSwitchRequiresPurge(previousID: previousID, newID: newID) { 1209 do { 1210 try await self.cloudService.purgeLocalData() 1211 } catch { 1212 self.syncMonitor.note("account-switch purge failed — \(error)") 1213 } 1214 } 1215 self.pushClient?.updateAuthorID(newID) 1216 // Recompute the address set for the new account; addresses that 1217 // belonged to the old account drop out and are unregistered. 1218 await self.accountPush.reconcilePushRegistration() 1219 } 1220 1221 await syncEngine.setOnGameAccessRevoked { [weak self, store, gameViewedStore, announcements, gameArchiver] gameID in 1222 store.markAccessRevoked(gameID: gameID) 1223 // Supersede any pending catch-up banner: advancing the view baseline 1224 // to now leaves nothing for the next open to diff against. 1225 gameViewedStore.advance(Date(), forGame: gameID) 1226 // Surface the revocation as a sticky, input-blocking banner on 1227 // the open puzzle, replacing the former AccessRevokedBanner 1228 // overlay. Game-scoped, so it only shows for this puzzle. 1229 announcements.post(.accessRevoked(gameID: gameID)) 1230 // The owner deleted the shared zone. For a *finished* game, swap the 1231 // revoked tombstone for a durable owned copy rebuilt from the 1232 // private-zone archive (and from the still-present local data); 1233 // in-progress games are left as revoked rows. 1234 await gameArchiver.promoteRevoked(gameID: gameID) 1235 await self?.accountPush.reconcilePushRegistration() 1236 } 1237 1238 await syncEngine.setOnGameRemoved { [weak self, store, gameViewedStore, announcements, gameArchiver] gameID in 1239 let wasOpen = store.handleRemoteRemoval(gameID: gameID) 1240 // Another owner device may have retired this completed live zone. 1241 // Its compact private Archive is account-wide, but the Archive 1242 // record was intentionally inert while the live row existed; apply 1243 // it now that the zone deletion removed that row. 1244 await gameArchiver.restoreRetired(gameID: gameID) 1245 gameViewedStore.advance(Date(), forGame: gameID) 1246 // The local row is gone, so drop its badge ledger entry: a seen 1247 // horizon can't clear it once there's no game left to open. 1248 BadgeState.forget(gameID: gameID) 1249 await self?.badge.refreshAppBadge(reason: "game removed") 1250 // A hard-deleted game (private zone gone, or a shared game left 1251 // elsewhere) only needs UI when its puzzle is on screen: a sticky, 1252 // input-blocking banner freezes the now-orphaned puzzle until the 1253 // user backs out. Off-screen removals just drop from the list. 1254 if wasOpen { 1255 announcements.post(.gameRemoved(gameID: gameID)) 1256 } 1257 await self?.accountPush.reconcilePushRegistration() 1258 } 1259 1260 await syncEngine.setOnGameCompleted { [weak self, gameArchiver] gameID in 1261 await self?.shareController.closeTicketForCompletedGame(gameID: gameID) 1262 // Completion learned purely via sync (this device wasn't present at 1263 // the finish, so persistCompletion never ran): drop the now-useless 1264 // peer-change ledger, the writer's terminal-game path doing the work. 1265 self?.store.enqueuePeerChangeLedgerUpdate(for: [gameID]) 1266 await gameArchiver.archiveIfNeeded(gameID: gameID) 1267 } 1268 1269 await syncEngine.setOnReplayJournalsSynced { [gameArchiver] gameIDs in 1270 for gameID in gameIDs { 1271 await gameArchiver.archiveIfNeeded(gameID: gameID) 1272 } 1273 } 1274 1275 await syncEngine.setOnCompletionRecordsSaved { [weak self] records in 1276 await self?.sessions.noteCompletionRecordsSaved(records) 1277 } 1278 await sessions.resumePendingCompletionDeliveries() 1279 1280 await syncEngine.setOnGameJoined { [weak self] gameID in 1281 guard let self else { return } 1282 // A shared zone just synced in for this game — joined here or on 1283 // a sibling device. Its "Invited" row is now redundant; drop it 1284 // so a freshly-synced game and its stale invite don't show side 1285 // by side. `applyInvitePings` GCs the same row, but only when a 1286 // ping is next fetched. 1287 do { 1288 try self.invites.removePendingInvite(forGameID: gameID) 1289 // The pending invite (if any) is gone; drop it from the badge. 1290 await self.badge.refreshAppBadge(reason: "game joined") 1291 } catch { 1292 self.announcements.post(Announcement( 1293 id: "remove-pending-invite-error-\(gameID.uuidString)", 1294 scope: .global, 1295 severity: .error, 1296 title: "Clearing Failed", 1297 body: error.localizedDescription, 1298 dismissal: .manual 1299 )) 1300 } 1301 // Defer the sync enqueue out of the `onGameJoined` callback; the 1302 // actual CKSyncEngine send drain remains detached in SyncEngine. 1303 Task { @MainActor [weak self] in 1304 await self?.accountPush.reconcilePushRegistration() 1305 } 1306 } 1307 1308 // A sibling device consumed (deleted) a directed ping; withdraw any 1309 // copy of that game's notification we delivered before the deletion 1310 // reached us, and clear any durable invite row backed by that Ping. 1311 await syncEngine.setOnPingDeleted { [weak self] pings in 1312 guard let self else { return } 1313 try? self.invites.removePendingInvites(forPingRecordNames: Set(pings.map { $0.recordName })) 1314 await self.badge.refreshAppBadge(reason: "ping deleted") 1315 for gameID in Set(pings.map { $0.gameID }) { 1316 await self.badge.dismissDeliveredNotifications( 1317 for: gameID, 1318 publishAccountSeen: false 1319 ) 1320 } 1321 } 1322 1323 cloudService.onShareJoined = { [weak self] gameID in 1324 guard let self else { return } 1325 // Register the app for notifications now that the user has joined 1326 // a collaboration. Mirrors the owner path in `onShareSaved` so the 1327 // app is in Settings > Notifications before any inbound moves. 1328 await AppDelegate.requestNotificationAuthorizationIfNeeded() 1329 await self.accountPush.reconcilePushRegistration() 1330 // Stamp (minting if needed) this account's own derived push address 1331 // for the joined game, both so the room broadcast below can exclude 1332 // our own devices and so we're addressable for inbound pushes. 1333 let ownAddress = self.identity.currentID.flatMap { 1334 self.accountPush.setDerivedPushAddress(gameID: gameID, authorID: $0) 1335 } 1336 // Joining can complete without presenting PuzzleDisplayView (for 1337 // example when the app backgrounds during acceptance). Publish the 1338 // profile-name snapshot here as part of the join itself rather than 1339 // relying on a later puzzle open to fill the Player record. 1340 await self.playerNamePublisher?.publishName(for: gameID) 1341 await self.accountPush.publishAccountJoinedPush(gameID: gameID) 1342 // Tell everyone already in the room that we've joined. 1343 await self.sessions.publishJoinPush(gameID: gameID, excludeAddress: ownAddress) 1344 } 1345 1346 // PlayerNamePublisher fans out name changes as `name` Decisions to the 1347 // account zone and every friend zone. PuzzleDisplayView publishes the 1348 // open game's Player-record name snapshot directly, which covers 1349 // first-sync-after-share-create / accept and pre-friendship display. 1350 playerNamePublisher = PlayerNamePublisher( 1351 preferences: preferences, 1352 persistence: persistence, 1353 authorIdentity: identity, 1354 enqueuePlayer: { [preferences, syncEngine] gameID, authorID, reason in 1355 let isEnabled = await MainActor.run { preferences.isICloudSyncEnabled } 1356 guard isEnabled else { return } 1357 await syncEngine.enqueuePlayer( 1358 gameID: gameID, 1359 authorID: authorID, 1360 reason: reason 1361 ) 1362 }, 1363 enqueueNameDecision: { [preferences, syncEngine] authorID, name, version, zoneID, scope in 1364 let isEnabled = await MainActor.run { preferences.isICloudSyncEnabled } 1365 guard isEnabled else { return } 1366 await syncEngine.enqueueNameDecision( 1367 authorID: authorID, 1368 name: name, 1369 version: version, 1370 zoneID: zoneID, 1371 scope: scope 1372 ) 1373 } 1374 ) 1375 1376 // Install this only after every SyncEngine callback above is ready. 1377 // Assignment starts the delegate's buffered-notification drain; doing 1378 // it earlier could let that drain start SyncEngine and advance its 1379 // change token while one-shot callbacks were still absent. Keep the 1380 // installation above the sync-enablement guard so buffered wakes are 1381 // also drained (and diagnosed as ignored) when iCloud sync is off. 1382 appDelegate.onRemoteNotification = { 1383 summary, scope, event, gameID, kind, senderDeviceID, presenceUntil, isBackground in 1384 await self.handleRemoteNotification( 1385 summary: summary, 1386 scope: scope, 1387 event: event, 1388 gameID: gameID, 1389 kind: kind, 1390 senderDeviceID: senderDeviceID, 1391 presenceUntil: presenceUntil, 1392 isBackground: isBackground 1393 ) 1394 } 1395 1396 guard await ensureICloudSyncStarted() else { 1397 syncMonitor.note("iCloud sync disabled — engine startup skipped") 1398 return 1399 } 1400 // Re-announce any inbox share a friend has still not accepted. The 1401 // bootstrap Ping is otherwise one-shot: a friend whose accept failed at 1402 // delivery is stuck at `friendshipNotReady` with no other retry path. 1403 // Runs only once the engine is up (it enqueues a share and a bootstrap 1404 // ping) and only with sync enabled. Detached so it never delays the 1405 // foreground sync below. 1406 if let localAuthorID = identity.currentID, !localAuthorID.isEmpty { 1407 Task { [friendController, preferences] in 1408 await friendController.healPendingBootstraps( 1409 localAuthorID: localAuthorID, 1410 localDisplayName: preferences.name 1411 ) 1412 } 1413 } 1414 // The scene-active phase that fires alongside cold launch runs the 1415 // first fetch + push via `syncOnForeground`. Doing it here as well 1416 // would mean two concurrent CKSyncEngine fetches on a fresh engine. 1417 } 1418 1419 func enqueueShareAcceptance(_ metadata: CKShare.Metadata) async { 1420 guard preferences.isICloudSyncEnabled else { 1421 syncMonitor.note("share acceptance ignored while iCloud sync is disabled") 1422 return 1423 } 1424 pendingShareMetadatas.append(metadata) 1425 syncMonitor.note( 1426 "share acceptance queued: container=\(metadata.containerIdentifier)" 1427 ) 1428 await processPendingShareAcceptances() 1429 } 1430 1431 func syncOnForeground() async { 1432 importVisibleNotificationReceipts() 1433 await movesUpdater.flush() 1434 guard await ensureICloudSyncStarted() else { return } 1435 let recoveredMoveCount = await syncEngine.enqueueUnconfirmedMoves() 1436 if recoveredMoveCount > 0 { 1437 syncMonitor.note("recovered \(recoveredMoveCount) unconfirmed move(s) for CloudKit enqueue") 1438 } 1439 await syncMonitor.run("foreground push") { 1440 try await syncEngine.pushChanges() 1441 } 1442 if isGameListVisible { 1443 syncMonitor.note("foreground fetch skipped: game list will refresh") 1444 await freshenGameList(reason: .foreground) 1445 return 1446 } 1447 if let (gameID, scope) = activePuzzleGridTarget() { 1448 syncMonitor.note("foreground fetch skipped: active puzzle will refresh") 1449 await freshenPuzzleGrid(gameID: gameID, scope: scope, reason: .foreground) 1450 return 1451 } 1452 await syncMonitor.run("foreground fetch") { 1453 try await syncEngine.fetchChanges(source: "foreground") 1454 } 1455 await refreshSnapshot() 1456 } 1457 1458 private func importVisibleNotificationReceipts() { 1459 for entry in VisibleNotificationReceiptLog.drain() { 1460 eventLog.note(VisibleNotificationReceiptLog.message(for: entry)) 1461 } 1462 } 1463 1464 func gameListAppeared() async { 1465 isGameListVisible = true 1466 await freshenGameList(reason: .appeared) 1467 } 1468 1469 func gameListDisappeared() { 1470 isGameListVisible = false 1471 } 1472 1473 func loadRecentCompleted(since cutoff: Date) async -> GameArchiver.CompletedPage { 1474 guard await ensureICloudSyncStarted() else { 1475 return .init(oldestCompletedAt: nil, hasMore: false) 1476 } 1477 let predecessor = completedPageTask 1478 let task = Task { @MainActor in 1479 if let predecessor { _ = await predecessor.value } 1480 return await gameArchiver.loadRecentCompleted(since: cutoff) 1481 } 1482 let taskID = UUID() 1483 completedPageTask = task 1484 completedPageTaskID = taskID 1485 let page = await task.value 1486 if completedPageTaskID == taskID { 1487 completedPageTask = nil 1488 completedPageTaskID = nil 1489 } 1490 beginRemoteCompletedMigrationIfNeeded() 1491 return page 1492 } 1493 1494 func loadMoreCompleted() async -> GameArchiver.CompletedPage { 1495 guard await ensureICloudSyncStarted() else { 1496 return .init(oldestCompletedAt: nil, hasMore: false) 1497 } 1498 let predecessor = completedPageTask 1499 let task = Task { @MainActor in 1500 if let predecessor { _ = await predecessor.value } 1501 return await gameArchiver.loadMoreCompleted() 1502 } 1503 let taskID = UUID() 1504 completedPageTask = task 1505 completedPageTaskID = taskID 1506 let page = await task.value 1507 if completedPageTaskID == taskID { 1508 completedPageTask = nil 1509 completedPageTaskID = nil 1510 } 1511 return page 1512 } 1513 1514 private func beginRemoteCompletedMigrationIfNeeded() { 1515 guard !hasStartedRemoteCompletedMigration else { return } 1516 hasStartedRemoteCompletedMigration = true 1517 Task { @MainActor [weak self] in 1518 await self?.gameArchiver.migrateMissingCompletedGames() 1519 } 1520 } 1521 1522 /// Runs `work` to completion under a `UIApplication` background-execution 1523 /// assertion, so a flush or enqueue that begins as the app heads to the 1524 /// background still reaches durable state before iOS suspends us. The 1525 /// assertion is taken **synchronously** — before `work`'s first await — and 1526 /// released exactly once: when `work` returns, or when iOS signals imminent 1527 /// expiration, whichever comes first. Best-effort: a force-quit before 1528 /// `work` lands is the one case this can't cover, so callers pair it with a 1529 /// foreground reconcile sweep that re-runs anything that didn't finish. 1530 /// 1531 /// The assertion owns its own lifetime — no instance slot — so overlapping 1532 /// calls each hold an independent assertion that self-releases. Correct 1533 /// only on the main actor: the expiration handler and the completion both 1534 /// run there, so the `released` latch serialises into a single 1535 /// `endBackgroundTask` (a double release is a UIKit fault). `name` is the 1536 /// debug label iOS shows for the assertion. 1537 func ensureInBackground(_ name: String, _ work: @escaping () async -> Void) { 1538 var token = UIBackgroundTaskIdentifier.invalid 1539 var released = false 1540 func release() { 1541 guard !released, token != .invalid else { return } 1542 released = true 1543 UIApplication.shared.endBackgroundTask(token) 1544 } 1545 token = UIApplication.shared.beginBackgroundTask(withName: name, expirationHandler: release) 1546 Task { 1547 await work() 1548 release() 1549 } 1550 } 1551 1552 /// Flush buffered cell edits on the way to the background. Held under a 1553 /// background assertion so the persist + CKSyncEngine enqueue completes 1554 /// even if the scene is suspended immediately; whatever doesn't land is 1555 /// recovered by the next foreground's `enqueueUnconfirmedMoves`. 1556 func syncOnBackground() { 1557 ensureInBackground("moves-flush") { [weak self] in 1558 await self?.movesUpdater.flush() 1559 } 1560 ensureInBackground("event-log-flush") { [weak self] in 1561 await self?.eventLog.flush() 1562 } 1563 } 1564 1565 /// Pull-to-refresh action for the library. Discovers any zones the 1566 /// device hasn't seen yet on both database scopes, then runs the normal 1567 /// engine fetch so any in-flight changes also catch up. Bypasses 1568 /// CKSyncEngine's database-scope change delivery, which can lag behind 1569 /// reality when the engine has been idle. 1570 func refreshLibrary() async { 1571 await freshenGameList(reason: .manual) 1572 guard await ensureICloudSyncStarted() else { return } 1573 await syncMonitor.run("library refresh: engine fetch") { 1574 try await syncEngine.fetchChanges(source: "library refresh") 1575 } 1576 await refreshSnapshot() 1577 } 1578 1579 func freshenGameList(reason: FreshenReason) async { 1580 guard await ensureICloudSyncStarted() else { return } 1581 if let task = gameListFreshenTask { 1582 syncMonitor.note( 1583 "freshen game list \(reason.diagnosticLabel): coalesced into in-flight freshen" 1584 ) 1585 await task.value 1586 return 1587 } 1588 1589 let task = Task { @MainActor in 1590 await self.runFreshenGameList(reason: reason) 1591 } 1592 gameListFreshenTask = task 1593 await task.value 1594 gameListFreshenTask = nil 1595 } 1596 1597 private func runFreshenGameList(reason: FreshenReason) async { 1598 // The game list is a foreground-visible freshness path, not the live 1599 // collaboration path. Keep the two database scopes serialized so list 1600 // appearance and foreground transitions do not create a read burst. 1601 await freshenGameListScope( 1602 .private, 1603 label: "private", 1604 reason: reason 1605 ) 1606 await freshenGameListScope( 1607 .shared, 1608 label: "shared", 1609 reason: reason 1610 ) 1611 await refreshSnapshot() 1612 // Now that Core Data unread reflects server ground truth, prune any 1613 // badge-ledger entry no longer backed by unread state or a delivered 1614 // notification. Reconciling here (rather than once at startup, before 1615 // the freshen settles) clears orphans like a game whose push stamped 1616 // the ledger but was since opened — the divergence that otherwise pins 1617 // the badge above the count the library list shows. 1618 await badge.reconcileBadgeLedgerWithDeliveredNotifications() 1619 await badge.refreshAppBadge(reason: "game list freshen") 1620 await reconcilePendingJournalUploads() 1621 if shouldRunColdLaunchArchiveReconcile { 1622 shouldRunColdLaunchArchiveReconcile = false 1623 // Startup-only backstop for the private-DB archive: re-attempts any 1624 // completed participant game whose archive never landed, without 1625 // repeating the scan on every foreground/manual/remote refresh. 1626 await gameArchiver.reconcileUnarchived() 1627 } 1628 } 1629 1630 /// Level-triggered backstop for replay journal uploads. The upload is 1631 /// normally fired edge-style — once, at local completion or when an inbound 1632 /// sync reveals the completion. But the local-completion enqueue is async 1633 /// (journal flush → prefs check → `enqueueJournalUpload`), so a solver who 1634 /// swipes the app away the instant they win can be suspended before the save 1635 /// reaches CKSyncEngine's durable state; nothing re-fires it, and replay's 1636 /// strict completeness then waits on that contributor forever. This sweep 1637 /// re-enqueues any completed game whose journal hasn't been confirmed 1638 /// uploaded. A re-send is a benign no-op, and `journalUploaded` (set on the 1639 /// confirmed save, here for games this device never contributed to) makes it 1640 /// converge to a no-op rather than re-enqueuing every freshen. 1641 private func reconcilePendingJournalUploads() async { 1642 guard let authorID = identity.currentID, !authorID.isEmpty else { return } 1643 let ctx = persistence.container.newBackgroundContext() 1644 ctx.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump 1645 let candidates: [UUID] = await ctx.perform { 1646 let req = NSFetchRequest<GameEntity>(entityName: "GameEntity") 1647 req.predicate = NSPredicate(format: "completedAt != nil AND journalUploaded == NO") 1648 return ((try? ctx.fetch(req)) ?? []).compactMap(\.id) 1649 } 1650 guard !candidates.isEmpty else { return } 1651 1652 var nothingToUpload: [UUID] = [] 1653 for gameID in candidates { 1654 if store.localJournalEntries(for: gameID).isEmpty { 1655 nothingToUpload.append(gameID) 1656 } else { 1657 await syncEngine.enqueueJournalUpload(gameID: gameID, authorID: authorID) 1658 } 1659 } 1660 guard !nothingToUpload.isEmpty else { return } 1661 1662 // No local journal for these — this device never played them, so there 1663 // is nothing to publish. Mark them done so the sweep stops reconsidering. 1664 let toMark = nothingToUpload 1665 await ctx.perform { 1666 let req = NSFetchRequest<GameEntity>(entityName: "GameEntity") 1667 req.predicate = NSPredicate(format: "id IN %@", toMark) 1668 for game in (try? ctx.fetch(req)) ?? [] { 1669 game.journalUploaded = true 1670 } 1671 if ctx.hasChanges { try? ctx.save() } 1672 } 1673 } 1674 1675 /// Edge-triggered, immediate companion to `reconcilePendingJournalUploads`: 1676 /// fired synchronously on completion (`GameStore.onJournalComplete`). Runs 1677 /// under a background assertion so the journal flush and the CKSyncEngine 1678 /// enqueue reach durable state even if the user backgrounds the app the 1679 /// instant they finish; CKSyncEngine then completes the send. The flush runs 1680 /// regardless of iCloud (it persists local journal entries that would 1681 /// otherwise be lost on termination); the enqueue is gated on sync being 1682 /// enabled. A force-quit before the work completes is the one case this 1683 /// can't cover — that falls to the foreground sweep. 1684 func beginCompletionJournalUpload(gameID: UUID, authorID: String) { 1685 ensureInBackground("journal-upload-\(gameID.uuidString)") { [weak self] in 1686 guard let self else { return } 1687 // Flush the cell buffer and journal queue first, so both the journal 1688 // upload and the archive snapshot see the finished grid and full log 1689 // (the winning move is still in flight when completion fires). The 1690 // flush persists local entries regardless of iCloud; the cloud-bound 1691 // steps below are gated on it. 1692 await self.store.flushCompletionWrites() 1693 guard self.preferences.isICloudSyncEnabled else { return } 1694 await self.shareController.closeTicketForCompletedGame(gameID: gameID) 1695 await self.syncEngine.enqueueJournalUpload(gameID: gameID, authorID: authorID) 1696 // Snapshot finished participant games to this user's private DB for 1697 // cross-device durability. A no-op for owned games (already durable) 1698 // and ones already archived; sequenced after the flush so it can't 1699 // capture a stale grid or miss the final journal rows. 1700 await self.gameArchiver.archiveIfNeeded(gameID: gameID) 1701 } 1702 } 1703 1704 private func freshenGameList( 1705 scope: CKDatabase.Scope, 1706 reason: FreshenReason 1707 ) async { 1708 let label: String 1709 switch scope { 1710 case .private: 1711 label = "private" 1712 case .shared: 1713 label = "shared" 1714 case .public: 1715 return 1716 @unknown default: 1717 return 1718 } 1719 guard await ensureICloudSyncStarted() else { return } 1720 if let task = gameListFreshenTask { 1721 syncMonitor.note( 1722 "freshen game list \(reason.diagnosticLabel): \(label) coalesced into in-flight freshen" 1723 ) 1724 await task.value 1725 return 1726 } 1727 await freshenGameListScope(scope, label: label, reason: reason) 1728 await refreshSnapshot() 1729 } 1730 1731 private func freshenGameListScope( 1732 _ scope: CKDatabase.Scope, 1733 label: String, 1734 reason: FreshenReason 1735 ) async { 1736 let reasonLabel = reason.diagnosticLabel 1737 if !shouldRunGameListFreshen(scope: scope, reason: reason, label: label) { 1738 return 1739 } 1740 guard beginGameListFreshen(scope: scope, label: label, reason: reasonLabel) else { 1741 return 1742 } 1743 defer { endGameListFreshen(scope: scope) } 1744 1745 await syncMonitor.run("freshen game list \(reasonLabel): \(label) discovery") { 1746 _ = try await self.syncEngine.discoverNewZonesDirect(scope: scope) 1747 } 1748 let catchUpResult: Int? = await syncMonitor.run("freshen game list \(reasonLabel): \(label) game/moves") { 1749 try await self.syncEngine.fetchKnownGameMovesDirect(scope: scope) 1750 } 1751 let inviteResult: Int? = await syncMonitor.run("freshen game list \(reasonLabel): \(label) invites") { 1752 try await self.syncEngine.fetchFriendInvitesDirect(scope: scope) 1753 } 1754 if inviteResult != nil { 1755 await badge.refreshAppBadge(reason: "invite freshen") 1756 } 1757 if catchUpResult != nil { 1758 noteGameListFreshenCompleted(scope: scope) 1759 } 1760 } 1761 1762 /// Decides whether a game-list freshen should run for this scope. The 1763 /// freshen polls each active zone for new records and enumerates 1764 /// database zones for newly-shared games; between inbound pushes it 1765 /// can't surface anything new, so we skip when the push signal hasn't 1766 /// moved since the last successful run and the staleness budget hasn't 1767 /// been exhausted. `.manual` (pull-to-refresh) always runs because the 1768 /// user explicitly asked. 1769 private func shouldRunGameListFreshen( 1770 scope: CKDatabase.Scope, 1771 reason: FreshenReason, 1772 label: String 1773 ) -> Bool { 1774 if reason == .manual { 1775 return true 1776 } 1777 guard let last = lastGameListFreshenAt(scope: scope) else { 1778 return true 1779 } 1780 if let pushAt = lastRemoteNotificationAt, pushAt > last { 1781 return true 1782 } 1783 let elapsed = Date().timeIntervalSince(last) 1784 if elapsed >= gameListFreshenCooldown { 1785 return true 1786 } 1787 let elapsedSeconds = Int(elapsed.rounded()) 1788 syncMonitor.note( 1789 "freshen game list \(reason.diagnosticLabel): \(label) skipped (cooldown, last \(elapsedSeconds)s ago)" 1790 ) 1791 return false 1792 } 1793 1794 private func lastGameListFreshenAt(scope: CKDatabase.Scope) -> Date? { 1795 switch scope { 1796 case .private: 1797 return lastPrivateGameListFreshenAt 1798 case .shared: 1799 return lastSharedGameListFreshenAt 1800 case .public: 1801 return nil 1802 @unknown default: 1803 return nil 1804 } 1805 } 1806 1807 private func noteGameListFreshenCompleted(scope: CKDatabase.Scope) { 1808 let now = Date() 1809 switch scope { 1810 case .private: 1811 lastPrivateGameListFreshenAt = now 1812 case .shared: 1813 lastSharedGameListFreshenAt = now 1814 case .public: 1815 return 1816 @unknown default: 1817 return 1818 } 1819 } 1820 1821 private func beginGameListFreshen( 1822 scope: CKDatabase.Scope, 1823 label: String, 1824 reason: String 1825 ) -> Bool { 1826 switch scope { 1827 case .private: 1828 guard !isFresheningPrivateGameList else { 1829 syncMonitor.note("freshen game list \(reason): \(label) coalesced into in-flight freshen") 1830 return false 1831 } 1832 isFresheningPrivateGameList = true 1833 return true 1834 case .shared: 1835 guard !isFresheningSharedGameList else { 1836 syncMonitor.note("freshen game list \(reason): \(label) coalesced into in-flight freshen") 1837 return false 1838 } 1839 isFresheningSharedGameList = true 1840 return true 1841 case .public: 1842 return false 1843 @unknown default: 1844 return false 1845 } 1846 } 1847 1848 private func endGameListFreshen(scope: CKDatabase.Scope) { 1849 switch scope { 1850 case .private: 1851 isFresheningPrivateGameList = false 1852 case .shared: 1853 isFresheningSharedGameList = false 1854 case .public: 1855 return 1856 @unknown default: 1857 return 1858 } 1859 } 1860 1861 func freshenPuzzleGrid( 1862 gameID: UUID, 1863 scope: CKDatabase.Scope, 1864 reason: FreshenReason 1865 ) async { 1866 await movesUpdater.flush() 1867 guard await ensureICloudSyncStarted() else { return } 1868 let label = reason.diagnosticLabel 1869 if reason == .remote, 1870 shouldSkipRecentRemotePuzzleGridFreshen( 1871 gameID: gameID, 1872 scope: scope, 1873 label: label 1874 ) { 1875 return 1876 } 1877 guard beginPuzzleGridFreshen(gameID: gameID, scope: scope, reason: label) else { 1878 return 1879 } 1880 defer { 1881 endPuzzleGridFreshen(gameID: gameID, scope: scope) 1882 if reason == .remote { 1883 noteRemotePuzzleGridFreshenCompleted(gameID: gameID, scope: scope) 1884 } 1885 } 1886 1887 await syncMonitor.run("freshen puzzle grid \(label)") { 1888 let handled = try await syncEngine.fetchGameDirect( 1889 scope: scope, 1890 gameID: gameID 1891 ) 1892 if !handled { 1893 try await syncEngine.fetchChanges(source: "puzzle grid \(label)") 1894 } 1895 } 1896 await refreshSnapshot() 1897 } 1898 1899 private func beginPuzzleGridFreshen( 1900 gameID: UUID, 1901 scope: CKDatabase.Scope, 1902 reason: String 1903 ) -> Bool { 1904 let key = puzzleGridFreshenKey(gameID: gameID, scope: scope) 1905 guard !fresheningPuzzleGridKeys.contains(key) else { 1906 syncMonitor.note( 1907 "freshen puzzle grid \(reason): \(scopeLabel(scope)) \(gameID.uuidString.prefix(8)) coalesced into in-flight freshen" 1908 ) 1909 return false 1910 } 1911 fresheningPuzzleGridKeys.insert(key) 1912 return true 1913 } 1914 1915 private func endPuzzleGridFreshen(gameID: UUID, scope: CKDatabase.Scope) { 1916 fresheningPuzzleGridKeys.remove(puzzleGridFreshenKey(gameID: gameID, scope: scope)) 1917 } 1918 1919 private func shouldSkipRecentRemotePuzzleGridFreshen( 1920 gameID: UUID, 1921 scope: CKDatabase.Scope, 1922 label: String 1923 ) -> Bool { 1924 // The debounce only suppresses refreshes that the live channel already 1925 // covers. When the engagement websocket is live for this game, grid 1926 // deltas arrive over it and the push-driven fetch is redundant, so 1927 // collapsing a burst of pushes is harmless. When it is not live, the 1928 // CK-push path is the only thing converging the grid — never skip it, 1929 // or convergence stalls exactly when the live overlay is down. 1930 guard engagementStatus.isLive(gameID: gameID) else { return false } 1931 let key = puzzleGridFreshenKey(gameID: gameID, scope: scope) 1932 guard let last = lastRemotePuzzleGridFreshenAt[key] else { return false } 1933 let elapsed = Date().timeIntervalSince(last) 1934 guard elapsed < remotePuzzleGridFreshenDebounce else { return false } 1935 syncMonitor.note( 1936 "freshen puzzle grid \(label): \(scopeLabel(scope)) \(gameID.uuidString.prefix(8)) skipped (recent remote refresh \(Int(elapsed.rounded()))s ago)" 1937 ) 1938 return true 1939 } 1940 1941 private func noteRemotePuzzleGridFreshenCompleted(gameID: UUID, scope: CKDatabase.Scope) { 1942 lastRemotePuzzleGridFreshenAt[puzzleGridFreshenKey(gameID: gameID, scope: scope)] = Date() 1943 } 1944 1945 private func puzzleGridFreshenKey(gameID: UUID, scope: CKDatabase.Scope) -> String { 1946 "\(scopeLabel(scope)):\(gameID.uuidString)" 1947 } 1948 1949 private func scopeLabel(_ scope: CKDatabase.Scope) -> String { 1950 switch scope { 1951 case .private: 1952 return "private" 1953 case .shared: 1954 return "shared" 1955 case .public: 1956 return "public" 1957 @unknown default: 1958 return "unknown" 1959 } 1960 } 1961 1962 func makePlayerRoster(for gameID: UUID, preferences: PlayerPreferences) -> PlayerRoster { 1963 PlayerRoster( 1964 gameID: gameID, 1965 authorIdentity: identity, 1966 preferences: preferences, 1967 persistence: persistence, 1968 container: ckContainer, 1969 engagementStore: engagementStore, 1970 tracer: { [syncMonitor] message in syncMonitor.note(message) } 1971 ) 1972 } 1973 1974 private func handleRemoteNotification( 1975 summary: String, 1976 scope: CKDatabase.Scope?, 1977 event: PushPayload.Event?, 1978 gameID: UUID?, 1979 kind: String?, 1980 senderDeviceID: String?, 1981 presenceUntil: Date?, 1982 isBackground: Bool 1983 ) async { 1984 // Authoritative foreground correction. A content-available push is, by 1985 // definition, not the user looking at this app, so when the OS reports 1986 // we're backgrounded, pull the cached `isAppForeground` flag false now. 1987 // `scenePhase`'s `.onChange` — the flag's only other writer — never 1988 // fires for the *initial* phase of a process launched or woken straight 1989 // into the background, so the flag otherwise keeps its optimistic `true` 1990 // default and every background wake slips past the presence and 1991 // engagement foreground gates: re-arming a departed peer's read lease 1992 // (the ghost) and re-dialling the live socket it can't sustain. Only 1993 // ever downgrade here — a genuine foreground is restored by the 1994 // `.active` scenePhase transition, never by a push. 1995 if isBackground { 1996 noteAppForeground(false) 1997 } 1998 guard preferences.isICloudSyncEnabled else { 1999 syncMonitor.note("remote notification ignored while iCloud sync is disabled") 2000 return 2001 } 2002 guard await ensureICloudSyncStarted() else { return } 2003 lastRemoteNotificationAt = Date() 2004 syncMonitor.note("remote notification: \(summary)") 2005 2006 if await handleAccountControlPush( 2007 kind: kind, 2008 gameID: gameID, 2009 senderDeviceID: senderDeviceID, 2010 presenceUntil: presenceUntil 2011 ) { 2012 return 2013 } 2014 2015 if event == .replay { 2016 let label = gameID.map { String($0.uuidString.prefix(8)) } ?? "unknown" 2017 syncMonitor.note("push(replay): syncing game \(label)") 2018 await syncMonitor.run("replay push fetch") { 2019 try await syncEngine.fetchChanges(source: "replay push") 2020 } 2021 await refreshSnapshot() 2022 await reconcilePendingJournalUploads() 2023 return 2024 } 2025 2026 guard let scope, scope != .public else { 2027 await syncMonitor.run("remote-notification fetch") { 2028 try await syncEngine.fetchChanges(source: "push") 2029 } 2030 await refreshSnapshot() 2031 return 2032 } 2033 2034 guard beginRemoteNotificationHandling(scope: scope) else { return } 2035 defer { endRemoteNotificationHandling(scope: scope) } 2036 2037 cancelBackgroundPushCatchUp(scope: scope) 2038 2039 // A share accept is downloading the puzzle asset on the joining screen. 2040 // Don't fan out a session scan, zone discovery, or fetchChanges against 2041 // the shared database while it does — the deferred catch-up runs once 2042 // the burst (and the join) settle. 2043 if scope == .shared, isAcceptingSharedGame { 2044 syncMonitor.note("shared remote notification deferred during share acceptance") 2045 scheduleBackgroundPushCatchUp(scope: scope) 2046 await refreshSnapshot() 2047 return 2048 } 2049 2050 if isBackground { 2051 scheduleBackgroundSessionScan(scope: scope) 2052 scheduleBackgroundPushCatchUp(scope: scope) 2053 await refreshSnapshot() 2054 return 2055 } 2056 2057 if isGameListVisible { 2058 syncMonitor.note("remote notification: game list visible, refreshing list only") 2059 await freshenGameList(scope: scope, reason: .remote) 2060 scheduleBackgroundPushCatchUp(scope: scope) 2061 await refreshSnapshot() 2062 return 2063 } 2064 2065 if let activeGameID = activeGameID(in: scope) { 2066 // Hot path: collaborator activity on the open puzzle. The Puzzle 2067 // Grid surface owns the direct Game/Moves/Player fetch so push 2068 // handling and open-puzzle polling coalesce instead of duplicating 2069 // the same active-zone query. 2070 syncMonitor.note("remote notification: active puzzle visible, refreshing game only") 2071 await freshenPuzzleGrid( 2072 gameID: activeGameID, 2073 scope: scope, 2074 reason: .remote 2075 ) 2076 } else { 2077 // Cold path: no puzzle open. Discover any zones this device 2078 // hasn't seen yet (e.g. a freshly-accepted share or a game 2079 // started on another device of the same iCloud user). The broader 2080 // game/moves catch-up is delayed below so a cold push doesn't fan 2081 // out multiple immediate CloudKit read paths. 2082 await syncMonitor.run("remote-notification zone discovery") { 2083 _ = try await self.syncEngine.discoverNewZonesDirect(scope: scope) 2084 } 2085 scheduleBackgroundPushCatchUp(scope: scope) 2086 } 2087 2088 await refreshSnapshot() 2089 } 2090 2091 private func handleAccountControlPush( 2092 kind: String?, 2093 gameID: UUID?, 2094 senderDeviceID: String?, 2095 presenceUntil: Date? 2096 ) async -> Bool { 2097 guard let kind, 2098 kind == AccountPushCoordinator.accountJoinedPushKind || kind == AccountPushCoordinator.accountSeenPushKind 2099 else { return false } 2100 if senderDeviceID == RecordSerializer.localDeviceID { 2101 syncMonitor.note("push(\(kind)): ignored self-send") 2102 return true 2103 } 2104 guard let gameID else { 2105 syncMonitor.note("push(\(kind)): ignored (no gameID)") 2106 return true 2107 } 2108 2109 switch kind { 2110 case AccountPushCoordinator.accountJoinedPushKind: 2111 syncMonitor.note("push(accountJoined): sibling joined \(gameID.uuidString.prefix(8))") 2112 await syncMonitor.run("account-joined shared discovery") { 2113 try await syncEngine.fetchChanges(source: "account joined") 2114 } 2115 await freshenGameList(scope: .shared, reason: .remote) 2116 await accountPush.reconcilePushRegistration() 2117 await refreshSnapshot() 2118 case AccountPushCoordinator.accountSeenPushKind: 2119 guard let presenceUntil else { 2120 syncMonitor.note("push(accountSeen): ignored (no presenceUntil)") 2121 return true 2122 } 2123 let (previous, adopted) = store.noteIncomingReadCursor(gameID: gameID, presenceUntil: presenceUntil) 2124 if store.isCompletedGameFamily(gameID: gameID) { 2125 // A completed game is immutable. Treat the sibling's explicit 2126 // open as a read receipt immediately, including when retirement 2127 // has already removed the live row and only the Chronicle 2128 // projection remains. The Player.readThrough echo is still the 2129 // durable convergence path while the live zone exists. 2130 store.advanceReadThrough( 2131 gameID: gameID, 2132 through: min(presenceUntil, Date()) 2133 ) 2134 } 2135 syncMonitor.note( 2136 "push(accountSeen): sibling saw \(gameID.uuidString.prefix(8)) " + 2137 "presenceUntil=\(presenceUntil.ISO8601Format()) " + 2138 "was=\(previous?.ISO8601Format() ?? "—")" + 2139 (adopted ? "" : " (no-op)") 2140 ) 2141 // The catch-up baseline is no longer recomputed here — it arrives, 2142 // accurate, on the sibling's `Player.viewedAt` via the 2143 // record sync this push's companion DB change triggers. This fast 2144 // push is now only the cross-device notification-dismissal signal. 2145 // A forward-dated presenceUntil is an active presence lease: the sibling is 2146 // in this game right now and has seen its events live, so retract 2147 // even the unread-marking notifications (win/resign/pause) from this 2148 // device — the "soon-swept" half of sending to every device. A past 2149 // presenceUntil is a plain read watermark (the sibling left), where we still 2150 // preserve genuinely-unread alerts. 2151 let siblingPresent = presenceUntil > Date() 2152 await badge.dismissDeliveredNotifications( 2153 for: gameID, 2154 seenAt: presenceUntil, 2155 publishAccountSeen: false, 2156 preserveUnread: !siblingPresent 2157 ) 2158 default: 2159 break 2160 } 2161 return true 2162 } 2163 2164 /// Retracts a friend's invite-failure banner once that invitation is back 2165 /// in flight, so a successful retry never leaves the old failure standing. 2166 private func dismissInviteFailureAnnouncement(gameID: UUID, friendAuthorID: String) { 2167 announcements.dismiss( 2168 id: InviteDeliveryStore.failureAnnouncementID( 2169 gameID: gameID, 2170 friendAuthorID: friendAuthorID 2171 ) 2172 ) 2173 } 2174 2175 private func activePuzzleGridTarget() -> (UUID, CKDatabase.Scope)? { 2176 guard let entity = store.currentEntity, 2177 let gameID = entity.id, 2178 !store.isGameArchived(entity) 2179 else { return nil } 2180 switch entity.databaseScope { 2181 case 0: 2182 return (gameID, .private) 2183 case 1: 2184 return (gameID, .shared) 2185 default: 2186 return nil 2187 } 2188 } 2189 2190 private func beginRemoteNotificationHandling(scope: CKDatabase.Scope) -> Bool { 2191 switch scope { 2192 case .private: 2193 guard !isHandlingPrivateRemoteNotification else { 2194 syncMonitor.note("private remote notification coalesced into in-flight handler") 2195 return false 2196 } 2197 isHandlingPrivateRemoteNotification = true 2198 return true 2199 case .shared: 2200 guard !isHandlingSharedRemoteNotification else { 2201 syncMonitor.note("shared remote notification coalesced into in-flight handler") 2202 return false 2203 } 2204 isHandlingSharedRemoteNotification = true 2205 return true 2206 case .public: 2207 return false 2208 @unknown default: 2209 return false 2210 } 2211 } 2212 2213 private func endRemoteNotificationHandling(scope: CKDatabase.Scope) { 2214 switch scope { 2215 case .private: 2216 isHandlingPrivateRemoteNotification = false 2217 case .shared: 2218 isHandlingSharedRemoteNotification = false 2219 case .public: 2220 return 2221 @unknown default: 2222 return 2223 } 2224 } 2225 2226 private func cancelBackgroundPushCatchUp(scope: CKDatabase.Scope) { 2227 switch scope { 2228 case .private: 2229 privatePushCatchUpTask?.cancel() 2230 privatePushCatchUpTask = nil 2231 case .shared: 2232 sharedPushCatchUpTask?.cancel() 2233 sharedPushCatchUpTask = nil 2234 case .public: 2235 return 2236 @unknown default: 2237 return 2238 } 2239 } 2240 2241 private func scheduleBackgroundPushCatchUp(scope: CKDatabase.Scope) { 2242 switch scope { 2243 case .private: 2244 privatePushCatchUpTask?.cancel() 2245 privatePushCatchUpTask = makeBackgroundPushCatchUpTask(scope: scope, label: "private") 2246 case .shared: 2247 sharedPushCatchUpTask?.cancel() 2248 sharedPushCatchUpTask = makeBackgroundPushCatchUpTask(scope: scope, label: "shared") 2249 case .public: 2250 return 2251 @unknown default: 2252 return 2253 } 2254 } 2255 2256 /// Trailing-edge window over which a burst of background pushes is collapsed 2257 /// into a single session scan. A collaborator playing live writes a record 2258 /// every second or two; running the full-zone scan per record turned a 2259 /// backgrounded join into minutes of back-to-back fetches. 2260 private static let backgroundSessionScanDebounce: UInt64 = 5_000_000_000 2261 2262 /// Coalesces background-push session scans. Unlike the catch-up scheduler 2263 /// this does *not* cancel a pending scan — under sustained activity a 2264 /// cancel-and-reschedule would push the scan out indefinitely and starve 2265 /// presence. The first push arms a scan; later pushes within the window are 2266 /// no-ops; the task clears its own handle when it runs. 2267 private func scheduleBackgroundSessionScan(scope: CKDatabase.Scope) { 2268 switch scope { 2269 case .private: 2270 guard privateSessionScanTask == nil else { return } 2271 privateSessionScanTask = makeBackgroundSessionScanTask(scope: scope) 2272 case .shared: 2273 guard sharedSessionScanTask == nil else { return } 2274 sharedSessionScanTask = makeBackgroundSessionScanTask(scope: scope) 2275 case .public: 2276 return 2277 @unknown default: 2278 return 2279 } 2280 } 2281 2282 private func clearBackgroundSessionScanTask(scope: CKDatabase.Scope) { 2283 switch scope { 2284 case .private: 2285 privateSessionScanTask = nil 2286 case .shared: 2287 sharedSessionScanTask = nil 2288 case .public: 2289 return 2290 @unknown default: 2291 return 2292 } 2293 } 2294 2295 private func makeBackgroundSessionScanTask(scope: CKDatabase.Scope) -> Task<Void, Never> { 2296 Task { @MainActor in 2297 defer { clearBackgroundSessionScanTask(scope: scope) } 2298 do { 2299 try await Task.sleep(nanoseconds: Self.backgroundSessionScanDebounce) 2300 } catch { 2301 return 2302 } 2303 guard !Task.isCancelled else { return } 2304 guard await ensureICloudSyncStarted() else { return } 2305 let result = await syncMonitor.run("remote-notification background session scan") { 2306 try await syncEngine.fetchBackgroundSessionsDirect(scope: scope) 2307 } 2308 if let result { 2309 // The receiver-side `presentBegins` path is no longer wired 2310 // up. The catch-up banner that summarises peer adds/clears 2311 // still consumes the SessionMonitor buckets via `consumeOnOpen` 2312 // — see `handlePuzzleOpened`. 2313 if result.isEmpty { 2314 syncMonitor.note("remote-notification background session scan: no active sessions") 2315 } 2316 } 2317 await refreshSnapshot() 2318 } 2319 } 2320 2321 private func makeBackgroundPushCatchUpTask( 2322 scope: CKDatabase.Scope, 2323 label: String 2324 ) -> Task<Void, Never> { 2325 syncMonitor.note("\(label) game/moves catch-up scheduled") 2326 return Task { @MainActor in 2327 let shortMoveCount = await runBackgroundPushCatchUp( 2328 scope: scope, 2329 label: label, 2330 delayNanoseconds: 5_000_000_000, 2331 phaseSuffix: "short" 2332 ) 2333 guard !Task.isCancelled else { return } 2334 guard shortMoveCount == 0 else { 2335 syncMonitor.note( 2336 "\(label) game/moves catch-up long skipped after short fetched \(shortMoveCount) move record(s)" 2337 ) 2338 return 2339 } 2340 _ = await runBackgroundPushCatchUp( 2341 scope: scope, 2342 label: label, 2343 delayNanoseconds: 15_000_000_000, 2344 phaseSuffix: "long" 2345 ) 2346 } 2347 } 2348 2349 private func runBackgroundPushCatchUp( 2350 scope: CKDatabase.Scope, 2351 label: String, 2352 delayNanoseconds: UInt64, 2353 phaseSuffix: String 2354 ) async -> Int { 2355 do { 2356 try await Task.sleep(nanoseconds: delayNanoseconds) 2357 } catch { 2358 return 0 2359 } 2360 guard !Task.isCancelled else { return 0 } 2361 guard await ensureICloudSyncStarted() else { return 0 } 2362 guard beginGameListFreshen( 2363 scope: scope, 2364 label: label, 2365 reason: "remote \(phaseSuffix) catch-up" 2366 ) else { 2367 return 0 2368 } 2369 defer { endGameListFreshen(scope: scope) } 2370 2371 let moveCount = await syncMonitor.run("remote-notification \(label) game/moves catch-up \(phaseSuffix)") { 2372 try await syncEngine.fetchKnownGameMovesDirect(scope: scope) 2373 } 2374 await refreshSnapshot() 2375 return moveCount ?? 0 2376 } 2377 2378 private func activeGameID(in scope: CKDatabase.Scope) -> UUID? { 2379 guard let target = activePuzzleGridTarget() else { return nil } 2380 return target.1 == scope ? target.0 : nil 2381 } 2382 2383 private func ensureICloudSyncStarted() async -> Bool { 2384 guard preferences.isICloudSyncEnabled else { return false } 2385 guard !syncStarted else { return true } 2386 if let inFlight = syncStartTask { return await inFlight.value } 2387 2388 let task = Task { @MainActor in 2389 await identity.refresh(using: ckContainer) 2390 pushClient?.updateAuthorID(identity.currentID) 2391 await syncEngine.start() 2392 syncStarted = true 2393 2394 let recoveredMoveCount = await syncEngine.enqueueUnconfirmedMoves() 2395 if recoveredMoveCount > 0 { 2396 syncMonitor.note("recovered \(recoveredMoveCount) unconfirmed move(s) for CloudKit enqueue") 2397 } 2398 isReadyForShareAcceptance = true 2399 await processPendingShareAcceptances() 2400 // Only when this device has nothing cached to derive from is 2401 // `reconcilePushRegistration` about to *mint* a fresh secret/address 2402 // — and minting before a sibling's already-published value has been 2403 // fetched is what enqueues divergent per-game addresses that briefly 2404 // clobber the converged set. `start()` only constructs the engines, 2405 // so fetch the account zone first in exactly that case, letting the 2406 // inbound path (`onAccountPushSecret`) adopt and cache the winner so 2407 // reconcile derives from it instead of minting. On every later 2408 // launch the value is already cached, so this is skipped and startup 2409 // is unchanged. If the fetch throws, `run` swallows it and reconcile 2410 // still runs (no worse than before). 2411 if let authorID = identity.currentID, !authorID.isEmpty, 2412 !accountPush.hasCachedAccountPushCredentials(authorID: authorID) { 2413 await syncMonitor.run("startup account sync") { 2414 try await syncEngine.fetchChanges(source: "startup") 2415 } 2416 } 2417 await accountPush.reconcilePushRegistration() 2418 return true 2419 } 2420 syncStartTask = task 2421 let result = await task.value 2422 syncStartTask = nil 2423 return result 2424 } 2425 2426 /// Parses the silent-push payload into a short, human-readable summary 2427 /// (database scope, notification type, subscription ID, pruned flag). 2428 /// Used by the diagnostics log to confirm whether shared-DB pushes are 2429 /// actually being delivered to the device. 2430 static func describePush(userInfo: [AnyHashable: Any]) -> String { 2431 guard let note = CKNotification(fromRemoteNotificationDictionary: userInfo) else { 2432 let kind = (userInfo["kind"] as? String) ?? "<nil>" 2433 let gameID = (userInfo["gameID"] as? String) ?? "<nil>" 2434 return "custom kind=\(kind) gameID=\(gameID)" 2435 } 2436 let kind: String 2437 let scope: CKDatabase.Scope? 2438 switch note { 2439 case let n as CKDatabaseNotification: 2440 kind = "database" 2441 scope = n.databaseScope 2442 case let n as CKRecordZoneNotification: 2443 kind = "recordZone" 2444 scope = n.databaseScope 2445 case let n as CKQueryNotification: 2446 kind = "query(\(n.queryNotificationReason.rawValue))" 2447 scope = n.databaseScope 2448 default: 2449 kind = "type(\(note.notificationType.rawValue))" 2450 scope = nil 2451 } 2452 let scopeLabel: String 2453 switch scope { 2454 case .private: scopeLabel = "private" 2455 case .shared: scopeLabel = "shared" 2456 case .public: scopeLabel = "public" 2457 case .none: scopeLabel = "n/a" 2458 case .some(let other): scopeLabel = "scope(\(other.rawValue))" 2459 } 2460 let sub = note.subscriptionID ?? "<nil>" 2461 return "scope=\(scopeLabel) kind=\(kind) sub=\(sub) pruned=\(note.isPruned)" 2462 } 2463 2464 static func databaseScope(fromPush userInfo: [AnyHashable: Any]) -> CKDatabase.Scope? { 2465 guard let note = CKNotification(fromRemoteNotificationDictionary: userInfo) else { 2466 return nil 2467 } 2468 switch note { 2469 case let n as CKDatabaseNotification: 2470 return n.databaseScope 2471 case let n as CKRecordZoneNotification: 2472 return n.databaseScope 2473 case let n as CKQueryNotification: 2474 return n.databaseScope 2475 default: 2476 return nil 2477 } 2478 } 2479 2480 private func processPendingShareAcceptances() async { 2481 guard isReadyForShareAcceptance, !isProcessingShareAcceptanceQueue else { return } 2482 isProcessingShareAcceptanceQueue = true 2483 isAcceptingSharedGame = true 2484 defer { 2485 isProcessingShareAcceptanceQueue = false 2486 isAcceptingSharedGame = false 2487 } 2488 2489 while !pendingShareMetadatas.isEmpty { 2490 let metadata = pendingShareMetadatas.removeFirst() 2491 do { 2492 let outcome = try await cloudService.acceptShare(metadata: metadata) 2493 // Accepted but the puzzle hasn't synced in yet — reassure the 2494 // user it's coming, mirroring the link-tap path. 2495 if case .pendingSync = outcome { 2496 announcements.post(.puzzleStillSyncing()) 2497 } 2498 } catch { 2499 // The CloudService already recorded the detailed CloudKit 2500 // failure. The OS route has no caller to present it, so surface 2501 // the accepted-but-unavailable distinction here and keep 2502 // draining the queue. 2503 if error is AcceptedShareError { 2504 let copy = CloudFailureCopy.joinFailure(for: error) 2505 announcements.post(Announcement( 2506 id: "os-share-joined-unavailable", 2507 scope: .global, 2508 severity: .error, 2509 title: copy.title, 2510 body: copy.body, 2511 dismissal: .manual 2512 )) 2513 } 2514 } 2515 } 2516 } 2517 2518 private func refreshSnapshot() async { 2519 let snapshot = await syncEngine.diagnosticSnapshot() 2520 syncMonitor.updateSnapshot(snapshot) 2521 } 2522 2523 /// Publishes this account's read horizon for other-author moves by 2524 /// updating `GameEntity.lastReadOtherMoveAt` and re-enqueuing its Player 2525 /// record. Active puzzle sessions write a future lease and refresh it 2526 /// only when less than `readLeaseRefreshFloor` remains; exits/background 2527 /// write the current time, which can intentionally close that lease. 2528 /// Records the app's foreground/active state. The one place the "user is 2529 /// actively using the app" fact is set; `publishReadCursor` reads it to 2530 /// decide whether a presence-lease renewal is legitimate. 2531 func noteAppForeground(_ foreground: Bool) { 2532 isAppForeground = foreground 2533 } 2534 2535 func publishReadCursor( 2536 for gameID: UUID, 2537 mode: ReadCursorPublishMode = .activeLease, 2538 requireActivePuzzle: Bool = false 2539 ) async { 2540 guard let authorID = identity.currentID, !authorID.isEmpty else { return } 2541 // A read lease asserts "the user is actively present on this puzzle," so 2542 // only a foregrounded app may advance it. A background CKSyncEngine wake 2543 // must never re-arm presence — that is what resurrected a departed 2544 // peer's cursor and held the engagement room open. The `.currentTime` 2545 // collapse is always allowed: we must be able to *end* the lease on the 2546 // way to the background. 2547 if case .activeLease = mode, !isAppForeground { 2548 syncMonitor.note("lease(activeLease) skipped for \(gameID.uuidString): backgrounded") 2549 return 2550 } 2551 // A re-assert triggered by an inbound sibling close (`requireActivePuzzle`) 2552 // is decided before an `await`, so the user may have left the puzzle in 2553 // the gap. Re-check here, synchronously in the write's critical section, 2554 // that this is still the puzzle on screen. The strict `activePuzzleID` 2555 // (no leave-grace tail) flips synchronously on `.onDisappear`/`.background`, 2556 // so a concurrent leave deterministically wins and we never strand a 2557 // future lease on a puzzle no device is actually viewing. 2558 if case .activeLease = mode, 2559 requireActivePuzzle, 2560 NotificationState.activePuzzleID() != gameID { 2561 syncMonitor.note("lease(activeLease) skipped for \(gameID.uuidString): not active puzzle") 2562 return 2563 } 2564 let now = Date() 2565 let didUpdate: Bool 2566 switch mode { 2567 case .activeLease: 2568 let presenceUntil = now.addingTimeInterval(Self.readLeaseDuration) 2569 didUpdate = store.setReadCursor( 2570 gameID: gameID, 2571 presenceUntil: presenceUntil, 2572 minimumExistingPresenceUntil: now.addingTimeInterval(Self.readLeaseRefreshFloor) 2573 ) 2574 if didUpdate { 2575 // Ghost-peer probe: every active-session lease passes through 2576 // here. `suppressed` is "is this device actually viewing this 2577 // puzzle right now." A mint with suppressed=false is a presence 2578 // lease asserted while the user isn't looking — the resurrection 2579 // — and the foreground flag tells us which gate let it through. 2580 syncMonitor.note( 2581 "lease MINT[\(gameID.uuidString.prefix(8))] " + 2582 "presenceUntil=\(presenceUntil.ISO8601Format()) foreground=\(isAppForeground) " + 2583 "suppressed=\(NotificationState.isSuppressed(gameID: gameID))" 2584 ) 2585 // Mirror the renewed lease into the badge ledger so an NSE 2586 // push landing mid-session stays suppressed past the open's 2587 // initial horizon (the open stamps one via 2588 // `dismissDeliveredNotifications`; this covers the refreshes). 2589 BadgeState.adoptReadHorizon(gameID: gameID, horizon: presenceUntil) 2590 await accountPush.publishAccountSeenPush(gameID: gameID, presenceUntil: presenceUntil) 2591 } 2592 case .currentTime: 2593 // Leaving / backgrounding: collapse the presence lease to now and, 2594 // in lockstep, advance the read watermark to now — the user was 2595 // looking right up to here, so they've seen everything through now. 2596 // The watermark write is what stops a peer re-summarising moves we 2597 // saw live just before leaving; it never reaches into the future. 2598 let collapsed = store.setReadCursor(gameID: gameID, presenceUntil: now) 2599 let advanced = store.advanceReadThrough(gameID: gameID, through: now) 2600 // Collapse the badge ledger's suppression horizon in the same 2601 // lockstep. This write is local (App Group defaults), so it lands 2602 // even when the CloudKit lease collapse doesn't — an NSE push 2603 // arriving a minute after leave badges instead of being swallowed 2604 // for the rest of the lease window. 2605 BadgeState.markSeen(gameID: gameID, at: now) 2606 BadgeState.collapseSuppression(gameID: gameID, to: now) 2607 didUpdate = collapsed || advanced 2608 } 2609 guard didUpdate else { return } 2610 let reason: String 2611 let drain: Bool 2612 switch mode { 2613 case .activeLease: 2614 reason = "lease(activeLease)" 2615 drain = true 2616 case .currentTime: 2617 // Exit/background cursor: enqueue durably but don't force a send. 2618 // Live presence rides the engagement socket; CloudKit carries the 2619 // cursor on its own schedule, off the scarce suspension budget. 2620 reason = "lease(currentTime)" 2621 drain = false 2622 } 2623 await syncEngine.enqueuePlayer( 2624 gameID: gameID, 2625 authorID: authorID, 2626 reason: reason, 2627 drain: drain 2628 ) 2629 } 2630 2631 /// Diagnostic: logs each participant's effective `Player.presenceUntil` lease 2632 /// for `gameID` at open, so a lingering peer cursor or engagement room can be 2633 /// reasoned about from the device log alone. Peer leases come from their 2634 /// received Player rows; the local lease comes from `GameEntity`, matching the 2635 /// source `RecordBuilder` writes into the outgoing Player record. One line per 2636 /// player: self/peer, author prefix, name, the raw `presenceUntil` (UTC), and 2637 /// whether it currently reads as present (`+Ns` until expiry) or lapsed 2638 /// (`Ns ago`). 2639 func logPlayerLeaseSnapshot(gameID: UUID) async { 2640 let localAuthorID = identity.currentID 2641 let context = persistence.container.newBackgroundContext() 2642 let lines: [String] = await withCheckedContinuation { continuation in 2643 context.perform { 2644 let req = NSFetchRequest<PlayerEntity>(entityName: "PlayerEntity") 2645 req.predicate = NSPredicate(format: "game.id == %@", gameID as CVarArg) 2646 let now = Date() 2647 let players = (try? context.fetch(req)) ?? [] 2648 let lines = players.map { player -> String in 2649 let author = player.authorID ?? "?" 2650 let isLocal = author == CKCurrentUserDefaultName 2651 || (localAuthorID.map { author == $0 } ?? false) 2652 let tag = isLocal ? "self" : "peer" 2653 let name = (player.name?.isEmpty == false) ? player.name! : "—" 2654 let presenceUntil = isLocal 2655 ? player.game?.lastReadOtherMoveAt 2656 : player.presenceUntil 2657 guard let presenceUntil else { 2658 return "\(tag) \(author.prefix(8)) [\(name)] presenceUntil=nil" 2659 } 2660 let delta = Int(presenceUntil.timeIntervalSince(now)) 2661 let state = delta > 0 ? "present, +\(delta)s" : "absent, \(-delta)s ago" 2662 return "\(tag) \(author.prefix(8)) [\(name)] presenceUntil=\(presenceUntil.ISO8601Format()) (\(state))" 2663 } 2664 continuation.resume(returning: lines) 2665 } 2666 } 2667 syncMonitor.note("open lease snapshot \(gameID.uuidString.prefix(8)): \(lines.count) player(s)") 2668 for line in lines { 2669 syncMonitor.note(" \(line)") 2670 } 2671 } 2672 2673 /// Builds the `GameStore.onGameDeleted` callback. Extracted so tests can 2674 /// drive the exact same closure that production wires up — keeps the 2675 /// cursor-cleanup branch from drifting silently. (Friend colours need no 2676 /// cleanup: they are derived on the fly, never persisted per game.) 2677 static func makeOnGameDeleted( 2678 syncEngine: SyncEngine, 2679 cursorStore: GameCursorStore? = nil, 2680 viewedStore: GameViewedStore? = nil 2681 ) -> (GameCloudDeletion) -> Void { 2682 { deletion in 2683 cursorStore?.clearCursor(forGame: deletion.gameID) 2684 viewedStore?.clearLastViewed(forGame: deletion.gameID) 2685 Task { await syncEngine.enqueueDeleteGame(deletion) } 2686 } 2687 } 2688 2689 /// True iff some non-local participant in `gameID` currently holds a 2690 /// valid read lease (`presenceUntil` in the future). The active-lease cursor — 2691 /// set ~10 minutes ahead while the puzzle is open and collapsed to `now` 2692 /// on leave — is the presence signal: it survives think-time without 2693 /// cursor movement and self-expires if a peer vanishes uncleanly, so a 2694 /// solo solver in a shared puzzle stops treating a departed peer as 2695 /// present within the lease window rather than on every paused minute. 2696 static func hasPresentPeer( 2697 persistence: PersistenceController, 2698 gameID: UUID, 2699 localAuthorID: String? 2700 ) async -> Bool { 2701 let context = persistence.container.newBackgroundContext() 2702 return await withCheckedContinuation { continuation in 2703 context.perform { 2704 let now = Date() 2705 let req = NSFetchRequest<PlayerEntity>(entityName: "PlayerEntity") 2706 req.predicate = NSPredicate( 2707 format: "game.id == %@ AND presenceUntil > %@", 2708 gameID as CVarArg, 2709 PeerPresence.presenceCutoff(asOf: now) as NSDate 2710 ) 2711 let players = (try? context.fetch(req)) ?? [] 2712 let hasPeer = players.contains { player in 2713 guard let authorID = player.authorID, !authorID.isEmpty else { return false } 2714 if authorID == CKCurrentUserDefaultName { return false } 2715 if let localAuthorID, !localAuthorID.isEmpty, authorID == localAuthorID { return false } 2716 return PeerPresence.isPresent(presenceUntil: player.presenceUntil, asOf: now) 2717 } 2718 continuation.resume(returning: hasPeer) 2719 } 2720 } 2721 } 2722 2723 /// The earliest future `presenceUntil` among non-local participants in `gameID` — 2724 /// i.e. when the soonest peer lease lapses — or `nil` if no peer is 2725 /// present. `nil` is the same condition `hasPresentPeer` reports as absent, 2726 /// so callers can derive presence from this and also schedule against the 2727 /// expiry instant. 2728 static func soonestPeerLease( 2729 persistence: PersistenceController, 2730 gameID: UUID, 2731 localAuthorID: String? 2732 ) async -> Date? { 2733 let context = persistence.container.newBackgroundContext() 2734 return await withCheckedContinuation { continuation in 2735 context.perform { 2736 let now = Date() 2737 let req = NSFetchRequest<PlayerEntity>(entityName: "PlayerEntity") 2738 req.predicate = NSPredicate( 2739 format: "game.id == %@ AND presenceUntil > %@", 2740 gameID as CVarArg, 2741 PeerPresence.presenceCutoff(asOf: now) as NSDate 2742 ) 2743 var soonest: Date? 2744 for player in (try? context.fetch(req)) ?? [] { 2745 guard let authorID = player.authorID, !authorID.isEmpty else { continue } 2746 if authorID == CKCurrentUserDefaultName { continue } 2747 if let localAuthorID, !localAuthorID.isEmpty, authorID == localAuthorID { continue } 2748 guard let presenceUntil = player.presenceUntil, 2749 PeerPresence.isPresent(presenceUntil: presenceUntil, asOf: now) else { continue } 2750 if soonest == nil || presenceUntil < soonest! { soonest = presenceUntil } 2751 } 2752 continuation.resume(returning: soonest) 2753 } 2754 } 2755 } 2756 2757 static func presentPeers( 2758 persistence: PersistenceController, 2759 gameIDs: Set<UUID>?, 2760 localAuthorID: String? 2761 ) async -> [UUID: [String]] { 2762 let context = persistence.container.newBackgroundContext() 2763 return await withCheckedContinuation { continuation in 2764 context.perform { 2765 let now = Date() 2766 let req = NSFetchRequest<PlayerEntity>(entityName: "PlayerEntity") 2767 var predicates = [ 2768 NSPredicate(format: "presenceUntil > %@", PeerPresence.presenceCutoff(asOf: now) as NSDate) 2769 ] 2770 if let gameIDs, !gameIDs.isEmpty { 2771 predicates.append(NSPredicate(format: "game.id IN %@", Array(gameIDs))) 2772 } 2773 if let localAuthorID, !localAuthorID.isEmpty { 2774 predicates.append(NSPredicate(format: "authorID != %@", localAuthorID)) 2775 } 2776 req.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: predicates) 2777 2778 var result: [UUID: Set<String>] = [:] 2779 for player in (try? context.fetch(req)) ?? [] { 2780 guard let gameID = player.game?.id, 2781 let authorID = player.authorID, 2782 !authorID.isEmpty, 2783 authorID != CKCurrentUserDefaultName, 2784 PeerPresence.isPresent(presenceUntil: player.presenceUntil, asOf: now) else { continue } 2785 result[gameID, default: []].insert(authorID) 2786 } 2787 continuation.resume(returning: result.mapValues { Array($0) }) 2788 } 2789 } 2790 } 2791 2792 }