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