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