SyncEngine.swift (111338B)
1 import CloudKit 2 import CoreData 3 import Foundation 4 import SwiftUI 5 6 extension EnvironmentValues { 7 @Entry var syncEngine: SyncEngine? = nil 8 @Entry var resetDatabase: (() async -> Void)? = nil 9 /// `(gameID, friendAuthorID)` — re-invites an existing friend to a game. 10 @Entry var inviteFriend: ((UUID, String) async throws -> Void)? = nil 11 /// `(shareURL, pingRecordName)` — accepts a pending game invite. 12 @Entry var acceptInvite: ((String, String) async throws -> Void)? = nil 13 /// `(gameID)` — declines a pending game invite (tombstone + badge refresh). 14 @Entry var declineInvite: ((UUID) async throws -> Void)? = nil 15 /// `(friendAuthorID)` — blocks a collaborator: suppress future invites, 16 /// leave their shared games, tear down the friend zone. 17 @Entry var blockFriend: ((String) async -> Void)? = nil 18 /// `(friendAuthorID, nickname)` — sets the user's private nickname for a 19 /// friend; an empty nickname clears it back to the friend's own name. 20 @Entry var renameFriend: ((String, String) async -> Void)? = nil 21 /// `(gameID)` — fires the completion APN after a game is resigned from 22 /// the library (the in-puzzle path uses `onResign` directly). 23 @Entry var sendResignPings: ((UUID) async -> Void)? = nil 24 } 25 26 extension Notification.Name { 27 /// Posted by `SyncEngine` after applying fetched record zone changes that 28 /// touched a game's roster-relevant state — a Player record, a Game record, 29 /// a deletion, or a new contributor's first Moves row (see 30 /// `BatchEffects.rosterRelevant`). `userInfo["gameIDs"]` is a `Set<UUID>`. 31 /// `PlayerRoster` observes this to refresh in response to remote name / 32 /// cursor updates and new participants joining. Repeat Moves from a known 33 /// contributor (peer letters) are excluded — they don't change the roster. 34 static let playerRosterShouldRefresh = Notification.Name("playerRosterShouldRefresh") 35 36 /// Posted by `SyncEngine` when an inbound `Journal` record lands for one or 37 /// more games. `userInfo["gameIDs"]` is a `Set<UUID>`. The finish-banner 38 /// replay observes this to re-check completeness the moment a contributor's 39 /// journal syncs, instead of polling. 40 static let replayJournalDidSync = Notification.Name("replayJournalDidSync") 41 } 42 43 44 /// Owns the CloudKit sync lifecycle via two `CKSyncEngine` instances — one for 45 /// the private database (owned games and shares) and one for the shared 46 /// database (joined games). Zone creation, subscription setup, change-token 47 /// management, batching, and retry are all delegated to the framework. 48 /// This actor's job is to: 49 /// 50 /// - Start and persist each engine's state across launches. 51 /// - Translate outbound edits (from `MovesUpdater`) into pending record zone 52 /// changes that CKSyncEngine will batch and send. 53 /// - Apply incoming `Moves`, `Game`, and `Player` records to Core Data and 54 /// replay them onto the `CellEntity` cache. 55 /// - Notify the main actor so the in-memory `Game` stays current. 56 actor SyncEngine { 57 let container: CKContainer 58 let persistence: PersistenceController 59 60 var privateEngine: CKSyncEngine? 61 var sharedEngine: CKSyncEngine? 62 63 /// In-memory map for Ping records pending send. Pings have no Core Data 64 /// backing — they're write-once-and-forget — so we stash the minimal data 65 /// here keyed by record name and look it up in `buildRecord`. 66 private var pendingPings: [String: PingPayload] = [:] 67 /// Payloads for `Decision` records pending send, keyed by 68 /// `decisionStateKey` (zone + record name — the same decision record can 69 /// be pending in several zones at once, e.g. a name Decision fanned out 70 /// to every friend zone, and one zone's save must not strip the others'). 71 /// Unlike a ping body, these must survive an app kill: CKSyncEngine persists 72 /// the pending `.saveRecord`, so on relaunch `buildRecord` would otherwise 73 /// emit a payload-less Decision (e.g. an empty `pushSecret`) that uploads as 74 /// a poison record other devices can't parse and this device never 75 /// republishes. Mirrored to UserDefaults on every mutation and restored in 76 /// `start()`. 77 private var pendingDecisionPayloads: [String: String] = [:] 78 79 private static let pendingDecisionPayloadsDefaultsKey = 80 "SyncEngine.pendingDecisionPayloads" 81 82 /// Intended generation for a versioned `Decision` pending send, keyed by 83 /// `decisionStateKey`. Mirrors `pendingDecisionPayloads`' lifecycle and 84 /// durability: the version must survive an app kill so a rebuilt rotation 85 /// re-asserts at the right generation rather than a stale one. The push 86 /// secret and display name are versioned; unversioned decisions 87 /// (block/left/pushAddress) have no entry and stay write-once on conflict. 88 private var pendingDecisionVersions: [String: Int64] = [:] 89 90 private static let pendingDecisionVersionsDefaultsKey = 91 "SyncEngine.pendingDecisionVersions" 92 93 /// Server system fields adopted while recovering a *versioned* Decision that 94 /// lost the change-tag race but won on version — stashed so the next 95 /// `buildRecord` carries the server's tag and the overwrite is accepted 96 /// rather than re-colliding. In-memory only: the version in 97 /// `pendingDecisionVersions` carries correctness across a relaunch (a 98 /// tagless re-send simply re-hits the conflict and re-recovers), so this is 99 /// a round-trip optimization, not durable state. Keyed by 100 /// `decisionStateKey` — the same record name carries a different change 101 /// tag in each zone it lives in. Cleared once the record saves or settles. 102 private var decisionSystemFields: [String: Data] = [:] 103 104 /// Key for the per-zone decision state above. A decision's record name is 105 /// deterministic, so the same name can be pending in the account zone and 106 /// several friend zones simultaneously; the zone name disambiguates. 107 nonisolated static func decisionStateKey(_ recordID: CKRecord.ID) -> String { 108 "\(recordID.zoneID.zoneName)/\(recordID.recordName)" 109 } 110 111 struct PingPayload { 112 let gameID: UUID 113 let authorID: String 114 let deviceID: String 115 let playerName: String 116 let puzzleTitle: String 117 let eventTimestampMs: Int64 118 let kind: PingKind 119 let payload: String? 120 let addressee: String? 121 } 122 123 /// Label for the in-flight fetch — surfaced in traces so the diagnostics 124 /// log can distinguish push-driven fetches from polls / foreground / etc. 125 /// `nil` means CKSyncEngine drove the fetch itself (its internal scheduler). 126 private var currentFetchSource: String? 127 /// One-shot flag — set the first time we observe shared-DB content 128 /// arriving via a push-triggered fetch. Confirms the silent-push path is 129 /// actually wired up end-to-end. 130 private var loggedFirstSharedPushPayload = false 131 132 var onRemoteMovesUpdated: (@MainActor @Sendable (Set<UUID>) async -> Void)? 133 /// Fires with the game IDs for which a collaborator's `Player` record was 134 /// seen for the **first time** (a new `PlayerEntity` was created) — not on 135 /// their subsequent name / cursor updates. Independent of moves; the 136 /// friendship bootstrap keys off this so a collaborator becomes a friend 137 /// once, as soon as their identity syncs, without waiting for a move. 138 var onRemotePlayersUpdated: (@MainActor @Sendable (Set<UUID>) async -> Void)? 139 /// Fires when a remote collaborator's `Player` record updates active 140 /// presence state (selection set/cleared or refreshed). Unlike 141 /// `onRemotePlayersUpdated`, this fires for existing Player records too, 142 /// so live engagement can start when a known collaborator opens a puzzle. 143 var onRemotePlayerPresenceChanged: (@MainActor @Sendable (Set<UUID>) async -> Void)? 144 /// Fires with the game IDs whose Game-record `engagement` creds just 145 /// changed (a peer minted or rotated the room). Drives the receiver to 146 /// reconcile its live connection toward the new creds. 147 var onRemoteEngagementChanged: (@MainActor @Sendable (Set<UUID>) async -> Void)? 148 var onPings: (@MainActor @Sendable ([Ping]) async -> Void)? 149 private var onAccountChange: (@MainActor @Sendable () async -> Void)? 150 private var onGameAccessRevoked: (@MainActor @Sendable (UUID) async -> Void)? 151 private var onGameRemoved: (@MainActor @Sendable (UUID) async -> Void)? 152 /// Fires when an inbound Game record transitions a local row to completed. 153 /// App-level side effects that are not sync-engine state (for example 154 /// closing public share tickets) hang off this edge. 155 var onGameCompleted: (@MainActor @Sendable (UUID) async -> Void)? 156 /// Fires with the game ID of a shared zone that just appeared locally — 157 /// the user joined the game here or on a sibling device. Drives cleanup 158 /// of the now-redundant pending invite row. 159 private var onGameJoined: (@MainActor @Sendable (UUID) async -> Void)? 160 /// Fires with Ping records that were just deleted on the server (a sibling 161 /// device consumed a directed ping). Drives cross-device withdrawal of the 162 /// notification and any local durable invite row this device may hold. 163 var onPingDeleted: (@MainActor @Sendable ([(recordName: String, gameID: UUID)]) async -> Void)? 164 /// Fires with (gameID, readAt) pairs lifted from inbound Player records 165 /// whose authorID matches the local user. A sibling device has recorded 166 /// the account's read horizon; active sessions may move it into the near 167 /// future and later close it with a lower current-time value. 168 var onIncomingReadCursor: (@MainActor @Sendable ([(UUID, Date, Data?)]) async -> Void)? 169 var onAccountPushAddress: (@MainActor @Sendable (String) async -> Void)? 170 var onAccountPushSecret: (@MainActor @Sendable (String, Int64) async -> Void)? 171 private var localAuthorIDProvider: (@MainActor @Sendable () -> String?)? 172 private var tracer: (@MainActor @Sendable (String) -> Void)? 173 /// Fires when a delegate event reports a successful round-trip with the 174 /// CloudKit server (fetched DB changes, fetched zone changes, or sent 175 /// zone changes with no failures). Bumps `Last Success` in the 176 /// diagnostics view so the timestamp reflects actual engine activity 177 /// rather than only instrumented phases run through `SyncMonitor.run`. 178 private var successCheckpoint: (@MainActor @Sendable () -> Void)? 179 var liveQueryCheckpoints: [String: Date] = [:] 180 let liveQueryCheckpointOverlap: TimeInterval = 5 181 182 /// Per-scope checkpoint for the background ping fast path. Independent of 183 /// CKSyncEngine's change tokens and of `liveQueryCheckpoints` (which are 184 /// per-zone and Moves/Player oriented). Keyed by databaseScope value 185 /// (0 = private, 1 = shared). 186 var pingPushCheckpoints: [Int16: Date] = [:] 187 let pingPushCheckpointOverlap: TimeInterval = 30 188 /// Per-scope record-name → modificationDate of Ping records already 189 /// surfaced by the fast path. The time-window query deliberately re-fetches 190 /// anything within `pingPushCheckpointOverlap` of the floor (skew safety), 191 /// so without this a record — unboundedly, the newest one — would re-emit 192 /// on every push. Pruned to the overlap window each scan, so it stays 193 /// small. Mirrors the presentation-layer dedupe in `NotificationState`. 194 var seenPingRecords: [Int16: [String: Date]] = [:] 195 let backgroundSessionLookback: TimeInterval = 10 * 60 196 197 func setTracer(_ t: @MainActor @Sendable @escaping (String) -> Void) { 198 tracer = t 199 } 200 201 func setSuccessCheckpoint(_ cb: @MainActor @Sendable @escaping () -> Void) { 202 successCheckpoint = cb 203 } 204 205 private func noteRoundTripSuccess() async { 206 guard let successCheckpoint else { return } 207 await successCheckpoint() 208 } 209 210 func setOnRemoteMovesUpdated(_ cb: @MainActor @Sendable @escaping (Set<UUID>) async -> Void) { 211 onRemoteMovesUpdated = cb 212 } 213 214 func setOnRemotePlayersUpdated(_ cb: @MainActor @Sendable @escaping (Set<UUID>) async -> Void) { 215 onRemotePlayersUpdated = cb 216 } 217 218 func setOnRemotePlayerPresenceChanged(_ cb: @MainActor @Sendable @escaping (Set<UUID>) async -> Void) { 219 onRemotePlayerPresenceChanged = cb 220 } 221 222 func setOnRemoteEngagementChanged(_ cb: @MainActor @Sendable @escaping (Set<UUID>) async -> Void) { 223 onRemoteEngagementChanged = cb 224 } 225 226 func setOnPings(_ cb: @MainActor @Sendable @escaping ([Ping]) async -> Void) { 227 onPings = cb 228 } 229 230 func setOnAccountChange(_ cb: @MainActor @Sendable @escaping () async -> Void) { 231 onAccountChange = cb 232 } 233 234 func setOnGameAccessRevoked(_ cb: @MainActor @Sendable @escaping (UUID) async -> Void) { 235 onGameAccessRevoked = cb 236 } 237 238 func setOnGameRemoved(_ cb: @MainActor @Sendable @escaping (UUID) async -> Void) { 239 onGameRemoved = cb 240 } 241 242 func setOnGameCompleted(_ cb: @MainActor @Sendable @escaping (UUID) async -> Void) { 243 onGameCompleted = cb 244 } 245 246 func setOnGameJoined(_ cb: @MainActor @Sendable @escaping (UUID) async -> Void) { 247 onGameJoined = cb 248 } 249 250 func setOnPingDeleted( 251 _ cb: @MainActor @Sendable @escaping ([(recordName: String, gameID: UUID)]) async -> Void 252 ) { 253 onPingDeleted = cb 254 } 255 256 func setOnIncomingReadCursor(_ cb: @MainActor @Sendable @escaping ([(UUID, Date, Data?)]) async -> Void) { 257 onIncomingReadCursor = cb 258 } 259 260 func setOnAccountPushAddress(_ cb: @MainActor @Sendable @escaping (String) async -> Void) { 261 onAccountPushAddress = cb 262 } 263 264 func setOnAccountPushSecret(_ cb: @MainActor @Sendable @escaping (String, Int64) async -> Void) { 265 onAccountPushSecret = cb 266 } 267 268 func setLocalAuthorIDProvider(_ cb: @MainActor @Sendable @escaping () -> String?) { 269 localAuthorIDProvider = cb 270 } 271 272 init(container: CKContainer, persistence: PersistenceController) { 273 self.container = container 274 self.persistence = persistence 275 } 276 277 // MARK: - Lifecycle 278 279 /// Database subscription IDs. Stable, per-scope, idempotent on re-creation 280 /// so we can always attempt a save without first listing. 281 private static let privateSubscriptionID = "crossmate-private-db-subscription" 282 private static let sharedSubscriptionID = "crossmate-shared-db-subscription" 283 284 /// Creates both `CKSyncEngine` instances, restoring previously-saved state 285 /// so pending changes and change tokens survive restarts. Call once after 286 /// wiring callbacks. Idempotent — extra calls are no-ops so a race between 287 /// `services.start()` and a scene-active foreground sync can't double- 288 /// initialise the engines or re-fire subscription setup. 289 func start() async { 290 guard privateEngine == nil, sharedEngine == nil else { return } 291 292 // CKSyncEngine restores its pending `.saveRecord` changes from the 293 // serialized state below; restore the matching Decision payloads so a 294 // pending decision rebuilds with its body instead of as a poison record. 295 restorePendingDecisionPayloads() 296 restorePendingDecisionVersions() 297 298 let bgCtx = persistence.container.newBackgroundContext() 299 300 let privateData: Data? = bgCtx.performAndWait { 301 SyncStateEntity.current(in: bgCtx).ckPrivateEngineState 302 } 303 let privateState = await decodeEngineState(privateData, label: "private") 304 privateEngine = CKSyncEngine(CKSyncEngine.Configuration( 305 database: container.privateCloudDatabase, 306 stateSerialization: privateState, 307 delegate: self 308 )) 309 310 let sharedData: Data? = bgCtx.performAndWait { 311 SyncStateEntity.current(in: bgCtx).ckSharedEngineState 312 } 313 let sharedState = await decodeEngineState(sharedData, label: "shared") 314 sharedEngine = CKSyncEngine(CKSyncEngine.Configuration( 315 database: container.sharedCloudDatabase, 316 stateSerialization: sharedState, 317 delegate: self 318 )) 319 320 // CKSyncEngine's automatic subscription creation is unreliable in 321 // practice — diagnostics on real devices showed both scopes with zero 322 // subscriptions even after a healthy initial fetch and push, which 323 // means CloudKit never fires pushes and the engine silently degrades 324 // to its periodic poll. Create the database subscriptions ourselves; 325 // CKDatabase.save is idempotent for an existing subscriptionID. 326 Task { await ensureDatabaseSubscriptions() } 327 Task { await purgeLegacyLeasePings_v1() } 328 Task { await purgeLegacyInvitePings_v1() } 329 Task { await purgeStaleHailPings_v1() } 330 Task { await purgeDebugPreviewFriends_v1() } 331 Task { await purgeLegacyPlayPings_v1() } 332 } 333 334 private func ensureDatabaseSubscriptions() async { 335 await ensureDatabaseSubscription( 336 database: container.privateCloudDatabase, 337 subscriptionID: Self.privateSubscriptionID, 338 label: "private" 339 ) 340 await ensureDatabaseSubscription( 341 database: container.sharedCloudDatabase, 342 subscriptionID: Self.sharedSubscriptionID, 343 label: "shared" 344 ) 345 } 346 347 private func ensureDatabaseSubscription( 348 database: CKDatabase, 349 subscriptionID: String, 350 label: String 351 ) async { 352 do { 353 let existing = try await database.allSubscriptions() 354 if existing.contains(where: { $0.subscriptionID == subscriptionID }) { 355 await trace("\(label) subscription already present (\(subscriptionID))") 356 return 357 } 358 } catch { 359 await trace("\(label) allSubscriptions probe failed: \(describe(error)) — attempting save anyway") 360 } 361 let subscription = CKDatabaseSubscription(subscriptionID: subscriptionID) 362 let info = CKSubscription.NotificationInfo() 363 info.shouldSendContentAvailable = true 364 subscription.notificationInfo = info 365 do { 366 _ = try await database.save(subscription) 367 await trace("\(label) subscription created (\(subscriptionID))") 368 } catch { 369 await trace("\(label) subscription save FAILED: \(describe(error))") 370 } 371 } 372 373 /// Kicks CKSyncEngine's outbound drain after pending state has been queued. 374 /// This must be detached: several enqueue paths are reachable from 375 /// CKSyncEngine delegate callbacks, and a plain `Task {}` can inherit the 376 /// callback's executor and re-enter CKSyncEngine before the callback 377 /// unwinds, tripping CloudKit's serialization guard. 378 private func sendChangesDetached(on engine: CKSyncEngine) { 379 Task.detached { [engine] in try? await engine.sendChanges() } 380 } 381 382 /// Per-scope burst depth. `enqueuePlayer` consults this — while 383 /// non-zero for a scope, it records that a drain is owed instead of 384 /// firing `sendChanges` immediately. The outermost frame issues one 385 /// `sendChanges` on exit if any enqueue landed during the burst. 386 /// Replaces an earlier time-window coalesce that had to be tuned to 387 /// match `PlayerSelectionPublisher`'s debounce: the open path now 388 /// explicitly fans out read cursor, name, and the initial selection 389 /// inside a burst, so the batch is bounded by the work that produces 390 /// it rather than by a wall-clock window. 391 private var playerSendBurstDepth: [Int16: Int] = [:] 392 private var playerSendBurstPending: Set<Int16> = [] 393 394 /// Opens a Player-record send burst for `gameID`'s scope. Subsequent 395 /// `enqueuePlayer` calls on that scope skip their immediate 396 /// drain; the caller must pair this with `endPlayerSendBurst(scope:)`, 397 /// which fires one `sendChanges` if any enqueue landed in between. 398 /// Returns the scope so the caller can close the matching burst even 399 /// if the game's zone routing changes after the call. 400 func beginPlayerSendBurst(gameID: UUID) -> Int16? { 401 let ctx = persistence.container.newBackgroundContext() 402 guard let info = zoneInfo(forGameID: gameID, in: ctx) else { return nil } 403 playerSendBurstDepth[info.scope, default: 0] += 1 404 return info.scope 405 } 406 407 /// Closes a burst opened by `beginPlayerSendBurst`. The outermost 408 /// frame drains the affected engine if any enqueue landed during the 409 /// burst; nested frames just decrement the counter. 410 func endPlayerSendBurst(scope: Int16) { 411 guard let depth = playerSendBurstDepth[scope], depth > 0 else { return } 412 if depth > 1 { 413 playerSendBurstDepth[scope] = depth - 1 414 return 415 } 416 playerSendBurstDepth.removeValue(forKey: scope) 417 guard playerSendBurstPending.remove(scope) != nil else { return } 418 let engine = scope == 1 ? sharedEngine : privateEngine 419 guard let engine else { return } 420 sendChangesDetached(on: engine) 421 } 422 423 // MARK: - Outbound 424 425 /// Registers each game's local-device Moves record as a pending save, 426 /// routed per-game to the correct engine. Called by the `MovesUpdater` 427 /// sink after the device's `MovesEntity` row has been merged and persisted. 428 /// 429 /// `drain` controls whether to force an eager `sendChanges()`: 430 /// 431 /// - `false` (live typing debounce): the engagement socket already carries 432 /// the letters via `cellEdit`/`cellEditBatch`, so CloudKit is the durable 433 /// backstop ("appears on sync"), not the live path. With no peer watching 434 /// the durable write in real time, leaving the drain to CKSyncEngine's 435 /// automatic scheduler lets it coalesce a typing burst into far fewer 436 /// round-trips and avoids the self-induced oplock races two concurrent 437 /// drains produced over the same `CKRecord.ID`. 438 /// - `true` (explicit `flush()` on leave/background, and recovery via 439 /// `enqueueUnconfirmedMoves`): the solver's final letters must reach 440 /// CloudKit promptly even when no peer is on the socket, so force the 441 /// send rather than waiting for the next foreground. 442 func enqueueMoves(gameIDs: Set<UUID>, drain: Bool = true) async { 443 guard !gameIDs.isEmpty else { return } 444 // The local-device row is matched by author as well as device: after 445 // an iCloud account switch this device's old rows persist under the 446 // previous authorID (the record name embeds the author, so they are 447 // distinct rows), and a device-only `fetchLimit = 1` lookup could pick 448 // the old account's record and push a save the user no longer owns. 449 // When no author is known yet (provider unset, first launch) fall back 450 // to the device-only match, which is unambiguous until a switch has 451 // happened. 452 let localAuthorID = await currentLocalAuthorID() 453 let ctx = persistence.container.newBackgroundContext() 454 let (privateRecordIDs, sharedRecordIDs): ([CKRecord.ID], [CKRecord.ID]) = ctx.performAndWait { 455 var privateIDs: [CKRecord.ID] = [] 456 var sharedIDs: [CKRecord.ID] = [] 457 for gameID in gameIDs { 458 guard let info = zoneInfo(forGameID: gameID, in: ctx) else { continue } 459 let isShared = info.scope == 1 460 let req = NSFetchRequest<MovesEntity>(entityName: "MovesEntity") 461 if let localAuthorID, !localAuthorID.isEmpty { 462 req.predicate = NSPredicate( 463 format: "game.id == %@ AND deviceID == %@ AND authorID == %@", 464 gameID as CVarArg, 465 RecordSerializer.localDeviceID, 466 localAuthorID 467 ) 468 } else { 469 req.predicate = NSPredicate( 470 format: "game.id == %@ AND deviceID == %@", 471 gameID as CVarArg, 472 RecordSerializer.localDeviceID 473 ) 474 } 475 req.fetchLimit = 1 476 guard let entity = try? ctx.fetch(req).first, 477 let recordName = entity.ckRecordName 478 else { continue } 479 let recordID = CKRecord.ID(recordName: recordName, zoneID: info.zoneID) 480 if isShared { 481 sharedIDs.append(recordID) 482 } else { 483 privateIDs.append(recordID) 484 } 485 } 486 return (privateIDs, sharedIDs) 487 } 488 if !privateRecordIDs.isEmpty, let engine = privateEngine { 489 engine.state.add( 490 pendingRecordZoneChanges: privateRecordIDs.map { .saveRecord($0) } 491 ) 492 if drain { sendChangesDetached(on: engine) } 493 } 494 if !sharedRecordIDs.isEmpty, let engine = sharedEngine { 495 engine.state.add( 496 pendingRecordZoneChanges: sharedRecordIDs.map { .saveRecord($0) } 497 ) 498 if drain { sendChangesDetached(on: engine) } 499 } 500 } 501 502 /// Re-enqueues locally persisted Moves rows that do not yet have CloudKit 503 /// system fields. Covers crash/relaunch recovery and any save that reached 504 /// Core Data before CKSyncEngine recorded the pending change. Scoped to the 505 /// current author for the same reason as `enqueueMoves`: an account 506 /// switch leaves the old account's unconfirmed rows behind on this device, 507 /// and recovery must not push them under the new account. 508 @discardableResult 509 func enqueueUnconfirmedMoves() async -> Int { 510 let localAuthorID = await currentLocalAuthorID() 511 let ctx = persistence.container.newBackgroundContext() 512 let gameIDs: Set<UUID> = ctx.performAndWait { 513 let req = NSFetchRequest<MovesEntity>(entityName: "MovesEntity") 514 if let localAuthorID, !localAuthorID.isEmpty { 515 req.predicate = NSPredicate( 516 format: "ckSystemFields == nil AND deviceID == %@ AND authorID == %@", 517 RecordSerializer.localDeviceID, 518 localAuthorID 519 ) 520 } else { 521 req.predicate = NSPredicate( 522 format: "ckSystemFields == nil AND deviceID == %@", 523 RecordSerializer.localDeviceID 524 ) 525 } 526 let entities = (try? ctx.fetch(req)) ?? [] 527 return Set(entities.compactMap { $0.game?.id }) 528 } 529 await enqueueMoves(gameIDs: gameIDs) 530 return gameIDs.count 531 } 532 533 /// Registers record deletions as pending sends. Extracts the game UUID 534 /// from the record name and routes to the correct engine. 535 /// Registers the game's CloudKit zone for deletion. Each game owns its 536 /// own zone, so this removes all remote records for the puzzle, including 537 /// moves, player records, pings, and share metadata. 538 func enqueueDeleteGame(_ deletion: GameCloudDeletion) { 539 let zoneID = CKRecordZone.ID( 540 zoneName: deletion.ckZoneName, 541 ownerName: deletion.ckZoneOwnerName 542 ) 543 let engine = deletion.databaseScope == 1 ? sharedEngine : privateEngine 544 guard let engine else { return } 545 engine.state.add(pendingDatabaseChanges: [.deleteZone(zoneID)]) 546 sendChangesDetached(on: engine) 547 548 // A finished participant game keeps a self-contained backup in a 549 // separate archive-<id> zone (see GameArchiver). The live game lives in 550 // the shared database, so the deletion above never reaches that backup — 551 // tear it down here. The archive is always in this account's private 552 // database, so it routes through the private engine regardless of the 553 // game's own scope. 554 guard let archiveZoneName = deletion.archiveZoneName, 555 let privateEngine else { return } 556 let archiveZoneID = CKRecordZone.ID( 557 zoneName: archiveZoneName, 558 ownerName: CKCurrentUserDefaultName 559 ) 560 privateEngine.state.add(pendingDatabaseChanges: [.deleteZone(archiveZoneID)]) 561 sendChangesDetached(on: privateEngine) 562 } 563 564 /// Registers a Ping record as a pending send. Pings now only cover 565 /// bootstrap kinds (`.join` / `.friend` / `.invite` / `.hail`) — the 566 /// user-facing play events go through the push worker. Sender-only 567 /// state: the payload is stashed in `pendingPings` and only used to 568 /// build the outgoing `CKRecord`; nothing is persisted. 569 func enqueuePing( 570 kind: PingKind, 571 gameID: UUID, 572 authorID: String, 573 playerName: String, 574 payload: String? = nil, 575 addressee: String? = nil 576 ) { 577 let ctx = persistence.container.newBackgroundContext() 578 let zoneAndTitle: (info: ZoneInfo, title: String)? = ctx.performAndWait { 579 guard let info = self.zoneInfo(forGameID: gameID, in: ctx) else { return nil } 580 let req = NSFetchRequest<GameEntity>(entityName: "GameEntity") 581 req.predicate = NSPredicate(format: "id == %@", gameID as CVarArg) 582 req.fetchLimit = 1 583 let entity = try? ctx.fetch(req).first 584 let title = PuzzleNotificationText.title(for: entity) 585 return (info, title) 586 } 587 guard let zoneAndTitle else { 588 Task { 589 await trace( 590 "ping send: SKIPPED kind=\(kind.rawValue) " + 591 "game=\(gameID.uuidString) " + 592 "— no zone info (game not yet synced/shared on this device)" 593 ) 594 } 595 return 596 } 597 let engine = zoneAndTitle.info.scope == 1 ? sharedEngine : privateEngine 598 guard let engine else { 599 Task { 600 await trace( 601 "ping send: SKIPPED kind=\(kind.rawValue) " + 602 "game=\(gameID.uuidString) " + 603 "— no CKSyncEngine for " + 604 "\(zoneAndTitle.info.scope == 1 ? "shared" : "private") scope" 605 ) 606 } 607 return 608 } 609 let deviceID = RecordSerializer.localDeviceID 610 let eventTimestampMs = Int64(Date().timeIntervalSince1970 * 1000) 611 let recordName = RecordSerializer.recordName( 612 forPingInGame: gameID, 613 authorID: authorID, 614 deviceID: deviceID, 615 eventTimestampMs: eventTimestampMs 616 ) 617 pendingPings[recordName] = PingPayload( 618 gameID: gameID, 619 authorID: authorID, 620 deviceID: deviceID, 621 playerName: playerName, 622 puzzleTitle: zoneAndTitle.title, 623 eventTimestampMs: eventTimestampMs, 624 kind: kind, 625 payload: payload, 626 addressee: addressee 627 ) 628 let recordID = CKRecord.ID(recordName: recordName, zoneID: zoneAndTitle.info.zoneID) 629 engine.state.add(pendingRecordZoneChanges: [.saveRecord(recordID)]) 630 Task { 631 await trace( 632 "ping send: enqueued kind=\(kind.rawValue) " + 633 "game=\(gameID.uuidString) " + 634 "target=\(zoneAndTitle.info.scope == 1 ? "shared" : "private") " + 635 "zone=\(zoneAndTitle.info.zoneID.zoneName) record=\(recordName)" 636 ) 637 } 638 sendChangesDetached(on: engine) 639 } 640 641 /// Registers an `.opened` Ping for cross-device notification dismissal. 642 /// Written to the account zone in the private database, so only the 643 /// authoring user's own devices receive it. The receive side filters 644 /// self-sends by (authorID, deviceID). 645 /// One-shot cleanup of legacy `.opened`/`.closed` lease pings from this 646 /// device's slot in the private-DB account zone. The lease subsystem is 647 /// gone (cross-device read state now rides `Player.readAt`), so any 648 /// remaining records are dead weight — every device cleans its own slice 649 /// the first time it launches the new build. Gated by an App-Group flag; 650 /// failures retry on the next launch. Slated for removal in a future 651 /// release once every device of every user has run it. 652 func purgeLegacyLeasePings_v1() async { 653 guard NotificationState.legacyLeasePurgeNeeded() else { return } 654 let predicate = NSPredicate( 655 format: "kind IN %@ AND deviceID == %@", 656 ["opened", "closed"], 657 RecordSerializer.localDeviceID 658 ) 659 do { 660 let records = try await queryRecords( 661 type: "Ping", 662 database: container.privateCloudDatabase, 663 zoneID: RecordSerializer.accountZoneID, 664 predicate: predicate, 665 desiredKeys: [] 666 ) 667 try await deleteRecords( 668 withIDs: records.map(\.recordID), 669 in: container.privateCloudDatabase 670 ) 671 NotificationState.markLegacyLeasePurged() 672 if !records.isEmpty { 673 await trace("legacy-lease purge: deleted \(records.count) record(s)") 674 } 675 } catch { 676 await trace("legacy-lease purge failed: \(describe(error))") 677 } 678 } 679 680 /// One-shot cleanup of legacy broadcast `.invite` pings from pairwise 681 /// friend zones. Current invites are addressed with `addressee`; older 682 /// ones were broadcast within the friend zone and can resurrect stale 683 /// Game List rows after a device restores or replays zone history. 684 func purgeLegacyInvitePings_v1() async { 685 guard NotificationState.legacyInvitePurgeNeeded() else { return } 686 let privateZones = friendZoneIDs(forScope: 0) 687 let sharedZones = friendZoneIDs(forScope: 1) 688 do { 689 let privateDeleted = try await purgeLegacyInvitePings( 690 in: privateZones, 691 database: container.privateCloudDatabase 692 ) 693 let sharedDeleted = try await purgeLegacyInvitePings( 694 in: sharedZones, 695 database: container.sharedCloudDatabase 696 ) 697 NotificationState.markLegacyInvitePurged() 698 let total = privateDeleted + sharedDeleted 699 if total > 0 { 700 await trace("legacy-invite purge: deleted \(total) record(s)") 701 } 702 } catch { 703 await trace("legacy-invite purge failed: \(describe(error))") 704 } 705 } 706 707 private func purgeLegacyInvitePings( 708 in zoneIDs: [CKRecordZone.ID], 709 database: CKDatabase 710 ) async throws -> Int { 711 var deleted = 0 712 let predicate = NSPredicate(format: "kind == %@", PingKind.invite.rawValue) 713 for zoneID in zoneIDs { 714 let records = try await queryRecords( 715 type: "Ping", 716 database: database, 717 zoneID: zoneID, 718 predicate: predicate, 719 desiredKeys: ["addressee"] 720 ) 721 let legacyIDs = records 722 .filter { ($0["addressee"] as? String)?.isEmpty != false } 723 .map(\.recordID) 724 try await deleteRecords(withIDs: legacyIDs, in: database) 725 deleted += legacyIDs.count 726 } 727 return deleted 728 } 729 730 /// One-shot cleanup of stale legacy `.hail` records from known game 731 /// zones. Hails are ephemeral bootstrap envelopes; any records written 732 /// before the current cleanup/deletion path shipped can only replay 733 /// obsolete handshakes, so remove them once per device. 734 func purgeStaleHailPings_v1() async { 735 guard NotificationState.staleHailPurgeNeeded() else { return } 736 do { 737 let privateDeleted = try await purgeStaleHailPings( 738 in: gameZoneIDs(forScope: 0), 739 database: container.privateCloudDatabase 740 ) 741 let sharedDeleted = try await purgeStaleHailPings( 742 in: gameZoneIDs(forScope: 1), 743 database: container.sharedCloudDatabase 744 ) 745 NotificationState.markStaleHailPurged() 746 let total = privateDeleted + sharedDeleted 747 await trace("stale-hail purge: deleted \(total) record(s)") 748 } catch { 749 await trace("stale-hail purge failed: \(describe(error))") 750 } 751 } 752 753 private func purgeStaleHailPings( 754 in zoneIDs: [CKRecordZone.ID], 755 database: CKDatabase 756 ) async throws -> Int { 757 var deleted = 0 758 let predicate = NSPredicate(format: "kind == %@", PingKind.hail.rawValue) 759 for zoneID in zoneIDs { 760 let records = try await queryRecords( 761 type: "Ping", 762 database: database, 763 zoneID: zoneID, 764 predicate: predicate, 765 desiredKeys: [] 766 ) 767 try await deleteRecords(withIDs: records.map(\.recordID), in: database) 768 deleted += records.count 769 } 770 return deleted 771 } 772 773 /// One-shot cleanup of Ping records whose kinds were retired when the 774 /// push worker took over user-facing event notifications: 775 /// `.check`, `.reveal`, `.resign`, `.win`, and the old session-start 776 /// `.join`. Those kinds are gone from `PingKind`, but records written 777 /// by pre-cutover builds linger in shared game zones and will replay 778 /// as obsolete announcements after a zone fetch. Every device drains 779 /// the game zones it can reach exactly once. 780 func purgeLegacyPlayPings_v1() async { 781 guard NotificationState.legacyPlayPingPurgeNeeded() else { return } 782 do { 783 let privateDeleted = try await purgeLegacyPlayPings( 784 in: gameZoneIDs(forScope: 0), 785 database: container.privateCloudDatabase 786 ) 787 let sharedDeleted = try await purgeLegacyPlayPings( 788 in: gameZoneIDs(forScope: 1), 789 database: container.sharedCloudDatabase 790 ) 791 NotificationState.markLegacyPlayPingPurged() 792 let total = privateDeleted + sharedDeleted 793 if total > 0 { 794 await trace("legacy play-ping purge: deleted \(total) record(s)") 795 } 796 } catch { 797 await trace("legacy play-ping purge failed: \(describe(error))") 798 } 799 } 800 801 private func purgeLegacyPlayPings( 802 in zoneIDs: [CKRecordZone.ID], 803 database: CKDatabase 804 ) async throws -> Int { 805 var deleted = 0 806 let predicate = NSPredicate( 807 format: "kind IN %@", 808 ["check", "reveal", "resign", "win", "join"] 809 ) 810 for zoneID in zoneIDs { 811 let records = try await queryRecords( 812 type: "Ping", 813 database: database, 814 zoneID: zoneID, 815 predicate: predicate, 816 desiredKeys: [] 817 ) 818 try await deleteRecords(withIDs: records.map(\.recordID), in: database) 819 deleted += records.count 820 } 821 return deleted 822 } 823 824 private func gameZoneIDs(forScope scope: Int16) -> [CKRecordZone.ID] { 825 let ctx = persistence.container.newBackgroundContext() 826 return ctx.performAndWait { 827 let req = NSFetchRequest<GameEntity>(entityName: "GameEntity") 828 req.predicate = NSPredicate( 829 format: "databaseScope == %d AND isAccessRevoked == NO", 830 scope 831 ) 832 var seen = Set<String>() 833 var result: [CKRecordZone.ID] = [] 834 for entity in (try? ctx.fetch(req)) ?? [] { 835 guard let gameID = entity.id else { continue } 836 let zoneName = entity.ckZoneName ?? "game-\(gameID.uuidString)" 837 let ownerName = entity.ckZoneOwnerName ?? CKCurrentUserDefaultName 838 let key = "\(ownerName)|\(zoneName)" 839 guard seen.insert(key).inserted else { continue } 840 result.append(CKRecordZone.ID(zoneName: zoneName, ownerName: ownerName)) 841 } 842 return result 843 } 844 } 845 846 /// One-shot local cleanup for pre-release debug friend rows whose 847 /// `friend-debug-preview-*` zones no longer exist or have invalid 848 /// participants. These rows are local metadata, but while present they 849 /// make the ping fast-path query dead zones on every poll. 850 func purgeDebugPreviewFriends_v1() async { 851 guard NotificationState.debugPreviewFriendPurgeNeeded() else { return } 852 let ctx = persistence.container.newBackgroundContext() 853 let deleted = ctx.performAndWait { 854 let req = NSFetchRequest<FriendEntity>(entityName: "FriendEntity") 855 req.predicate = NSPredicate(format: "friendZoneName BEGINSWITH %@", "friend-debug-preview-") 856 let rows = (try? ctx.fetch(req)) ?? [] 857 for row in rows { 858 ctx.delete(row) 859 } 860 do { 861 if ctx.hasChanges { 862 try ctx.save() 863 } 864 return rows.count 865 } catch { 866 ctx.rollback() 867 return -1 868 } 869 } 870 871 guard deleted >= 0 else { 872 await trace("debug-preview friend purge failed") 873 return 874 } 875 NotificationState.markDebugPreviewFriendPurged() 876 await trace("debug-preview friend purge: deleted \(deleted) row(s)") 877 } 878 879 /// Registers a directed Ping (`.invite` or `.decline`) into an existing 880 /// *friend* zone. Unlike `enqueuePing`, the target zone is the friend zone 881 /// (not the game zone), so the zone and engine are passed in explicitly: 882 /// `scope == 1` means we joined the friend zone (it lives in our shared DB 883 /// → shared engine); `scope == 0` means we own it (private DB → private 884 /// engine). The zone already exists by the time an invite or decline is 885 /// possible, so no `saveZone`. `gameID` is the game in question; it rides 886 /// the record name so the recipient resolves it without reading the game 887 /// zone. `authorID` is the sender, so an invite carries the inviter and a 888 /// decline carries the decliner. 889 func enqueueFriendZonePing( 890 kind: PingKind, 891 gameID: UUID, 892 gameTitle: String, 893 authorID: String, 894 playerName: String, 895 addressee: String, 896 friendZoneID: CKRecordZone.ID, 897 friendZoneScope: Int16, 898 payload: String? = nil 899 ) { 900 let engine = friendZoneScope == 1 ? sharedEngine : privateEngine 901 guard let engine else { return } 902 let deviceID = RecordSerializer.localDeviceID 903 let eventTimestampMs = Int64(Date().timeIntervalSince1970 * 1000) 904 let recordName = RecordSerializer.recordName( 905 forPingInGame: gameID, 906 authorID: authorID, 907 deviceID: deviceID, 908 eventTimestampMs: eventTimestampMs 909 ) 910 pendingPings[recordName] = PingPayload( 911 gameID: gameID, 912 authorID: authorID, 913 deviceID: deviceID, 914 playerName: playerName, 915 puzzleTitle: gameTitle, 916 eventTimestampMs: eventTimestampMs, 917 kind: kind, 918 payload: payload, 919 addressee: addressee 920 ) 921 let recordID = CKRecord.ID(recordName: recordName, zoneID: friendZoneID) 922 engine.state.add(pendingRecordZoneChanges: [.saveRecord(recordID)]) 923 sendChangesDetached(on: engine) 924 } 925 926 /// Registers a durable `Decision` record into the account zone so the fact 927 /// reaches the user's own other devices. Decisions without payloads are 928 /// reconstructable from their names; payload-bearing decisions mirror their 929 /// body to UserDefaults so CKSyncEngine's persisted pending save survives an 930 /// app kill without rebuilding as a payload-less record. Idempotent — a 931 /// re-send of an existing decision is a benign conflict the send path drops. 932 func enqueueDecision( 933 kind: String, 934 key: String, 935 payload: String? = nil, 936 version: Int64? = nil 937 ) { 938 guard let engine = privateEngine else { return } 939 let zoneID = RecordSerializer.accountZoneID 940 // CKSyncEngine dedupes redundant saveZone requests, so it's safe to 941 // repeat — block may be the first thing ever written to this zone. 942 engine.state.add(pendingDatabaseChanges: [.saveZone(CKRecordZone(zoneID: zoneID))]) 943 registerDecisionSave( 944 kind: kind, key: key, payload: payload, version: version, 945 zoneID: zoneID, engine: engine 946 ) 947 } 948 949 /// Registers a `Decision` into an existing *friend* zone — the channel a 950 /// display name rides to reach the other participant. Engine selection 951 /// mirrors `enqueueFriendInvitePing`: `scope == 1` means we joined the 952 /// zone (shared engine), `scope == 0` means we own it (private engine). 953 /// The zone already exists by the time a friendship is recorded, so no 954 /// `saveZone`. 955 func enqueueFriendDecision( 956 kind: String, 957 key: String, 958 payload: String? = nil, 959 version: Int64? = nil, 960 friendZoneID: CKRecordZone.ID, 961 friendZoneScope: Int16 962 ) { 963 guard let engine = friendZoneScope == 1 ? sharedEngine : privateEngine else { return } 964 registerDecisionSave( 965 kind: kind, key: key, payload: payload, version: version, 966 zoneID: friendZoneID, engine: engine 967 ) 968 } 969 970 /// Routes a display-name Decision to its zone. The account zone may not 971 /// exist yet, so that path rides `enqueueDecision`'s `saveZone` backstop; 972 /// a friend zone always exists by the time a friendship is recorded. 973 func enqueueNameDecision( 974 authorID: String, 975 name: String, 976 version: Int64, 977 zoneID: CKRecordZone.ID, 978 scope: Int16 979 ) { 980 if zoneID == RecordSerializer.accountZoneID { 981 enqueueDecision( 982 kind: RecordSerializer.nameDecisionKind, 983 key: authorID, 984 payload: name, 985 version: version 986 ) 987 } else { 988 enqueueFriendDecision( 989 kind: RecordSerializer.nameDecisionKind, 990 key: authorID, 991 payload: name, 992 version: version, 993 friendZoneID: zoneID, 994 friendZoneScope: scope 995 ) 996 } 997 } 998 999 private func registerDecisionSave( 1000 kind: String, 1001 key: String, 1002 payload: String?, 1003 version: Int64?, 1004 zoneID: CKRecordZone.ID, 1005 engine: CKSyncEngine 1006 ) { 1007 let name = RecordSerializer.decisionRecordName(kind: kind, key: key) 1008 let recordID = CKRecord.ID(recordName: name, zoneID: zoneID) 1009 let stateKey = Self.decisionStateKey(recordID) 1010 if let payload { 1011 pendingDecisionPayloads[stateKey] = payload 1012 persistPendingDecisionPayloads() 1013 } 1014 if let version { 1015 pendingDecisionVersions[stateKey] = version 1016 persistPendingDecisionVersions() 1017 } 1018 engine.state.add(pendingRecordZoneChanges: [.saveRecord(recordID)]) 1019 sendChangesDetached(on: engine) 1020 } 1021 1022 /// Deletes a durable `Decision` record (account zone) so a fact that no 1023 /// longer holds stops propagating — e.g. a `left` decision is voided when 1024 /// the user re-joins that game. Deleting an absent record is benign 1025 /// (CloudKit reports it gone; the send path does not retry-loop it). 1026 func enqueueDecisionDeletion(kind: String, key: String) { 1027 guard let engine = privateEngine else { return } 1028 let name = RecordSerializer.decisionRecordName(kind: kind, key: key) 1029 let recordID = CKRecord.ID(recordName: name, zoneID: RecordSerializer.accountZoneID) 1030 engine.state.add(pendingRecordZoneChanges: [.deleteRecord(recordID)]) 1031 sendChangesDetached(on: engine) 1032 } 1033 1034 /// Mirrors `pendingDecisionPayloads` to durable storage. CKSyncEngine 1035 /// persists the pending `.saveRecord` on its own, so the payload must be 1036 /// equally durable or a relaunch sends a payload-less Decision. 1037 private func persistPendingDecisionPayloads() { 1038 if pendingDecisionPayloads.isEmpty { 1039 UserDefaults.standard.removeObject( 1040 forKey: Self.pendingDecisionPayloadsDefaultsKey 1041 ) 1042 } else { 1043 UserDefaults.standard.set( 1044 pendingDecisionPayloads, 1045 forKey: Self.pendingDecisionPayloadsDefaultsKey 1046 ) 1047 } 1048 } 1049 1050 private func restorePendingDecisionPayloads() { 1051 pendingDecisionPayloads = (UserDefaults.standard.dictionary( 1052 forKey: Self.pendingDecisionPayloadsDefaultsKey 1053 ) as? [String: String]) ?? [:] 1054 } 1055 1056 /// Mirrors `pendingDecisionVersions` to durable storage, alongside 1057 /// `persistPendingDecisionPayloads`. NSNumber/Int64 is plist-representable. 1058 private func persistPendingDecisionVersions() { 1059 if pendingDecisionVersions.isEmpty { 1060 UserDefaults.standard.removeObject( 1061 forKey: Self.pendingDecisionVersionsDefaultsKey 1062 ) 1063 } else { 1064 UserDefaults.standard.set( 1065 pendingDecisionVersions.mapValues { NSNumber(value: $0) }, 1066 forKey: Self.pendingDecisionVersionsDefaultsKey 1067 ) 1068 } 1069 } 1070 1071 private func restorePendingDecisionVersions() { 1072 let raw = (UserDefaults.standard.dictionary( 1073 forKey: Self.pendingDecisionVersionsDefaultsKey 1074 ) as? [String: NSNumber]) ?? [:] 1075 pendingDecisionVersions = raw.mapValues { $0.int64Value } 1076 } 1077 1078 /// Consume-deletes a single Ping the local account has handled (shown, 1079 /// suppressed, or a duplicate). The deletion syncs through the game zone so 1080 /// this user's other devices withdraw any notification they showed for it. 1081 /// Deleting an absent record is benign (CloudKit reports it gone; the send 1082 /// path does not retry-loop it). The deletion is queued into `engine.state` 1083 /// synchronously; only the `sendChanges` drain is deferred — and via 1084 /// `Task.detached`, not a plain `Task {}`. The completion-ack consume path 1085 /// (`presentPings` → `consumeIfDirected`) reaches this from inside the 1086 /// `onPings` delegate callback, so an un-detached Task could re-enter 1087 /// CKSyncEngine before the callback unwinds (same class as the 1088 /// friend-invite `fetchChanges` trap). Detaching keeps it off the 1089 /// callback's actor; the drain only needs to land eventually. 1090 func deletePing(recordName: String, gameID: UUID) { 1091 let ctx = persistence.container.newBackgroundContext() 1092 guard let info = zoneInfo(forGameID: gameID, in: ctx) else { return } 1093 let engine = info.scope == 1 ? sharedEngine : privateEngine 1094 guard let engine else { return } 1095 pendingPings.removeValue(forKey: recordName) 1096 let recordID = CKRecord.ID(recordName: recordName, zoneID: info.zoneID) 1097 engine.state.add(pendingRecordZoneChanges: [.deleteRecord(recordID)]) 1098 sendChangesDetached(on: engine) 1099 } 1100 1101 /// Deletes a Ping from a known non-game zone, currently used for accepted 1102 /// friend invites. Unlike `deletePing(recordName:gameID:)`, the GameEntity 1103 /// may not exist before acceptance, so the caller supplies the friend-zone 1104 /// route directly. 1105 func deletePing(recordName: String, zoneID: CKRecordZone.ID, databaseScope: Int16) { 1106 let engine = databaseScope == 1 ? sharedEngine : privateEngine 1107 guard let engine else { return } 1108 pendingPings.removeValue(forKey: recordName) 1109 let recordID = CKRecord.ID(recordName: recordName, zoneID: zoneID) 1110 engine.state.add(pendingRecordZoneChanges: [.deleteRecord(recordID)]) 1111 sendChangesDetached(on: engine) 1112 } 1113 1114 /// Registers a Player record as a pending send. One record per 1115 /// (game, authorID), so participants only ever write their own slot. 1116 /// `reason` is logged so the diagnostics view can attribute each enqueue 1117 /// to its caller (rename, read cursor, cursor track, …); it does not 1118 /// affect routing. Drains immediately unless a burst is open for this 1119 /// scope (see `beginPlayerSendBurst`) — the open path uses a burst to 1120 /// ship read cursor + name + initial selection in one round-trip. 1121 /// 1122 /// `drain: false` enqueues the change durably but does not force an 1123 /// immediate `sendChanges()`; the record ships on CKSyncEngine's own 1124 /// schedule instead. Used on the way to the background / on puzzle leave, 1125 /// where a forced drain would race the scarce suspension budget to deliver 1126 /// a presence write the engagement socket already carries live (and that 1127 /// CloudKit delivers durably regardless). 1128 func enqueuePlayer(gameID: UUID, authorID: String, reason: String, drain: Bool = true) async { 1129 let ctx = persistence.container.newBackgroundContext() 1130 guard let info = zoneInfo(forGameID: gameID, in: ctx) else { return } 1131 // The shared zone has already been confirmed missing server-side 1132 // (see `applyZoneOrphaning`). Re-saving a Player record would just 1133 // fail with `.zoneNotFound` and drag the orphan handler through 1134 // another round of the same teardown work — open-game UI keeps 1135 // calling here for read-cursor / selection / name-open while the 1136 // user lingers on the revoked puzzle. Silently dropping the enqueue 1137 // is the only sensible response. 1138 guard !info.isAccessRevoked else { 1139 await trace( 1140 "enqueue Player[\(gameID.uuidString.prefix(8))] skipped " + 1141 "(access revoked) reason=\(reason)" 1142 ) 1143 return 1144 } 1145 let engine = info.scope == 1 ? sharedEngine : privateEngine 1146 guard let engine else { return } 1147 let recordName = RecordSerializer.recordName(forPlayerInGame: gameID, authorID: authorID) 1148 let recordID = CKRecord.ID(recordName: recordName, zoneID: info.zoneID) 1149 engine.state.add(pendingRecordZoneChanges: [.saveRecord(recordID)]) 1150 await trace("enqueue Player[\(gameID.uuidString.prefix(8))] reason=\(reason)") 1151 // Durably enqueued above. A `drain: false` caller leaves the send to 1152 // CKSyncEngine's automatic scheduling — and skips the burst-pending 1153 // mark too, so a concurrent burst won't drain on this record's behalf. 1154 guard drain else { return } 1155 if (playerSendBurstDepth[info.scope] ?? 0) > 0 { 1156 playerSendBurstPending.insert(info.scope) 1157 } else { 1158 sendChangesDetached(on: engine) 1159 } 1160 } 1161 1162 /// Registers a Game record as a pending send and ensures its zone is 1163 /// created in CloudKit first. Called when a new game is created locally. 1164 func enqueueGame(ckRecordName: String) { 1165 guard let gameID = gameID(fromRecordName: ckRecordName) else { return } 1166 let ctx = persistence.container.newBackgroundContext() 1167 guard let info = zoneInfo(forGameID: gameID, in: ctx) else { return } 1168 let engine = info.scope == 1 ? sharedEngine : privateEngine 1169 guard let engine else { return } 1170 // Save the zone before the game record so it exists when records arrive. 1171 engine.state.add(pendingDatabaseChanges: [.saveZone(CKRecordZone(zoneID: info.zoneID))]) 1172 let recordID = CKRecord.ID(recordName: ckRecordName, zoneID: info.zoneID) 1173 engine.state.add(pendingRecordZoneChanges: [.saveRecord(recordID)]) 1174 sendChangesDetached(on: engine) 1175 } 1176 1177 /// Registers this device's move journal as a pending send, into the game's 1178 /// existing zone (no `saveZone` — by completion the zone is long since 1179 /// created). One `Journal` record per (game, author, device); the asset is 1180 /// rebuilt from the durable local `JournalEntity` log in `buildRecord`, so 1181 /// no payload is stashed here. Called once at completion; a re-send is a 1182 /// benign conflict the send path drops. Skipped on an access-revoked game, 1183 /// where any save would just fail with `.zoneNotFound`. 1184 func enqueueJournalUpload(gameID: UUID, authorID: String) { 1185 let ctx = persistence.container.newBackgroundContext() 1186 guard let info = zoneInfo(forGameID: gameID, in: ctx), 1187 !info.isAccessRevoked else { return } 1188 // A device that logged nothing produces no JournalEntity rows, so the 1189 // build-time reap drops the save before it reaches CloudKit — an empty 1190 // journal never uploads, so it can't add a phantom device key to 1191 // replay's present set. No need to guard the enqueue itself. 1192 let engine = info.scope == 1 ? sharedEngine : privateEngine 1193 guard let engine else { return } 1194 let recordName = RecordSerializer.recordName( 1195 forJournalInGame: gameID, 1196 authorID: authorID, 1197 deviceID: RecordSerializer.localDeviceID 1198 ) 1199 let recordID = CKRecord.ID(recordName: recordName, zoneID: info.zoneID) 1200 engine.state.add(pendingRecordZoneChanges: [.saveRecord(recordID)]) 1201 sendChangesDetached(on: engine) 1202 } 1203 1204 // MARK: - Explicit sync triggers (called by AppServices / diagnostics view) 1205 1206 func fetchChanges(source: String = "manual") async throws { 1207 currentFetchSource = source 1208 defer { currentFetchSource = nil } 1209 async let p: Void = privateEngine?.fetchChanges() ?? () 1210 async let s: Void = sharedEngine?.fetchChanges() ?? () 1211 _ = try await (p, s) 1212 } 1213 1214 /// Zone-scoped fetch for a single game. Returns `false` if the game's zone 1215 /// isn't known locally (e.g. a freshly-invited share before its zone has 1216 /// landed) so the caller can fall back to a full `fetchChanges`. Records 1217 /// arrive via the normal `fetchedRecordZoneChanges` delegate path; the 1218 /// engine's change token is the only checkpoint. 1219 func fetchChangesForGame( 1220 scope: CKDatabase.Scope, 1221 gameID: UUID, 1222 source: String = "manual" 1223 ) async throws -> Bool { 1224 let engine: CKSyncEngine? 1225 let scopeValue: Int16 1226 switch scope { 1227 case .private: 1228 engine = privateEngine 1229 scopeValue = 0 1230 case .shared: 1231 engine = sharedEngine 1232 scopeValue = 1 1233 case .public: 1234 return false 1235 @unknown default: 1236 return false 1237 } 1238 guard let engine else { return false } 1239 let ctx = persistence.container.newBackgroundContext() 1240 guard let info = zoneInfo(forGameID: gameID, in: ctx), 1241 info.scope == scopeValue 1242 else { return false } 1243 currentFetchSource = source 1244 defer { currentFetchSource = nil } 1245 let options = CKSyncEngine.FetchChangesOptions(scope: .zoneIDs([info.zoneID])) 1246 try await engine.fetchChanges(options) 1247 return true 1248 } 1249 1250 func pushChanges() async throws { 1251 async let p: Void = privateEngine?.sendChanges() ?? () 1252 async let s: Void = sharedEngine?.sendChanges() ?? () 1253 _ = try await (p, s) 1254 } 1255 1256 // MARK: - Diagnostics 1257 1258 1259 /// Clears the saved state for both engines and replaces the in-memory 1260 /// engine instances so subsequent fetches walk every zone from scratch. 1261 /// Clearing the persisted state alone is ineffective: the running engines 1262 /// hold their tokens in memory and the next `stateUpdate` event saves 1263 /// those tokens back, so the wipe is undone before the user can act on it. 1264 /// Pending records already in CloudKit are unaffected. Locally-unconfirmed 1265 /// moves are re-enqueued so the new engines push them on the next cycle. 1266 func resetSyncState() async { 1267 let ctx = persistence.container.newBackgroundContext() 1268 ctx.performAndWait { 1269 let entity = SyncStateEntity.current(in: ctx) 1270 entity.ckPrivateEngineState = nil 1271 entity.ckSharedEngineState = nil 1272 do { 1273 try ctx.save() 1274 } catch { 1275 trace("resetSyncState ctx.save failed — \(error)") 1276 } 1277 } 1278 privateEngine = CKSyncEngine(CKSyncEngine.Configuration( 1279 database: container.privateCloudDatabase, 1280 stateSerialization: nil, 1281 delegate: self 1282 )) 1283 sharedEngine = CKSyncEngine(CKSyncEngine.Configuration( 1284 database: container.sharedCloudDatabase, 1285 stateSerialization: nil, 1286 delegate: self 1287 )) 1288 pendingPings = [:] 1289 pendingDecisionPayloads = [:] 1290 persistPendingDecisionPayloads() 1291 pendingDecisionVersions = [:] 1292 persistPendingDecisionVersions() 1293 decisionSystemFields = [:] 1294 pingPushCheckpoints = [:] 1295 seenPingRecords = [:] 1296 liveQueryCheckpoints = [:] 1297 loggedFirstSharedPushPayload = false 1298 playerSendBurstDepth = [:] 1299 playerSendBurstPending = [] 1300 _ = await enqueueUnconfirmedMoves() 1301 } 1302 1303 // MARK: - Private helpers 1304 1305 func currentLocalAuthorID() async -> String? { 1306 guard let localAuthorIDProvider else { return nil } 1307 return await MainActor.run { 1308 localAuthorIDProvider() 1309 } 1310 } 1311 1312 1313 func trace(_ message: String) async { 1314 guard let tracer else { return } 1315 await tracer(message) 1316 } 1317 1318 /// Decodes a persisted `CKSyncEngine.State.Serialization` payload. 1319 /// On failure, traces the cold-start cause so it isn't silent in the 1320 /// diagnostics log — the engine will rebuild change tokens and resend 1321 /// pending changes from scratch, which is visible-to-the-user behavior. 1322 private func decodeEngineState(_ data: Data?, label: String) async -> CKSyncEngine.State.Serialization? { 1323 guard let data else { return nil } 1324 do { 1325 return try JSONDecoder().decode(CKSyncEngine.State.Serialization.self, from: data) 1326 } catch { 1327 await trace("\(label) engine state decode failed (\(data.count) bytes) — cold-starting sync: \(describe(error))") 1328 return nil 1329 } 1330 } 1331 1332 private func saveEngineState( 1333 _ serialization: CKSyncEngine.State.Serialization, 1334 isPrivate: Bool 1335 ) { 1336 let ctx = persistence.container.newBackgroundContext() 1337 ctx.performAndWait { 1338 guard let data = try? JSONEncoder().encode(serialization) else { return } 1339 let entity = SyncStateEntity.current(in: ctx) 1340 if isPrivate { 1341 entity.ckPrivateEngineState = data 1342 } else { 1343 entity.ckSharedEngineState = data 1344 } 1345 do { 1346 try ctx.save() 1347 } catch { 1348 trace("saveEngineState ctx.save failed — \(error)") 1349 } 1350 } 1351 } 1352 1353 private func handleFetchedDatabaseChanges( 1354 _ event: CKSyncEngine.Event.FetchedDatabaseChanges, 1355 isPrivate: Bool 1356 ) async { 1357 let src = currentFetchSource ?? "framework" 1358 await trace( 1359 "\(isPrivate ? "private" : "shared") db changes [src=\(src)]: " + 1360 "\(event.modifications.count) zone mods, \(event.deletions.count) zone deletions" 1361 ) 1362 await noteRoundTripSuccess() 1363 1364 // Private-DB zone deletions reflect the user removing one of their own 1365 // games on another device — hard-delete locally so the row stops 1366 // hanging around forever. Shared-DB zone deletions reflect the owner 1367 // removing this account from the share — mark access-revoked instead 1368 // so the UI can surface "no longer have access" rather than silently 1369 // vanishing the row mid-game. Modifications on the shared DB also 1370 // create placeholder GameEntities for newly-joined shares. 1371 let ctx = persistence.container.newBackgroundContext() 1372 ctx.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump 1373 let (removedIDs, revokedIDs, rejoinedIDs, failureMessages): 1374 ([UUID], [UUID], [UUID], [String]) = ctx.performAndWait { 1375 var removed: [UUID] = [] 1376 var revoked: [UUID] = [] 1377 var rejoined: [UUID] = [] 1378 var messages: [String] = [] 1379 if !isPrivate { 1380 for mod in event.modifications { 1381 let zoneID = mod.zoneID 1382 let zoneName = zoneID.zoneName 1383 guard zoneName.hasPrefix("game-") else { continue } 1384 let req = NSFetchRequest<GameEntity>(entityName: "GameEntity") 1385 req.predicate = NSPredicate(format: "ckZoneName == %@", zoneName) 1386 req.fetchLimit = 1 1387 if (try? ctx.fetch(req).first) == nil { 1388 // Placeholder until the Game record arrives. 1389 let entity = GameEntity(context: ctx) 1390 let uuidString = String(zoneName.dropFirst("game-".count)) 1391 let gid = UUID(uuidString: uuidString) 1392 entity.id = gid 1393 entity.ckRecordName = zoneName 1394 entity.ckZoneName = zoneName 1395 entity.ckZoneOwnerName = zoneID.ownerName 1396 entity.databaseScope = 1 1397 entity.title = "Joining\u{2026}" 1398 entity.puzzleSource = "" 1399 entity.createdAt = Date() 1400 entity.updatedAt = Date() 1401 // Gaining access to this shared zone means the user 1402 // (re)joined. Any prior `left` decision for this game 1403 // is now void — clear it so a re-invited game isn't 1404 // re-deleted on this or a sibling device by the stale 1405 // durable fact — and any pending invite row for it is 1406 // now redundant. 1407 if let gid { rejoined.append(gid) } 1408 } 1409 } 1410 } 1411 for deletion in event.deletions { 1412 let zoneName = deletion.zoneID.zoneName 1413 guard zoneName.hasPrefix("game-") else { continue } 1414 let req = NSFetchRequest<GameEntity>(entityName: "GameEntity") 1415 req.predicate = NSPredicate(format: "ckZoneName == %@", zoneName) 1416 req.fetchLimit = 1 1417 guard let entity = try? ctx.fetch(req).first else { continue } 1418 if isPrivate { 1419 if let id = entity.id { removed.append(id) } 1420 ctx.delete(entity) 1421 } else { 1422 entity.isAccessRevoked = true 1423 if let id = entity.id { revoked.append(id) } 1424 } 1425 } 1426 if ctx.hasChanges { 1427 do { 1428 try ctx.save() 1429 } catch { 1430 // A dropped save here loses placeholder rows / revocation 1431 // flags with no redelivery (the change token advances on 1432 // return) — trace it so the diagnostics log shows the drop. 1433 let nsError = error as NSError 1434 messages.append( 1435 "db-changes ctx.save FAILED — domain=\(nsError.domain) " + 1436 "code=\(nsError.code) \(nsError.localizedDescription)" 1437 ) 1438 } 1439 } 1440 return (removed, revoked, rejoined, messages) 1441 } 1442 1443 for message in failureMessages { 1444 await trace(message) 1445 } 1446 for id in removedIDs { 1447 if let cb = onGameRemoved { await cb(id) } 1448 } 1449 for id in revokedIDs { 1450 if let cb = onGameAccessRevoked { await cb(id) } 1451 } 1452 // enqueueDecisionDeletion defers its CKSyncEngine work via Task — it 1453 // never awaits sync from this delegate callback's context. onGameJoined 1454 // only touches Core Data, so awaiting it directly is safe. 1455 for id in rejoinedIDs { 1456 enqueueDecisionDeletion(kind: "left", key: id.uuidString) 1457 if let cb = onGameJoined { await cb(id) } 1458 } 1459 } 1460 1461 private func handleFetchedRecordZoneChanges( 1462 _ event: CKSyncEngine.Event.FetchedRecordZoneChanges, 1463 isPrivate: Bool 1464 ) async { 1465 let scope: Int16 = isPrivate ? 0 : 1 1466 let src = currentFetchSource ?? "framework" 1467 await trace( 1468 "\(isPrivate ? "private" : "shared") fetch [src=\(src)]: " + 1469 "\(event.modifications.count) modifications, \(event.deletions.count) deletions" 1470 ) 1471 await noteRoundTripSuccess() 1472 if !isPrivate, !loggedFirstSharedPushPayload, src == "push", 1473 event.modifications.count + event.deletions.count > 0 { 1474 loggedFirstSharedPushPayload = true 1475 await trace("✅ first shared-DB push payload received — silent-push path is live") 1476 } 1477 1478 let ctx = persistence.container.newBackgroundContext() 1479 ctx.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump 1480 let localAuthorID = await currentLocalAuthorID() 1481 let effects: BatchEffects = ctx.performAndWait { 1482 var effects = BatchEffects() 1483 for mod in event.modifications { 1484 let record = mod.record 1485 switch record.recordType { 1486 case "Game": 1487 let entity = RecordSerializer.applyGameRecord( 1488 record, 1489 to: ctx, 1490 databaseScope: scope, 1491 onEngagementChange: { effects.engagementChanged.insert($0) }, 1492 onCompletedTransition: { effects.completedTransitions.insert($0) }, 1493 onContentKeyChange: { effects.contentKeysChanged.insert($0) } 1494 ) 1495 if let id = entity.id { effects.rosterRelevant.insert(id) } 1496 case "Moves": 1497 if let value = RecordSerializer.parseMovesRecord(record) { 1498 let cellsChanged = RecordSerializer.applyMovesRecord( 1499 record, 1500 value: value, 1501 to: ctx, 1502 localAuthorID: localAuthorID, 1503 onNewAuthor: { _ in effects.rosterRelevant.insert(value.gameID) } 1504 ) 1505 if cellsChanged { effects.movesUpdated.insert(value.gameID) } 1506 } 1507 case "Player": 1508 if let (gameID, _) = RecordSerializer.parsePlayerRecordName(record.recordID.recordName) { 1509 self.applyPlayerRecord( 1510 record, 1511 in: ctx, 1512 localAuthorID: localAuthorID, 1513 onFirstTime: { effects.playersUpdated.insert($0) }, 1514 onPresenceChange: { effects.playerPresenceChanged.insert($0) }, 1515 onReadCursor: { effects.readCursors.append(($0, $1, $2)) } 1516 ) 1517 effects.rosterRelevant.insert(gameID) 1518 } 1519 case "Ping": 1520 if let ping = Ping.parseRecord(record) { 1521 effects.pings.append(ping) 1522 } 1523 case "Decision": 1524 if let address = RecordSerializer.parseAccountPushAddressDecision(record) { 1525 effects.accountPushAddresses.append(address) 1526 } 1527 if let parsed = RecordSerializer.parseAccountPushSecretDecision(record) { 1528 effects.accountPushSecrets.append(parsed) 1529 } 1530 let wrote = RecordSerializer.applyDecisionRecord( 1531 record, 1532 to: ctx, 1533 localAuthorID: localAuthorID, 1534 databaseScope: scope 1535 ) 1536 // The decision-apply path is otherwise silent, which makes 1537 // a "synced fact that never landed" (e.g. a nickname that 1538 // applied on the sender but not here) impossible to diagnose 1539 // from the on-device log. Record the fetched decision name, 1540 // its zone, and whether it applied so the receive side is 1541 // observable. 1542 effects.traces.append( 1543 "decision applied=\(wrote) " + 1544 "\(record.recordID.recordName) " + 1545 "zone=\(record.recordID.zoneID.zoneName)/" + 1546 "\(record.recordID.zoneID.ownerName) scope=\(scope)" 1547 ) 1548 // Our own name Decision echoed back from the account zone 1549 // (or a friend zone a sibling seeded): adopt its version so 1550 // this device's next rename supersedes it rather than 1551 // colliding at an equal generation. 1552 if let (subject, _, version) = RecordSerializer.parseNameDecision(record), 1553 let localAuthorID, subject == localAuthorID { 1554 effects.selfNameVersions.append(version) 1555 } 1556 // A `left` decision hard-deletes a game row; surface it so 1557 // an open PuzzleView / the game list reacts, the same as 1558 // the private zone-deletion path does via onGameRemoved. 1559 if wrote, 1560 let (dKind, dKey) = RecordSerializer.parseDecisionRecordName( 1561 record.recordID.recordName 1562 ) { 1563 if dKind == "left", let gid = UUID(uuidString: dKey) { 1564 effects.removed.insert(gid) 1565 } 1566 // A friend's own rename or this user's nickname landed 1567 // — either side of an App Group nickname-directory 1568 // entry, so it's rebuilt after the batch saves. 1569 if dKind == RecordSerializer.nameDecisionKind 1570 || dKind == RecordSerializer.nicknameDecisionKind { 1571 effects.friendNamesChanged = true 1572 } 1573 } 1574 case "Journal": 1575 // Journals are never applied to Core Data from the sync 1576 // delegate — the replay loader (`fetchReplay`) pulls them on 1577 // demand with a plain CKQuery. But the record landing is the 1578 // signal a contributor finished and uploaded, so note the 1579 // game to wake a waiting replay banner (posted below). 1580 if let (gid, _, _) = RecordSerializer.parseJournalRecordName( 1581 record.recordID.recordName 1582 ) { 1583 effects.journalsSynced.insert(gid) 1584 } 1585 case Archive.recordType: 1586 // A finished shared game this user archived to their own 1587 // private DB. Inert where the live original still exists; 1588 // hydrated into a standalone completed game on a device that 1589 // lacks it (fresh install / after the original was revoked). 1590 if let id = self.applyArchiveRecord(record, in: ctx) { 1591 effects.rosterRelevant.insert(id) 1592 } 1593 default: 1594 break 1595 } 1596 } 1597 for deletion in event.deletions { 1598 self.applyDeletion( 1599 recordID: deletion.recordID, 1600 recordType: deletion.recordType, 1601 in: ctx 1602 ) 1603 if let id = self.gameID(fromRecordName: deletion.recordID.recordName) { 1604 effects.rosterRelevant.insert(id) 1605 } 1606 } 1607 for gameID in effects.movesUpdated { 1608 effects.traces += self.replayCellCache(for: gameID, in: ctx) 1609 } 1610 // CKSyncEngine advances its change token whenever the delegate 1611 // returns from fetchedRecordZoneChanges, regardless of whether we 1612 // persisted anything. A silent failure here means the records are 1613 // gone from the engine's "to deliver" set — they won't come back 1614 // without a `resetSyncState`. Surface failures so we can act — 1615 // through the tracer, which is the only channel that reaches the 1616 // on-device diagnostics log this project debugs Production from. 1617 if ctx.hasChanges { 1618 do { 1619 try ctx.save() 1620 } catch { 1621 let nsError = error as NSError 1622 effects.traces.append( 1623 "fetchedRecordZoneChanges ctx.save FAILED " + 1624 "— domain=\(nsError.domain) code=\(nsError.code) " + 1625 "\(nsError.localizedDescription)" 1626 ) 1627 } 1628 } 1629 // Re-mirror the App Group key directory once the batch is saved, so 1630 // a just-adopted content key is available to the NSE immediately. 1631 if !effects.contentKeysChanged.isEmpty { 1632 GameEntity.rebuildContentKeyDirectory(in: ctx) 1633 } 1634 return effects 1635 } 1636 1637 for message in effects.traces { 1638 await trace(message) 1639 } 1640 if let onRemoteMovesUpdated, !effects.movesUpdated.isEmpty { 1641 await onRemoteMovesUpdated(effects.movesUpdated) 1642 } 1643 if let onRemotePlayersUpdated, !effects.playersUpdated.isEmpty { 1644 await onRemotePlayersUpdated(effects.playersUpdated) 1645 } 1646 if let onRemotePlayerPresenceChanged, !effects.playerPresenceChanged.isEmpty { 1647 await onRemotePlayerPresenceChanged(effects.playerPresenceChanged) 1648 } 1649 if let onRemoteEngagementChanged, !effects.engagementChanged.isEmpty { 1650 await onRemoteEngagementChanged(effects.engagementChanged) 1651 } 1652 if let onIncomingReadCursor, !effects.readCursors.isEmpty { 1653 await onIncomingReadCursor(effects.readCursors) 1654 } 1655 if let onPings, !effects.pings.isEmpty { 1656 await onPings(effects.pings) 1657 } 1658 if let onAccountPushAddress { 1659 for address in effects.accountPushAddresses { 1660 await onAccountPushAddress(address) 1661 } 1662 } 1663 // The push secret converges across the account's own devices purely 1664 // through this inbound path (the account zone lives in the private DB, 1665 // which reliably syncs to every one of the account's devices). On a 1666 // simultaneous-mint race the loser adopts the winner's secret here on 1667 // the next fetch — no send-failure-recovery shortcut needed. 1668 if let onAccountPushSecret { 1669 for entry in effects.accountPushSecrets { 1670 await onAccountPushSecret(entry.secret, entry.version) 1671 } 1672 } 1673 if let localAuthorID, !localAuthorID.isEmpty, 1674 let maxVersion = effects.selfNameVersions.max() { 1675 NameVersionStore.adopt(maxVersion, authorID: localAuthorID) 1676 } 1677 if effects.friendNamesChanged { 1678 let mirrorCtx = persistence.container.newBackgroundContext() 1679 mirrorCtx.performAndWait { 1680 FriendEntity.rebuildNicknameDirectory(in: mirrorCtx) 1681 } 1682 } 1683 for id in effects.removed { 1684 if let cb = onGameRemoved { await cb(id) } 1685 } 1686 // A game just learned it's complete via sync: upload this device's 1687 // journal (no-op if it logged nothing) so replay can converge. The 1688 // enqueue defers its CKSyncEngine drain via `sendChangesDetached`, so 1689 // it never awaits sync from inside this delegate callback. 1690 if let localAuthorID, !localAuthorID.isEmpty { 1691 for id in effects.completedTransitions { 1692 enqueueJournalUpload(gameID: id, authorID: localAuthorID) 1693 } 1694 } 1695 if let onGameCompleted { 1696 for id in effects.completedTransitions { 1697 await onGameCompleted(id) 1698 } 1699 } 1700 let deletedPings = event.deletions.compactMap { deletion -> (recordName: String, gameID: UUID)? in 1701 let recordName = deletion.recordID.recordName 1702 guard recordName.hasPrefix("ping-"), 1703 let gameID = gameID(fromRecordName: recordName) 1704 else { return nil } 1705 return (recordName, gameID) 1706 } 1707 if let onPingDeleted, !deletedPings.isEmpty { 1708 await onPingDeleted(deletedPings) 1709 } 1710 if !effects.rosterRelevant.isEmpty { 1711 NotificationCenter.default.post( 1712 name: .playerRosterShouldRefresh, 1713 object: nil, 1714 userInfo: ["gameIDs": effects.rosterRelevant] 1715 ) 1716 } 1717 if !effects.journalsSynced.isEmpty { 1718 NotificationCenter.default.post( 1719 name: .replayJournalDidSync, 1720 object: nil, 1721 userInfo: ["gameIDs": effects.journalsSynced] 1722 ) 1723 } 1724 } 1725 1726 1727 private nonisolated static func recordTypeSummary(_ counts: [String: Int]) -> String { 1728 counts 1729 .filter { $0.value > 0 } 1730 .sorted { lhs, rhs in 1731 if lhs.key == rhs.key { return lhs.value > rhs.value } 1732 return lhs.key < rhs.key 1733 } 1734 .map { "\($0.key)=\($0.value)" } 1735 .joined(separator: ", ") 1736 } 1737 1738 private nonisolated static func inferredRecordType(for recordID: CKRecord.ID) -> String { 1739 let name = recordID.recordName 1740 if name.hasPrefix("moves-") { return "Moves" } 1741 if name.hasPrefix("journal-") { return "Journal" } 1742 if name.hasPrefix("player-") { return "Player" } 1743 if name.hasPrefix("game-") { return "Game" } 1744 if name.hasPrefix("ping-") { return "Ping" } 1745 if name.hasPrefix("decision-") { return "Decision" } 1746 return "Unknown" 1747 } 1748 1749 private func handleSentRecordZoneChanges( 1750 _ event: CKSyncEngine.Event.SentRecordZoneChanges, 1751 isPrivate: Bool 1752 ) async { 1753 let savedTypes = Dictionary( 1754 grouping: event.savedRecords, 1755 by: \.recordType 1756 ).mapValues(\.count) 1757 let failedTypes = Dictionary( 1758 grouping: event.failedRecordSaves, 1759 by: { $0.record.recordType } 1760 ).mapValues(\.count) 1761 let deletedTypes = Dictionary( 1762 grouping: event.deletedRecordIDs, 1763 by: Self.inferredRecordType(for:) 1764 ).mapValues(\.count) 1765 let savedSummary = Self.recordTypeSummary(savedTypes) 1766 let failedSummary = Self.recordTypeSummary(failedTypes) 1767 let deletedSummary = Self.recordTypeSummary(deletedTypes) 1768 1769 await trace( 1770 "\(isPrivate ? "private" : "shared") sent: " + 1771 "\(event.savedRecords.count) saved" + 1772 "\(savedSummary.isEmpty ? "" : " (\(savedSummary))"), " + 1773 "\(event.failedRecordSaves.count) failed" + 1774 "\(failedSummary.isEmpty ? "" : " (\(failedSummary))"), " + 1775 "\(event.deletedRecordIDs.count) deleted" + 1776 "\(deletedSummary.isEmpty ? "" : " (\(deletedSummary))")" 1777 ) 1778 // Only bump on a clean batch: a round trip with per-record failures 1779 // is not "success" from the user's point of view, and the existing 1780 // SyncMonitor.recordError path owns reporting whatever did break. 1781 if event.failedRecordSaves.isEmpty { 1782 await noteRoundTripSuccess() 1783 } 1784 for record in event.savedRecords { 1785 let name = record.recordID.recordName 1786 if name.hasPrefix("ping-") { 1787 pendingPings.removeValue(forKey: name) 1788 } else if name.hasPrefix("decision-") { 1789 let stateKey = Self.decisionStateKey(record.recordID) 1790 pendingDecisionPayloads.removeValue(forKey: stateKey) 1791 persistPendingDecisionPayloads() 1792 pendingDecisionVersions.removeValue(forKey: stateKey) 1793 persistPendingDecisionVersions() 1794 decisionSystemFields.removeValue(forKey: stateKey) 1795 } 1796 } 1797 // Snapshot the intended versions on-actor so the off-actor conflict 1798 // resolution can tell a deliberate, newer write from a stale one. 1799 let pendingVersionsSnapshot = pendingDecisionVersions 1800 let ctx = persistence.container.newBackgroundContext() 1801 ctx.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump 1802 let (failureMessages, orphanedZones, resolvedDecisions, settledJournals, 1803 resolvedAccountAddresses, resolvedAccountSecrets, decisionWins, recoveredSaves): 1804 ([String], Set<CKRecordZone.ID>, Set<CKRecord.ID>, Set<CKRecord.ID>, 1805 [String], [(secret: String, version: Int64)], 1806 [(recordID: CKRecord.ID, stateKey: String, systemFields: Data)], 1807 [CKRecord.ID]) = ctx.performAndWait { 1808 var messages: [String] = [] 1809 var orphaned = Set<CKRecordZone.ID>() 1810 var settledDecisions = Set<CKRecord.ID>() 1811 var settledJournals = Set<CKRecord.ID>() 1812 var accountAddresses: [String] = [] 1813 var accountSecrets: [(secret: String, version: Int64)] = [] 1814 // Versioned decisions that lost the change-tag race but win on 1815 // version: (decision state key, server system fields to adopt for 1816 // the overwrite retry). 1817 var decisionWins: [(recordID: CKRecord.ID, stateKey: String, systemFields: Data)] = [] 1818 // Game/Moves/Player saves that lost a change-tag race and had the 1819 // server's system fields written back: re-enqueued below. Like 1820 // decisions, a `serverRecordChanged` failure leaves nothing pending, 1821 // so without the re-add these heal only when unrelated activity 1822 // happens to re-enqueue the record — reliable enough for the 1823 // keystroke-cadence Moves/Player writes, but a Game record (share 1824 // metadata, completion) can strand on an idle game until its next 1825 // change. 1826 var recoveredSaves: [CKRecord.ID] = [] 1827 for record in event.savedRecords { 1828 self.writeBackSystemFields(record: record, in: ctx) 1829 let savedName = record.recordID.recordName 1830 if savedName.hasPrefix("game-") { 1831 self.clearPendingSaveFlag(for: savedName, in: ctx) 1832 } else if savedName.hasPrefix("journal-"), 1833 let (gid, _, _) = RecordSerializer.parseJournalRecordName(savedName) { 1834 // Confirmed durable upload of this device's journal — record 1835 // it so the level-triggered backstop 1836 // (`reconcilePendingJournalUploads`) stops re-enqueuing it. 1837 self.markJournalUploaded(gameID: gid, in: ctx) 1838 } 1839 } 1840 for failure in event.failedRecordSaves { 1841 let name = failure.record.recordID.recordName 1842 let err = failure.error as NSError 1843 // A settled failure is one CloudKit rejected but we treat as 1844 // success (the durable record already exists), so it skips the 1845 // verbose "failed to save" log below — that line reads as an 1846 // error and is pure noise next to the "settled" message. 1847 var settled = false 1848 if err.domain == CKErrorDomain, 1849 err.code == CKError.zoneNotFound.rawValue { 1850 orphaned.insert(failure.record.recordID.zoneID) 1851 } else if !isPrivate, 1852 self.isInvalidSharedZoneOwnerError(err) { 1853 orphaned.insert(failure.record.recordID.zoneID) 1854 } else if name.hasPrefix("decision-"), 1855 err.domain == CKErrorDomain, 1856 err.code == CKError.serverRecordChanged.rawValue { 1857 let serverRecord = err.userInfo[CKRecordChangedErrorServerRecordKey] as? CKRecord 1858 let stateKey = Self.decisionStateKey(failure.record.recordID) 1859 let intended = pendingVersionsSnapshot[stateKey] 1860 let serverVersion = serverRecord.map(RecordSerializer.decisionVersion) 1861 if let intended, let serverRecord, let serverVersion, 1862 intended > serverVersion, 1863 let serverFields = RecordSerializer.encodeSystemFields(of: serverRecord) { 1864 // A deliberate, newer write (e.g. a rotated push secret 1865 // or a rename) that lost only the change-tag race. Adopt 1866 // the server's tag so the next build overwrites instead 1867 // of re-colliding, then re-enqueue the save below: a 1868 // `serverRecordChanged` failure does NOT leave the change 1869 // in CKSyncEngine's pending state (the original code 1870 // assumed it did), so the retry must re-add it explicitly 1871 // or the overwrite is silently dropped. 1872 decisionWins.append((failure.record.recordID, stateKey, serverFields)) 1873 settled = true 1874 messages.append( 1875 "send: decision \(name) lost tag race but wins on version " + 1876 "(\(intended) > \(serverVersion)) — overwriting" 1877 ) 1878 } else { 1879 // Write-once (block/left/pushAddress) or an equal/older 1880 // version: the durable fact already on the server wins. 1881 // Drop the pending change so the record doesn't 1882 // retry-loop, and adopt the winner's payload straight off 1883 // the conflict instead of waiting for a later fetch — for 1884 // the push secret that closes the window in which this 1885 // device would keep deriving divergent per-game addresses. 1886 settledDecisions.insert(failure.record.recordID) 1887 if let serverRecord { 1888 if let address = RecordSerializer.parseAccountPushAddressDecision(serverRecord) { 1889 accountAddresses.append(address) 1890 } 1891 if let parsed = RecordSerializer.parseAccountPushSecretDecision(serverRecord) { 1892 accountSecrets.append(parsed) 1893 } 1894 } 1895 settled = true 1896 messages.append("send: decision \(name) already present — settled") 1897 } 1898 } else if name.hasPrefix("journal-"), 1899 err.domain == CKErrorDomain, 1900 err.code == CKError.serverRecordChanged.rawValue, 1901 let (gid, _, _) = RecordSerializer.parseJournalRecordName(name) { 1902 // Journals are write-once at completion: "record to insert 1903 // already exists" means this device's journal is already 1904 // durable server-side (a backstop re-enqueue, or a first 1905 // upload on a build predating the `journalUploaded` flag). 1906 // There is no system-fields archive to adopt for an update 1907 // and the content is frozen, so the re-send is a no-op — 1908 // settle it like a Decision: drop the pending change and 1909 // mark the game uploaded so `reconcilePendingJournalUploads` 1910 // stops re-enqueuing it. Without this the save fails every 1911 // sweep and `Pending Changes` never drains. 1912 settledJournals.insert(failure.record.recordID) 1913 self.markJournalUploaded(gameID: gid, in: ctx) 1914 settled = true 1915 messages.append("send: journal \(name) already present — settled") 1916 } else if self.recoverServerChangedSave(failure.error, failedRecordName: name, in: ctx) { 1917 recoveredSaves.append(failure.record.recordID) 1918 messages.append( 1919 "send: recovered stale system fields for \(name) from CloudKit server record" 1920 ) 1921 } 1922 guard !settled else { continue } 1923 let userInfo = err.userInfo 1924 .map { "\($0.key)=\($0.value)" } 1925 .joined(separator: " | ") 1926 messages.append( 1927 "send: failed to save \(name) — domain=\(err.domain) code=\(err.code) \(err.localizedDescription) | userInfo: \(userInfo)" 1928 ) 1929 } 1930 if ctx.hasChanges { 1931 do { 1932 try ctx.save() 1933 } catch { 1934 let nsError = error as NSError 1935 messages.append( 1936 "send: writeBack ctx.save failed — domain=\(nsError.domain) code=\(nsError.code) \(nsError.localizedDescription)" 1937 ) 1938 } 1939 } 1940 return (messages, orphaned, settledDecisions, settledJournals, 1941 accountAddresses, accountSecrets, decisionWins, recoveredSaves) 1942 } 1943 if !orphanedZones.isEmpty { 1944 await applyZoneOrphaning(orphanedZones, isPrivate: isPrivate) 1945 } 1946 // Settle/retry against the engine this sent-event belongs to: account- 1947 // zone decisions ride the private engine, but a name Decision in a 1948 // joined friend zone rides the shared one. 1949 let decisionEngine = isPrivate ? privateEngine : sharedEngine 1950 if !resolvedDecisions.isEmpty, let decisionEngine { 1951 decisionEngine.state.remove( 1952 pendingRecordZoneChanges: resolvedDecisions.map { .saveRecord($0) } 1953 ) 1954 for recordID in resolvedDecisions { 1955 let stateKey = Self.decisionStateKey(recordID) 1956 pendingDecisionPayloads.removeValue(forKey: stateKey) 1957 pendingDecisionVersions.removeValue(forKey: stateKey) 1958 decisionSystemFields.removeValue(forKey: stateKey) 1959 } 1960 persistPendingDecisionPayloads() 1961 persistPendingDecisionVersions() 1962 } 1963 // Adopt the server's tag for each versioned decision we're overwriting, 1964 // re-enqueue the save (the failed change is no longer pending), then 1965 // nudge the send loop so the retry (now carrying the tag) goes out 1966 // promptly rather than on CKSyncEngine's own cadence. 1967 if !decisionWins.isEmpty, let decisionEngine { 1968 for win in decisionWins { 1969 decisionSystemFields[win.stateKey] = win.systemFields 1970 decisionEngine.state.add( 1971 pendingRecordZoneChanges: [.saveRecord(win.recordID)] 1972 ) 1973 } 1974 sendChangesDetached(on: decisionEngine) 1975 } 1976 // Re-enqueue Game/Moves/Player saves whose stale system fields we just 1977 // healed: a `serverRecordChanged` failure leaves nothing pending (see 1978 // `decisionWins`), so the now-current record must be re-added or the 1979 // local change waits for the next unrelated enqueue. The engine for 1980 // this sent-event is the same one decisions ride. 1981 if !recoveredSaves.isEmpty, let engine = isPrivate ? privateEngine : sharedEngine { 1982 engine.state.add( 1983 pendingRecordZoneChanges: recoveredSaves.map { .saveRecord($0) } 1984 ) 1985 sendChangesDetached(on: engine) 1986 } 1987 if let onAccountPushAddress { 1988 for address in resolvedAccountAddresses { 1989 await onAccountPushAddress(address) 1990 } 1991 } 1992 if let onAccountPushSecret { 1993 for entry in resolvedAccountSecrets { 1994 await onAccountPushSecret(entry.secret, entry.version) 1995 } 1996 } 1997 if !settledJournals.isEmpty { 1998 // Drop from whichever engine owns the zone (private for solo games, 1999 // shared for collaborations) — unlike decisions, journals ride both. 2000 let engine = isPrivate ? privateEngine : sharedEngine 2001 engine?.state.remove( 2002 pendingRecordZoneChanges: settledJournals.map { .saveRecord($0) } 2003 ) 2004 } 2005 for message in failureMessages { 2006 await trace(message) 2007 } 2008 } 2009 2010 nonisolated func isInvalidSharedZoneOwnerError(_ error: NSError) -> Bool { 2011 let values = [error.localizedDescription] + error.userInfo.map { "\($0.value)" } 2012 return values.contains { 2013 $0.localizedCaseInsensitiveContains("Cannot convert userId to dsId") || 2014 $0.localizedCaseInsensitiveContains("invalid userId") 2015 } 2016 } 2017 2018 /// Reacts to per-record `.zoneNotFound` failures discovered during a push 2019 /// by reflecting the missing-zone reality locally. The framework reports 2020 /// the same failure on every retry without ever clearing the queued change, 2021 /// so we drop pending sends that target the zone, mirror the fetch-side 2022 /// deletion handling on the local game (delete on private, mark 2023 /// access-revoked on shared), and notify upstream observers. Without this, 2024 /// `Last Error` stays stuck on `Failed to send changes` indefinitely. 2025 /// Internal-rather-than-private so the test suite can drive it directly; 2026 /// `CKSyncEngine.Event` payloads have no public initializer so we cannot 2027 /// exercise `handleSentRecordZoneChanges` end-to-end. 2028 func applyZoneOrphaning( 2029 _ zones: Set<CKRecordZone.ID>, 2030 isPrivate: Bool 2031 ) async { 2032 let engine = isPrivate ? privateEngine : sharedEngine 2033 if let engine { 2034 let toRemove = engine.state.pendingRecordZoneChanges.filter { change in 2035 switch change { 2036 case .saveRecord(let id): 2037 return zones.contains(id.zoneID) 2038 case .deleteRecord(let id): 2039 return zones.contains(id.zoneID) 2040 @unknown default: 2041 return false 2042 } 2043 } 2044 if !toRemove.isEmpty { 2045 engine.state.remove(pendingRecordZoneChanges: toRemove) 2046 } 2047 } 2048 2049 for (name, _) in pendingPings { 2050 guard let gameID = gameID(fromRecordName: name) else { continue } 2051 if zones.contains(where: { $0.zoneName == "game-\(gameID.uuidString)" }) { 2052 pendingPings.removeValue(forKey: name) 2053 } 2054 } 2055 2056 let ctx = persistence.container.newBackgroundContext() 2057 ctx.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump 2058 let (removedIDs, revokedIDs, failureMessages): ([UUID], [UUID], [String]) = ctx.performAndWait { 2059 var removed: [UUID] = [] 2060 var revoked: [UUID] = [] 2061 var messages: [String] = [] 2062 for zone in zones { 2063 let zoneName = zone.zoneName 2064 guard zoneName.hasPrefix("game-") else { continue } 2065 let req = NSFetchRequest<GameEntity>(entityName: "GameEntity") 2066 req.predicate = NSPredicate(format: "ckZoneName == %@", zoneName) 2067 req.fetchLimit = 1 2068 guard let entity = try? ctx.fetch(req).first else { continue } 2069 if isPrivate { 2070 if let id = entity.id { removed.append(id) } 2071 ctx.delete(entity) 2072 } else { 2073 if !entity.isAccessRevoked, let id = entity.id { 2074 revoked.append(id) 2075 } 2076 entity.isAccessRevoked = true 2077 } 2078 } 2079 if ctx.hasChanges { 2080 do { 2081 try ctx.save() 2082 } catch { 2083 let nsError = error as NSError 2084 messages.append( 2085 "orphan-zone ctx.save FAILED — domain=\(nsError.domain) " + 2086 "code=\(nsError.code) \(nsError.localizedDescription)" 2087 ) 2088 } 2089 } 2090 return (removed, revoked, messages) 2091 } 2092 2093 for message in failureMessages { 2094 await trace(message) 2095 } 2096 await trace( 2097 "\(isPrivate ? "private" : "shared") orphaned \(zones.count) zone(s) on send: " + 2098 zones.map(\.zoneName).sorted().joined(separator: ", ") 2099 ) 2100 2101 for id in removedIDs { 2102 if let cb = onGameRemoved { await cb(id) } 2103 } 2104 for id in revokedIDs { 2105 if let cb = onGameAccessRevoked { await cb(id) } 2106 } 2107 } 2108 2109 /// CKSyncEngine reports optimistic-lock conflicts as failed saves, but the 2110 /// error payload often includes the current server record. Adopt only that 2111 /// record's system fields so a retry can use the fresh change tag while 2112 /// preserving the local values that caused the pending save. 2113 private nonisolated func recoverServerChangedSave( 2114 _ error: Error, 2115 failedRecordName: String, 2116 in ctx: NSManagedObjectContext 2117 ) -> Bool { 2118 let nsError = error as NSError 2119 guard nsError.domain == CKErrorDomain, 2120 nsError.code == CKError.serverRecordChanged.rawValue, 2121 let serverRecord = nsError.userInfo[CKRecordChangedErrorServerRecordKey] as? CKRecord, 2122 serverRecord.recordID.recordName == failedRecordName 2123 else { return false } 2124 2125 writeBackSystemFields(record: serverRecord, in: ctx) 2126 return true 2127 } 2128 2129 private nonisolated func writeBackSystemFields( 2130 record: CKRecord, 2131 in ctx: NSManagedObjectContext 2132 ) { 2133 let name = record.recordID.recordName 2134 let entityName: String 2135 if name.hasPrefix("moves-") { entityName = "MovesEntity" } 2136 else if name.hasPrefix("player-") { entityName = "PlayerEntity" } 2137 else if name.hasPrefix("game-") { entityName = "GameEntity" } 2138 else { return } 2139 2140 let req = NSFetchRequest<NSManagedObject>(entityName: entityName) 2141 req.predicate = NSPredicate(format: "ckRecordName == %@", name) 2142 req.fetchLimit = 1 2143 guard let obj = try? ctx.fetch(req).first else { return } 2144 obj.setValue(RecordSerializer.encodeSystemFields(of: record), forKey: "ckSystemFields") 2145 if entityName == "GameEntity" { 2146 obj.setValue(Date(), forKey: "lastSyncedAt") 2147 } 2148 } 2149 2150 /// Clears the `hasPendingSave` guard on `GameEntity` after a successful 2151 /// push so future inbound fetches resume adopting server fields. Called 2152 /// from `handleSentRecordZoneChanges` only on confirmed saves — *not* from 2153 /// the oplock-recovery path, which only adopts a fresh etag for retry. 2154 /// Also clears `hasPushPending`, the one-shot flag that forces the next 2155 /// push to re-include the `puzzleSource` asset (used by the NYT re-upgrade 2156 /// path to replace an already-uploaded puzzle). 2157 private nonisolated func clearPendingSaveFlag( 2158 for recordName: String, 2159 in ctx: NSManagedObjectContext 2160 ) { 2161 let req = NSFetchRequest<GameEntity>(entityName: "GameEntity") 2162 req.predicate = NSPredicate(format: "ckRecordName == %@", recordName) 2163 req.fetchLimit = 1 2164 guard let entity = try? ctx.fetch(req).first else { return } 2165 entity.hasPendingSave = false 2166 entity.hasPushPending = false 2167 } 2168 2169 private nonisolated func markJournalUploaded( 2170 gameID: UUID, 2171 in ctx: NSManagedObjectContext 2172 ) { 2173 let req = NSFetchRequest<GameEntity>(entityName: "GameEntity") 2174 req.predicate = NSPredicate(format: "id == %@", gameID as CVarArg) 2175 req.fetchLimit = 1 2176 guard let entity = try? ctx.fetch(req).first else { return } 2177 entity.journalUploaded = true 2178 } 2179 2180 // MARK: - Logging helpers 2181 2182 private nonisolated func trace(_ message: String) { 2183 print("SyncEngine: \(message)") 2184 } 2185 2186 } 2187 2188 // MARK: - CKSyncEngineDelegate 2189 2190 extension SyncEngine: CKSyncEngineDelegate { 2191 func handleEvent(_ event: CKSyncEngine.Event, syncEngine: CKSyncEngine) async { 2192 let isPrivate = syncEngine === privateEngine 2193 switch event { 2194 case .stateUpdate(let e): 2195 saveEngineState(e.stateSerialization, isPrivate: isPrivate) 2196 2197 case .accountChange(let e): 2198 await trace("account change: \(e.changeType)") 2199 if let onAccountChange { await onAccountChange() } 2200 2201 case .fetchedDatabaseChanges(let e): 2202 await handleFetchedDatabaseChanges(e, isPrivate: isPrivate) 2203 2204 case .fetchedRecordZoneChanges(let e): 2205 await handleFetchedRecordZoneChanges(e, isPrivate: isPrivate) 2206 2207 case .sentDatabaseChanges: 2208 break 2209 2210 case .sentRecordZoneChanges(let e): 2211 await handleSentRecordZoneChanges(e, isPrivate: isPrivate) 2212 2213 case .willFetchChanges, .didFetchChanges, 2214 .willFetchRecordZoneChanges, .didFetchRecordZoneChanges, 2215 .willSendChanges, .didSendChanges: 2216 break 2217 2218 @unknown default: 2219 break 2220 } 2221 } 2222 2223 func nextRecordZoneChangeBatch( 2224 _ context: CKSyncEngine.SendChangesContext, 2225 syncEngine: CKSyncEngine 2226 ) async -> CKSyncEngine.RecordZoneChangeBatch? { 2227 await makeRecordZoneChangeBatch(for: syncEngine) 2228 } 2229 2230 /// Builds the next outbound batch for `engine`, reaping any pending 2231 /// `.saveRecord` whose record can no longer be reconstructed. For a ping 2232 /// that means its ephemeral `pendingPings` payload was lost across a 2233 /// relaunch (CKSyncEngine persists pending changes; the ping body is 2234 /// in-memory only); for game/moves/player it means the Core Data entity 2235 /// was deleted. Either way the save can never succeed, so the change is 2236 /// dropped instead of returning a nil batch that leaves it queued forever 2237 /// — `Pending Changes` would never drain and no error is ever surfaced. 2238 /// Mirrors Apple's CKSyncEngine reference implementation, which reaps 2239 /// un-buildable changes inside the record provider. Internal-rather-than- 2240 /// private so the test suite can drive it directly; the delegate entry 2241 /// point can't be exercised because `CKSyncEngine.SendChangesContext` has 2242 /// no public initializer. 2243 func makeRecordZoneChangeBatch( 2244 for engine: CKSyncEngine 2245 ) async -> CKSyncEngine.RecordZoneChangeBatch? { 2246 let pending = engine.state.pendingRecordZoneChanges 2247 guard !pending.isEmpty else { return nil } 2248 await traceForeignPlayerWrites(in: pending) 2249 let pingSnapshot = pendingPings 2250 let decisionSnapshot = pendingDecisionPayloads 2251 let decisionVersionSnapshot = pendingDecisionVersions 2252 let decisionSystemFieldsSnapshot = decisionSystemFields 2253 return await CKSyncEngine.RecordZoneChangeBatch(pendingChanges: pending) { [weak self] recordID in 2254 guard let self else { return nil } 2255 if let record = self.buildRecord( 2256 for: recordID, 2257 pings: pingSnapshot, 2258 decisions: decisionSnapshot, 2259 decisionVersions: decisionVersionSnapshot, 2260 decisionSystemFields: decisionSystemFieldsSnapshot 2261 ) { 2262 return record 2263 } 2264 engine.state.remove(pendingRecordZoneChanges: [.saveRecord(recordID)]) 2265 return nil 2266 } 2267 } 2268 2269 /// Diagnostic for the recurring "ghost peer": flags any *peer's* Player 2270 /// slot in our outbound batch. A participant must only ever write its own 2271 /// `(game, authorID)` record — `enqueuePlayer` is keyed to the local 2272 /// author. A foreign authorID here means this device is about to upload 2273 /// someone else's presence, which `RecordBuilder` stamps with our own 2274 /// game-wide `lastReadOtherMoveAt` (`RecordBuilder.swift:131`). If that 2275 /// horizon is a live read lease, the peer is resurrected as present with 2276 /// our future `readAt`. We log the value the build will send so a future 2277 /// lease (the ghost) is distinguishable from a current-time close, and so 2278 /// the next recurrence names the culprit in one line rather than leaving it 2279 /// to inference. Silent in the normal case (own slot only), so it adds no 2280 /// noise until the bug actually fires. 2281 private func traceForeignPlayerWrites( 2282 in pending: [CKSyncEngine.PendingRecordZoneChange] 2283 ) async { 2284 guard let localAuthorID = await currentLocalAuthorID() else { return } 2285 var foreign: [(UUID, String)] = [] 2286 for change in pending { 2287 guard case .saveRecord(let recordID) = change, 2288 let (gameID, authorID) = 2289 RecordSerializer.parsePlayerRecordName(recordID.recordName), 2290 authorID != localAuthorID 2291 else { continue } 2292 foreign.append((gameID, authorID)) 2293 } 2294 guard !foreign.isEmpty else { return } 2295 let ctx = persistence.container.newBackgroundContext() 2296 for (gameID, authorID) in foreign { 2297 let readAt: Date? = ctx.performAndWait { 2298 let req = NSFetchRequest<GameEntity>(entityName: "GameEntity") 2299 req.predicate = NSPredicate(format: "id == %@", gameID as CVarArg) 2300 req.fetchLimit = 1 2301 return (try? ctx.fetch(req).first)?.lastReadOtherMoveAt 2302 } 2303 let leaseDesc: String 2304 if let readAt { 2305 let delta = Int(readAt.timeIntervalSinceNow) 2306 leaseDesc = "readAt=\(readAt.ISO8601Format()) " + 2307 (delta > 0 ? "(future +\(delta)s)" : "(past \(-delta)s)") 2308 } else { 2309 leaseDesc = "readAt=nil" 2310 } 2311 await trace( 2312 "‼️ OUTBOUND peer Player[\(gameID.uuidString.prefix(8))] " + 2313 "author=\(authorID.prefix(8)) local=\(localAuthorID.prefix(8)) " + 2314 "\(leaseDesc) — uploading a peer's slot" 2315 ) 2316 } 2317 } 2318 2319 /// Test seam: drives `makeRecordZoneChangeBatch` for the given scope's 2320 /// engine. Mirrors `pendingSaveRecordNames(scope:)`'s scope routing. 2321 func makeRecordZoneChangeBatch( 2322 forTestingScope scope: CKDatabase.Scope 2323 ) async -> CKSyncEngine.RecordZoneChangeBatch? { 2324 let engine = scope == .shared ? sharedEngine : privateEngine 2325 guard let engine else { return nil } 2326 return await makeRecordZoneChangeBatch(for: engine) 2327 } 2328 }