SyncEngine.swift (85729B)
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 } 10 11 extension Notification.Name { 12 /// Posted by `SyncEngine` after applying fetched record zone changes 13 /// that affected one or more games. `userInfo["gameIDs"]` is a 14 /// `Set<UUID>`. `PlayerRoster` observes this to refresh in response 15 /// to remote name updates and new participants joining. 16 static let playerRosterShouldRefresh = Notification.Name("playerRosterShouldRefresh") 17 } 18 19 /// What a Ping record represents. Stored as a string in the CKRecord's 20 /// `kind` field. 21 enum PingKind: String, Sendable { 22 case session 23 case join 24 case win 25 case check 26 case reveal 27 } 28 29 /// Granularity of a check/reveal action. Stored as a string in the CKRecord's 30 /// `scope` field; nil for kinds where it doesn't apply. 31 enum PingScope: String, Sendable { 32 case square 33 case word 34 case puzzle 35 } 36 37 struct Ping: Sendable { 38 let recordName: String 39 let gameID: UUID 40 let authorID: String 41 let playerName: String 42 let puzzleTitle: String 43 let kind: PingKind 44 let scope: PingScope? 45 } 46 47 /// Owns the CloudKit sync lifecycle via two `CKSyncEngine` instances — one for 48 /// the private database (owned games and shares) and one for the shared 49 /// database (joined games). Zone creation, subscription setup, change-token 50 /// management, batching, and retry are all delegated to the framework. 51 /// This actor's job is to: 52 /// 53 /// - Start and persist each engine's state across launches. 54 /// - Translate outbound edits (from `MovesUpdater`) into pending record zone 55 /// changes that CKSyncEngine will batch and send. 56 /// - Apply incoming `Moves`, `Game`, and `Player` records to Core Data and 57 /// replay them onto the `CellEntity` cache. 58 /// - Notify the main actor so the in-memory `Game` stays current. 59 actor SyncEngine { 60 let container: CKContainer 61 let persistence: PersistenceController 62 63 private var privateEngine: CKSyncEngine? 64 private var sharedEngine: CKSyncEngine? 65 66 /// In-memory map for Ping records pending send. Pings have no Core Data 67 /// backing — they're write-once-and-forget — so we stash the minimal data 68 /// here keyed by record name and look it up in `buildRecord`. 69 private var pendingPings: [String: PingPayload] = [:] 70 71 private struct PingPayload { 72 let gameID: UUID 73 let authorID: String 74 let playerName: String 75 let puzzleTitle: String 76 let eventTimestampMs: Int64 77 let kind: PingKind 78 let scope: PingScope? 79 } 80 81 /// Label for the in-flight fetch — surfaced in traces so the diagnostics 82 /// log can distinguish push-driven fetches from polls / foreground / etc. 83 /// `nil` means CKSyncEngine drove the fetch itself (its internal scheduler). 84 private var currentFetchSource: String? 85 /// One-shot flag — set the first time we observe shared-DB content 86 /// arriving via a push-triggered fetch. Confirms the silent-push path is 87 /// actually wired up end-to-end. 88 private var loggedFirstSharedPushPayload = false 89 90 private var onRemoteMovesUpdated: (@MainActor @Sendable (Set<UUID>) async -> Void)? 91 private var onPings: (@MainActor @Sendable ([Ping]) async -> Void)? 92 private var onAccountChange: (@MainActor @Sendable () async -> Void)? 93 private var onGameAccessRevoked: (@MainActor @Sendable (UUID) async -> Void)? 94 private var onGameRemoved: (@MainActor @Sendable (UUID) async -> Void)? 95 private var localAuthorIDProvider: (@MainActor @Sendable () -> String?)? 96 private var tracer: (@MainActor @Sendable (String) -> Void)? 97 private var liveQueryCheckpoints: [String: Date] = [:] 98 private let liveQueryCheckpointOverlap: TimeInterval = 5 99 100 /// Per-scope checkpoint for the background ping fast path. Independent of 101 /// CKSyncEngine's change tokens and of `liveQueryCheckpoints` (which are 102 /// per-zone and Moves/Player oriented). Keyed by databaseScope value 103 /// (0 = private, 1 = shared). 104 private var pingPushCheckpoints: [Int16: Date] = [:] 105 private let pingPushCheckpointOverlap: TimeInterval = 30 106 107 func setTracer(_ t: @MainActor @Sendable @escaping (String) -> Void) { 108 tracer = t 109 } 110 111 func setOnRemoteMovesUpdated(_ cb: @MainActor @Sendable @escaping (Set<UUID>) async -> Void) { 112 onRemoteMovesUpdated = cb 113 } 114 115 func setOnPings(_ cb: @MainActor @Sendable @escaping ([Ping]) async -> Void) { 116 onPings = cb 117 } 118 119 func setOnAccountChange(_ cb: @MainActor @Sendable @escaping () async -> Void) { 120 onAccountChange = cb 121 } 122 123 func setOnGameAccessRevoked(_ cb: @MainActor @Sendable @escaping (UUID) async -> Void) { 124 onGameAccessRevoked = cb 125 } 126 127 func setOnGameRemoved(_ cb: @MainActor @Sendable @escaping (UUID) async -> Void) { 128 onGameRemoved = cb 129 } 130 131 func setLocalAuthorIDProvider(_ cb: @MainActor @Sendable @escaping () -> String?) { 132 localAuthorIDProvider = cb 133 } 134 135 init(container: CKContainer, persistence: PersistenceController) { 136 self.container = container 137 self.persistence = persistence 138 } 139 140 // MARK: - Lifecycle 141 142 /// Database subscription IDs. Stable, per-scope, idempotent on re-creation 143 /// so we can always attempt a save without first listing. 144 private static let privateSubscriptionID = "crossmate-private-db-subscription" 145 private static let sharedSubscriptionID = "crossmate-shared-db-subscription" 146 147 /// Creates both `CKSyncEngine` instances, restoring previously-saved state 148 /// so pending changes and change tokens survive restarts. Call once after 149 /// wiring callbacks. 150 func start() { 151 let bgCtx = persistence.container.newBackgroundContext() 152 153 let privateState: CKSyncEngine.State.Serialization? = bgCtx.performAndWait { 154 guard let data = SyncStateEntity.current(in: bgCtx).ckPrivateEngineState else { 155 return nil 156 } 157 return try? JSONDecoder().decode(CKSyncEngine.State.Serialization.self, from: data) 158 } 159 privateEngine = CKSyncEngine(CKSyncEngine.Configuration( 160 database: container.privateCloudDatabase, 161 stateSerialization: privateState, 162 delegate: self 163 )) 164 165 let sharedState: CKSyncEngine.State.Serialization? = bgCtx.performAndWait { 166 guard let data = SyncStateEntity.current(in: bgCtx).ckSharedEngineState else { 167 return nil 168 } 169 return try? JSONDecoder().decode(CKSyncEngine.State.Serialization.self, from: data) 170 } 171 sharedEngine = CKSyncEngine(CKSyncEngine.Configuration( 172 database: container.sharedCloudDatabase, 173 stateSerialization: sharedState, 174 delegate: self 175 )) 176 177 // CKSyncEngine's automatic subscription creation is unreliable in 178 // practice — diagnostics on real devices showed both scopes with zero 179 // subscriptions even after a healthy initial fetch and push, which 180 // means CloudKit never fires pushes and the engine silently degrades 181 // to its periodic poll. Create the database subscriptions ourselves; 182 // CKDatabase.save is idempotent for an existing subscriptionID. 183 Task { await ensureDatabaseSubscriptions() } 184 } 185 186 private func ensureDatabaseSubscriptions() async { 187 await ensureDatabaseSubscription( 188 database: container.privateCloudDatabase, 189 subscriptionID: Self.privateSubscriptionID, 190 label: "private" 191 ) 192 await ensureDatabaseSubscription( 193 database: container.sharedCloudDatabase, 194 subscriptionID: Self.sharedSubscriptionID, 195 label: "shared" 196 ) 197 } 198 199 private func ensureDatabaseSubscription( 200 database: CKDatabase, 201 subscriptionID: String, 202 label: String 203 ) async { 204 do { 205 let existing = try await database.allSubscriptions() 206 if existing.contains(where: { $0.subscriptionID == subscriptionID }) { 207 await trace("\(label) subscription already present (\(subscriptionID))") 208 return 209 } 210 } catch { 211 await trace("\(label) allSubscriptions probe failed: \(describe(error)) — attempting save anyway") 212 } 213 let subscription = CKDatabaseSubscription(subscriptionID: subscriptionID) 214 let info = CKSubscription.NotificationInfo() 215 info.shouldSendContentAvailable = true 216 subscription.notificationInfo = info 217 do { 218 _ = try await database.save(subscription) 219 await trace("\(label) subscription created (\(subscriptionID))") 220 } catch { 221 await trace("\(label) subscription save FAILED: \(describe(error))") 222 } 223 } 224 225 // MARK: - Outbound 226 227 /// Registers each game's local-device Moves record as a pending save and 228 /// kicks the affected engines to send immediately. Called by the 229 /// `MovesUpdater` sink after the device's `MovesEntity` row has been 230 /// merged and persisted; the sink already debounces edits, so this fires 231 /// at most once per typing burst per game. Routes per-game to the correct 232 /// engine. Going through `sendChanges()` rather than relying on the 233 /// framework's own scheduler matters because every keystroke targets the 234 /// same `CKRecord.ID` — the scheduler treats repeated `state.add` calls 235 /// as the same already-queued intent and may sit on the change for a 236 /// while before deciding to ship it. 237 func enqueueMoves(gameIDs: Set<UUID>) { 238 guard !gameIDs.isEmpty else { return } 239 var kickPrivate = false 240 var kickShared = false 241 let ctx = persistence.container.newBackgroundContext() 242 ctx.performAndWait { 243 for gameID in gameIDs { 244 guard let info = zoneInfo(forGameID: gameID, in: ctx) else { continue } 245 let isShared = info.scope == 1 246 let engine = isShared ? sharedEngine : privateEngine 247 guard let engine else { continue } 248 let req = NSFetchRequest<MovesEntity>(entityName: "MovesEntity") 249 req.predicate = NSPredicate( 250 format: "game.id == %@ AND deviceID == %@", 251 gameID as CVarArg, 252 RecordSerializer.localDeviceID 253 ) 254 req.fetchLimit = 1 255 guard let entity = try? ctx.fetch(req).first, 256 let recordName = entity.ckRecordName 257 else { continue } 258 let recordID = CKRecord.ID(recordName: recordName, zoneID: info.zoneID) 259 engine.state.add(pendingRecordZoneChanges: [.saveRecord(recordID)]) 260 if isShared { kickShared = true } else { kickPrivate = true } 261 } 262 } 263 if kickPrivate, let engine = privateEngine { 264 Task { try? await engine.sendChanges() } 265 } 266 if kickShared, let engine = sharedEngine { 267 Task { try? await engine.sendChanges() } 268 } 269 } 270 271 /// Re-enqueues locally persisted Moves rows that do not yet have CloudKit 272 /// system fields. Covers crash/relaunch recovery and any save that reached 273 /// Core Data before CKSyncEngine recorded the pending change. 274 @discardableResult 275 func enqueueUnconfirmedMoves() -> Int { 276 let ctx = persistence.container.newBackgroundContext() 277 let gameIDs: Set<UUID> = ctx.performAndWait { 278 let req = NSFetchRequest<MovesEntity>(entityName: "MovesEntity") 279 req.predicate = NSPredicate( 280 format: "ckSystemFields == nil AND deviceID == %@", 281 RecordSerializer.localDeviceID 282 ) 283 let entities = (try? ctx.fetch(req)) ?? [] 284 return Set(entities.compactMap { $0.game?.id }) 285 } 286 enqueueMoves(gameIDs: gameIDs) 287 return gameIDs.count 288 } 289 290 /// Registers record deletions as pending sends. Extracts the game UUID 291 /// from the record name and routes to the correct engine. 292 /// Registers the game's CloudKit zone for deletion. Each game owns its 293 /// own zone, so this removes all remote records for the puzzle, including 294 /// moves, player records, session pings, and share metadata. 295 func enqueueDeleteGame(_ deletion: GameCloudDeletion) { 296 let zoneID = CKRecordZone.ID( 297 zoneName: deletion.ckZoneName, 298 ownerName: deletion.ckZoneOwnerName 299 ) 300 let engine = deletion.databaseScope == 1 ? sharedEngine : privateEngine 301 guard let engine else { return } 302 engine.state.add(pendingDatabaseChanges: [.deleteZone(zoneID)]) 303 Task { try? await engine.sendChanges() } 304 } 305 306 /// Registers a Ping record as a pending send. Pings cover session-start, 307 /// join, win, check, and reveal events; sender-only state — the payload is 308 /// stashed in `pendingPings` and only used to build the outgoing 309 /// `CKRecord`; nothing is persisted. 310 func enqueuePing( 311 kind: PingKind, 312 scope: PingScope?, 313 gameID: UUID, 314 authorID: String, 315 playerName: String 316 ) { 317 let ctx = persistence.container.newBackgroundContext() 318 let zoneAndTitle: (info: ZoneInfo, title: String)? = ctx.performAndWait { 319 guard let info = self.zoneInfo(forGameID: gameID, in: ctx) else { return nil } 320 let req = NSFetchRequest<GameEntity>(entityName: "GameEntity") 321 req.predicate = NSPredicate(format: "id == %@", gameID as CVarArg) 322 req.fetchLimit = 1 323 let entity = try? ctx.fetch(req).first 324 let title = Self.notificationTitle(for: entity) 325 return (info, title) 326 } 327 guard let zoneAndTitle else { return } 328 let engine = zoneAndTitle.info.scope == 1 ? sharedEngine : privateEngine 329 guard let engine else { return } 330 let eventTimestampMs = Int64(Date().timeIntervalSince1970 * 1000) 331 let recordName = RecordSerializer.recordName( 332 forPingInGame: gameID, 333 authorID: authorID, 334 eventTimestampMs: eventTimestampMs 335 ) 336 pendingPings[recordName] = PingPayload( 337 gameID: gameID, 338 authorID: authorID, 339 playerName: playerName, 340 puzzleTitle: zoneAndTitle.title, 341 eventTimestampMs: eventTimestampMs, 342 kind: kind, 343 scope: scope 344 ) 345 let recordID = CKRecord.ID(recordName: recordName, zoneID: zoneAndTitle.info.zoneID) 346 engine.state.add(pendingRecordZoneChanges: [.saveRecord(recordID)]) 347 Task { try? await engine.sendChanges() } 348 } 349 350 /// Deletes transient Ping records for a completed owned game while keeping 351 /// every `.win` ping. Participants see the owner's zone through the share, 352 /// so the owner-side deletion removes the records from the cooperative 353 /// game without risking the final win notification. 354 func deleteNonWinPings(forCompletedGame gameID: UUID) async throws { 355 let ctx = persistence.container.newBackgroundContext() 356 guard let info = zoneInfo(forGameID: gameID, in: ctx), 357 info.scope == 0 358 else { return } 359 360 removePendingNonWinPings(for: gameID, zoneID: info.zoneID) 361 362 let records = try await queryLiveRecords( 363 type: "Ping", 364 database: container.privateCloudDatabase, 365 zoneID: info.zoneID, 366 since: nil, 367 desiredKeys: ["kind"] 368 ) 369 let recordIDsToDelete = records.compactMap { record -> CKRecord.ID? in 370 guard (record["kind"] as? String) != PingKind.win.rawValue else { return nil } 371 return record.recordID 372 } 373 try await deleteRecords(withIDs: recordIDsToDelete, in: container.privateCloudDatabase) 374 if !recordIDsToDelete.isEmpty { 375 await trace("ping cleanup: deleted \(recordIDsToDelete.count) non-win ping(s) for \(gameID.uuidString)") 376 } 377 } 378 379 private func removePendingNonWinPings(for gameID: UUID, zoneID: CKRecordZone.ID) { 380 let namesToRemove = Set(pendingPings.compactMap { name, payload in 381 payload.gameID == gameID && payload.kind != .win ? name : nil 382 }) 383 guard !namesToRemove.isEmpty else { return } 384 385 for name in namesToRemove { 386 pendingPings.removeValue(forKey: name) 387 } 388 guard let privateEngine else { return } 389 let changesToRemove = privateEngine.state.pendingRecordZoneChanges.filter { change in 390 switch change { 391 case .saveRecord(let id): 392 return id.zoneID == zoneID && namesToRemove.contains(id.recordName) 393 case .deleteRecord: 394 return false 395 @unknown default: 396 return false 397 } 398 } 399 if !changesToRemove.isEmpty { 400 privateEngine.state.remove(pendingRecordZoneChanges: changesToRemove) 401 } 402 } 403 404 private nonisolated static func notificationTitle(for entity: GameEntity?) -> String { 405 guard let entity else { return "" } 406 return PuzzleNotificationText.title( 407 entity.title ?? "", 408 publisher: entity.cachedPublisher, 409 date: entity.cachedPuzzleDate 410 ) 411 } 412 413 /// Registers a Player record as a pending send. Used by `PlayerNamePublisher` 414 /// when the local user renames; one record per (game, authorID), so 415 /// participants only ever write their own slot. 416 func enqueuePlayerRecord(gameID: UUID, authorID: String) { 417 let ctx = persistence.container.newBackgroundContext() 418 guard let info = zoneInfo(forGameID: gameID, in: ctx) else { return } 419 let engine = info.scope == 1 ? sharedEngine : privateEngine 420 guard let engine else { return } 421 let recordName = RecordSerializer.recordName(forPlayerInGame: gameID, authorID: authorID) 422 let recordID = CKRecord.ID(recordName: recordName, zoneID: info.zoneID) 423 engine.state.add(pendingRecordZoneChanges: [.saveRecord(recordID)]) 424 Task { try? await engine.sendChanges() } 425 } 426 427 /// Registers a Game record as a pending send and ensures its zone is 428 /// created in CloudKit first. Called when a new game is created locally. 429 func enqueueGame(ckRecordName: String) { 430 guard let gameID = gameID(fromRecordName: ckRecordName) else { return } 431 let ctx = persistence.container.newBackgroundContext() 432 guard let info = zoneInfo(forGameID: gameID, in: ctx) else { return } 433 let engine = info.scope == 1 ? sharedEngine : privateEngine 434 guard let engine else { return } 435 // Save the zone before the game record so it exists when records arrive. 436 engine.state.add(pendingDatabaseChanges: [.saveZone(CKRecordZone(zoneID: info.zoneID))]) 437 let recordID = CKRecord.ID(recordName: ckRecordName, zoneID: info.zoneID) 438 engine.state.add(pendingRecordZoneChanges: [.saveRecord(recordID)]) 439 Task { try? await engine.sendChanges() } 440 } 441 442 // MARK: - Explicit sync triggers (called by AppServices / diagnostics view) 443 444 func fetchChanges(source: String = "manual") async throws { 445 currentFetchSource = source 446 defer { currentFetchSource = nil } 447 async let p: Void = privateEngine?.fetchChanges() ?? () 448 async let s: Void = sharedEngine?.fetchChanges() ?? () 449 _ = try await (p, s) 450 } 451 452 /// Push-only fallback path for the currently open game. On device, 453 /// CKSyncEngine.fetchChanges() can return successfully from a silent-push 454 /// wake without delivering database or record-zone events until a later 455 /// foreground fetch. This direct pull is deliberately narrow: it refreshes 456 /// the active game's Game record and recently-modified Moves/Player 457 /// records in that game's zone, then applies them through the same 458 /// idempotent merge path used by CKSyncEngine events. Event-like records 459 /// such as Ping are intentionally ignored because live play only needs the 460 /// current collaboration state. 461 @discardableResult 462 func fetchPushChangesDirect(scope: CKDatabase.Scope, gameID: UUID) async throws -> Bool { 463 let database: CKDatabase 464 let scopeValue: Int16 465 let label: String 466 switch scope { 467 case .private: 468 database = container.privateCloudDatabase 469 scopeValue = 0 470 label = "private" 471 case .shared: 472 database = container.sharedCloudDatabase 473 scopeValue = 1 474 label = "shared" 475 case .public: 476 return false 477 @unknown default: 478 return false 479 } 480 481 let ctx = persistence.container.newBackgroundContext() 482 guard let info = zoneInfo(forGameID: gameID, in: ctx), 483 info.scope == scopeValue 484 else { 485 await trace("\(label) live query skipped: no active game in scope") 486 return false 487 } 488 489 let checkpointKey = "\(scopeValue):\(gameID.uuidString)" 490 let since = liveQueryCheckpoints[checkpointKey]? 491 .addingTimeInterval(-liveQueryCheckpointOverlap) 492 493 let gameRecordID = CKRecord.ID( 494 recordName: RecordSerializer.recordName(forGameID: gameID), 495 zoneID: info.zoneID 496 ) 497 // The Game fetch and the Moves/Player queries are independent CK 498 // round-trips. Fire them in parallel so total latency is bounded by 499 // the slowest of the three rather than their sum. 500 async let gameResultsTask = database.records( 501 for: [gameRecordID], 502 desiredKeys: ["title", "completedAt", "shareRecordName"] 503 ) 504 async let movesTask = queryLiveRecords( 505 type: "Moves", 506 database: database, 507 zoneID: info.zoneID, 508 since: since, 509 desiredKeys: ["authorID", "deviceID", "cells", "updatedAt"] 510 ) 511 async let playersTask = queryLiveRecords( 512 type: "Player", 513 database: database, 514 zoneID: info.zoneID, 515 since: since, 516 desiredKeys: ["authorID", "name", "updatedAt", "selRow", "selCol", "selDir"] 517 ) 518 let gameResults = try await gameResultsTask 519 let moves = try await movesTask 520 let players = try await playersTask 521 522 var records: [CKRecord] = [] 523 let fetchedGameRecord: Bool 524 if case .success(let record)? = gameResults[gameRecordID] { 525 records.append(record) 526 fetchedGameRecord = true 527 } else { 528 fetchedGameRecord = false 529 } 530 records.append(contentsOf: moves) 531 records.append(contentsOf: players) 532 533 await applyDirectRecordZoneChanges( 534 records: records, 535 deletions: [], 536 scopeValue: scopeValue 537 ) 538 539 let latestModification = records.compactMap(\.modificationDate).max() 540 if let latestModification { 541 liveQueryCheckpoints[checkpointKey] = latestModification 542 } 543 544 await trace( 545 "\(label) live query fetch \(gameID.uuidString.prefix(8)): " + 546 "game=\(fetchedGameRecord ? 1 : 0), " + 547 "moves=\(moves.count), players=\(players.count)" 548 ) 549 return true 550 } 551 552 /// Background-wake fast path for surfacing collaborator notifications. 553 /// Queries every known zone in the given scope for Ping records modified 554 /// since the per-scope checkpoint and feeds them to `onPings`. Bypasses 555 /// `CKSyncEngine.fetchChanges()` because that path can return without 556 /// delivering events from a silent-push wake (same Apple quirk that 557 /// motivated `fetchPushChangesDirect`). Moves / Player / Game records are 558 /// deliberately left for the engine-driven or foreground fetch. 559 @discardableResult 560 func fetchPushPingsDirect(scope: CKDatabase.Scope) async throws -> Int { 561 let database: CKDatabase 562 let scopeValue: Int16 563 let label: String 564 switch scope { 565 case .private: 566 database = container.privateCloudDatabase 567 scopeValue = 0 568 label = "private" 569 case .shared: 570 database = container.sharedCloudDatabase 571 scopeValue = 1 572 label = "shared" 573 case .public: 574 return 0 575 @unknown default: 576 return 0 577 } 578 579 let ctx = persistence.container.newBackgroundContext() 580 let zones = knownZones(forScope: scopeValue, in: ctx) 581 guard !zones.isEmpty else { 582 await trace("\(label) ping fast-path: no known zones") 583 return 0 584 } 585 586 let scopeCheckpoint = pingPushCheckpoints[scopeValue]? 587 .addingTimeInterval(-pingPushCheckpointOverlap) 588 589 // Fan the per-zone Ping queries out concurrently. The actor's await 590 // points release isolation between round-trips, so the per-zone CK 591 // requests overlap; a serial N-zone scan becomes a single parallel 592 // batch. Per-zone errors are caught and traced so one transient 593 // failure doesn't suppress notifications from healthy zones. 594 let perZoneRecords = await withTaskGroup(of: [CKRecord].self) { group in 595 for (zoneID, createdAt) in zones { 596 // Scope checkpoint (if present) wins — it's forward-moving 597 // across all zones. On first run for a given scope we fall 598 // back to the game's createdAt floor so the ping that 599 // triggered this wake is still in range, but pings older 600 // than the device's first knowledge of the game are not. 601 let since = scopeCheckpoint 602 ?? createdAt.addingTimeInterval(-pingPushCheckpointOverlap) 603 group.addTask { [weak self] in 604 guard let self else { return [] } 605 do { 606 return try await self.queryLiveRecords( 607 type: "Ping", 608 database: database, 609 zoneID: zoneID, 610 since: since, 611 desiredKeys: ["authorID", "playerName", "puzzleTitle", "kind", "scope"] 612 ) 613 } catch { 614 await self.trace( 615 "\(label) ping fast-path: zone \(zoneID.zoneName) failed: " + 616 "\(error.localizedDescription)" 617 ) 618 return [] 619 } 620 } 621 } 622 var all: [[CKRecord]] = [] 623 for await batch in group { 624 all.append(batch) 625 } 626 return all 627 } 628 let collected: [CKRecord] = perZoneRecords.flatMap { $0 } 629 630 let pings = collected.compactMap(Self.parsePingRecord) 631 if let latest = collected.compactMap(\.modificationDate).max() { 632 pingPushCheckpoints[scopeValue] = latest 633 } 634 await trace( 635 "\(label) ping fast-path: zones=\(zones.count), pings=\(pings.count)" 636 ) 637 638 if !pings.isEmpty, let onPings { 639 await onPings(pings) 640 } 641 return pings.count 642 } 643 644 /// Discovers games whose zones the device has never seen and pulls their 645 /// Game / Moves / Player records directly, bypassing CKSyncEngine. 646 /// 647 /// CKSyncEngine is supposed to deliver database-scope change events that 648 /// announce new zones, but on a silent-push wake those events can be 649 /// withheld until the next foreground (the same quirk that motivated 650 /// `fetchPushChangesDirect` and `fetchPushPingsDirect`). Without zone 651 /// discovery, a game created on one device only appears on a second 652 /// device after CKSyncEngine eventually catches up — which can be a long 653 /// time if the second device only ever opens the app briefly. 654 /// 655 /// Enumerates zones via `CKDatabase.allRecordZones()`, diffs against 656 /// `knownZones`, and pulls every record type we care about for each new 657 /// zone. The pull is unbounded in time because, by definition, the 658 /// device has no checkpoint for a zone it hasn't seen. 659 /// 660 /// Returns the number of newly-discovered zones. 661 @discardableResult 662 func discoverNewZonesDirect(scope: CKDatabase.Scope) async throws -> Int { 663 let database: CKDatabase 664 let scopeValue: Int16 665 let label: String 666 switch scope { 667 case .private: 668 database = container.privateCloudDatabase 669 scopeValue = 0 670 label = "private" 671 case .shared: 672 database = container.sharedCloudDatabase 673 scopeValue = 1 674 label = "shared" 675 case .public: 676 return 0 677 @unknown default: 678 return 0 679 } 680 681 let serverZones = try await database.allRecordZones() 682 let ctx = persistence.container.newBackgroundContext() 683 let known = knownZones(forScope: scopeValue, in: ctx) 684 let knownKeys = Set(known.map { "\($0.zoneID.ownerName)|\($0.zoneID.zoneName)" }) 685 686 let candidates: [CKRecordZone.ID] = serverZones 687 .map(\.zoneID) 688 .filter { id in 689 id != CKRecordZone.ID.default && 690 !knownKeys.contains("\(id.ownerName)|\(id.zoneName)") 691 } 692 693 guard !candidates.isEmpty else { 694 await trace( 695 "\(label) zone discovery: nothing new " + 696 "(server=\(serverZones.count), known=\(known.count))" 697 ) 698 return 0 699 } 700 701 // Two layers of concurrency. Outer: fan the per-zone work out 702 // through a TaskGroup so N candidate zones don't serialize. Inner: 703 // fire Game / Moves / Player against each zone with `async let` so 704 // a single zone's three round-trips also overlap. The Game query 705 // gates whether the zone hosts a Crossmate puzzle, but Moves and 706 // Player against a non-puzzle zone simply return empty, so always 707 // pulling all three in parallel and discarding when Game is empty 708 // is cheaper than waiting on Game first. Per-zone errors are 709 // caught and traced so one bad zone doesn't abort the rest. 710 struct PerZoneResult: Sendable { 711 let records: [CKRecord] 712 let hasGame: Bool 713 } 714 let perZoneResults = await withTaskGroup(of: PerZoneResult.self) { group in 715 for zoneID in candidates { 716 group.addTask { [weak self] in 717 guard let self else { 718 return PerZoneResult(records: [], hasGame: false) 719 } 720 do { 721 async let games = try await self.queryLiveRecords( 722 type: "Game", 723 database: database, 724 zoneID: zoneID, 725 since: nil, 726 desiredKeys: ["title", "completedAt", "shareRecordName", "puzzleSource"] 727 ) 728 async let moves = try await self.queryLiveRecords( 729 type: "Moves", 730 database: database, 731 zoneID: zoneID, 732 since: nil, 733 desiredKeys: ["authorID", "deviceID", "cells", "updatedAt"] 734 ) 735 async let players = try await self.queryLiveRecords( 736 type: "Player", 737 database: database, 738 zoneID: zoneID, 739 since: nil, 740 desiredKeys: ["authorID", "name", "updatedAt", "selRow", "selCol", "selDir"] 741 ) 742 let (g, m, p) = try await (games, moves, players) 743 guard !g.isEmpty else { 744 return PerZoneResult(records: [], hasGame: false) 745 } 746 return PerZoneResult(records: g + m + p, hasGame: true) 747 } catch { 748 await self.trace( 749 "\(label) zone discovery: zone \(zoneID.zoneName) failed: " + 750 "\(error.localizedDescription)" 751 ) 752 return PerZoneResult(records: [], hasGame: false) 753 } 754 } 755 } 756 var all: [PerZoneResult] = [] 757 for await result in group { 758 all.append(result) 759 } 760 return all 761 } 762 let collected: [CKRecord] = perZoneResults.flatMap(\.records) 763 let zonesWithGame = perZoneResults.reduce(into: 0) { $0 += $1.hasGame ? 1 : 0 } 764 765 await applyDirectRecordZoneChanges( 766 records: collected, 767 deletions: [], 768 scopeValue: scopeValue 769 ) 770 771 await trace( 772 "\(label) zone discovery: candidates=\(candidates.count), " + 773 "withGame=\(zonesWithGame), records=\(collected.count)" 774 ) 775 return zonesWithGame 776 } 777 778 /// Pulls incremental updates for every game the device already knows 779 /// about in the given scope, bypassing CKSyncEngine. Pairs with 780 /// `discoverNewZonesDirect` so that pull-to-refresh covers both halves 781 /// of "what might have changed elsewhere": new zones *and* updates to 782 /// existing ones. Each game is dispatched to the existing 783 /// `fetchPushChangesDirect`, which uses the `liveQueryCheckpoints` 784 /// cursor so we only pull Moves/Player records newer than the last 785 /// direct fetch. Per-game errors are caught and traced so one bad zone 786 /// doesn't abort the rest. 787 /// 788 /// Returns the number of games for which the direct fetch reported 789 /// records were applied. 790 @discardableResult 791 func fetchKnownZoneUpdatesDirect(scope: CKDatabase.Scope) async throws -> Int { 792 let scopeValue: Int16 793 let label: String 794 switch scope { 795 case .private: 796 scopeValue = 0 797 label = "private" 798 case .shared: 799 scopeValue = 1 800 label = "shared" 801 case .public: 802 return 0 803 @unknown default: 804 return 0 805 } 806 807 let ctx = persistence.container.newBackgroundContext() 808 let gameIDs = knownGameIDs(forScope: scopeValue, in: ctx) 809 guard !gameIDs.isEmpty else { 810 await trace("\(label) known-zone refresh: no known games") 811 return 0 812 } 813 814 // Fan the per-game fetches out concurrently. Each fetchPushChangesDirect 815 // call hits a different zone with a different checkpoint key, so they 816 // don't race on shared state. The actor still serializes access to 817 // liveQueryCheckpoints at non-await points, but the actual CK round- 818 // trips overlap, turning a serial 1s-per-game wait into a single 819 // parallel batch. 820 let handled = await withTaskGroup(of: Bool.self) { group in 821 for gameID in gameIDs { 822 group.addTask { [weak self] in 823 guard let self else { return false } 824 do { 825 return try await self.fetchPushChangesDirect( 826 scope: scope, 827 gameID: gameID 828 ) 829 } catch { 830 await self.trace( 831 "\(label) known-zone refresh: game " + 832 "\(gameID.uuidString.prefix(8)) failed: " + 833 "\(error.localizedDescription)" 834 ) 835 return false 836 } 837 } 838 } 839 var count = 0 840 for await result in group where result { 841 count += 1 842 } 843 return count 844 } 845 await trace( 846 "\(label) known-zone refresh: games=\(gameIDs.count), handled=\(handled)" 847 ) 848 return handled 849 } 850 851 private func queryLiveRecords( 852 type: CKRecord.RecordType, 853 database: CKDatabase, 854 zoneID: CKRecordZone.ID, 855 since: Date?, 856 desiredKeys: [CKRecord.FieldKey] 857 ) async throws -> [CKRecord] { 858 let since = since ?? Date(timeIntervalSince1970: 0) 859 let predicate = NSPredicate(format: "modificationDate > %@", since as NSDate) 860 let query = CKQuery(recordType: type, predicate: predicate) 861 862 var records: [CKRecord] = [] 863 var result = try await database.records( 864 matching: query, 865 inZoneWith: zoneID, 866 desiredKeys: desiredKeys, 867 resultsLimit: CKQueryOperation.maximumResults 868 ) 869 records.append(contentsOf: result.matchResults.compactMap { _, recordResult in 870 try? recordResult.get() 871 }) 872 873 while let cursor = result.queryCursor { 874 result = try await database.records( 875 continuingMatchFrom: cursor, 876 desiredKeys: desiredKeys, 877 resultsLimit: CKQueryOperation.maximumResults 878 ) 879 records.append(contentsOf: result.matchResults.compactMap { _, recordResult in 880 try? recordResult.get() 881 }) 882 } 883 return records 884 } 885 886 private func deleteRecords( 887 withIDs recordIDs: [CKRecord.ID], 888 in database: CKDatabase 889 ) async throws { 890 guard !recordIDs.isEmpty else { return } 891 let batchSize = 200 892 var index = recordIDs.startIndex 893 while index < recordIDs.endIndex { 894 let end = recordIDs.index(index, offsetBy: batchSize, limitedBy: recordIDs.endIndex) 895 ?? recordIDs.endIndex 896 let batch = Array(recordIDs[index..<end]) 897 try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Void, Error>) in 898 let op = CKModifyRecordsOperation( 899 recordsToSave: nil, 900 recordIDsToDelete: batch 901 ) 902 op.qualityOfService = .utility 903 op.modifyRecordsResultBlock = { result in 904 cont.resume(with: result) 905 } 906 database.add(op) 907 } 908 index = end 909 } 910 } 911 912 private func applyDirectRecordZoneChanges( 913 records: [CKRecord], 914 deletions: [(CKRecord.ID, CKRecord.RecordType)], 915 scopeValue: Int16 916 ) async { 917 guard !records.isEmpty || !deletions.isEmpty else { return } 918 let ctx = persistence.container.newBackgroundContext() 919 ctx.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump 920 let localAuthorID = await currentLocalAuthorID() 921 let (movesUpdatedGameIDs, affectedGameIDs): (Set<UUID>, Set<UUID>) = ctx.performAndWait { 922 var movesUpdated = Set<UUID>() 923 var affected = Set<UUID>() 924 for record in records { 925 switch record.recordType { 926 case "Game": 927 let entity = RecordSerializer.applyGameRecord(record, to: ctx, databaseScope: scopeValue) 928 if let id = entity.id { affected.insert(id) } 929 case "Moves": 930 if let value = RecordSerializer.parseMovesRecord(record) { 931 RecordSerializer.applyMovesRecord( 932 record, 933 value: value, 934 to: ctx, 935 localAuthorID: localAuthorID 936 ) 937 movesUpdated.insert(value.gameID) 938 affected.insert(value.gameID) 939 } 940 case "Player": 941 if let (gameID, _) = RecordSerializer.parsePlayerRecordName(record.recordID.recordName) { 942 self.applyPlayerRecord(record, in: ctx) 943 affected.insert(gameID) 944 } 945 default: 946 break 947 } 948 } 949 for deletion in deletions { 950 self.applyDeletion( 951 recordID: deletion.0, 952 recordType: deletion.1, 953 in: ctx 954 ) 955 if let id = self.gameID(fromRecordName: deletion.0.recordName) { 956 affected.insert(id) 957 } 958 } 959 for gameID in movesUpdated { 960 self.replayCellCache(for: gameID, in: ctx) 961 } 962 if ctx.hasChanges { 963 do { 964 try ctx.save() 965 } catch { 966 let nsError = error as NSError 967 print( 968 "SyncEngine: direct-push ctx.save failed " + 969 "— domain=\(nsError.domain) code=\(nsError.code) " + 970 "\(nsError.localizedDescription)" 971 ) 972 } 973 } 974 return (movesUpdated, affected) 975 } 976 977 if let onRemoteMovesUpdated, !movesUpdatedGameIDs.isEmpty { 978 await onRemoteMovesUpdated(movesUpdatedGameIDs) 979 } 980 if !affectedGameIDs.isEmpty { 981 NotificationCenter.default.post( 982 name: .playerRosterShouldRefresh, 983 object: nil, 984 userInfo: ["gameIDs": affectedGameIDs] 985 ) 986 } 987 } 988 989 func pushChanges() async throws { 990 async let p: Void = privateEngine?.sendChanges() ?? () 991 async let s: Void = sharedEngine?.sendChanges() ?? () 992 _ = try await (p, s) 993 } 994 995 // MARK: - Diagnostics 996 997 struct DiagnosticSnapshot: Sendable { 998 let accountStatus: CKAccountStatus 999 let engineRunning: Bool 1000 let pendingChangesCount: Int 1001 let privatePendingCount: Int 1002 let sharedPendingCount: Int 1003 } 1004 1005 /// Record names of pending `.saveRecord` changes queued on the given 1006 /// scope's engine. Used by tests to verify that outbound enqueues route 1007 /// to the correct database. 1008 func pendingSaveRecordNames(scope: CKDatabase.Scope) -> [String] { 1009 let engine = scope == .shared ? sharedEngine : privateEngine 1010 guard let engine else { return [] } 1011 return engine.state.pendingRecordZoneChanges.compactMap { 1012 if case .saveRecord(let id) = $0 { return id.recordName } 1013 return nil 1014 } 1015 } 1016 1017 /// Zone names queued for deletion on the given scope's engine. Used by 1018 /// tests to verify delete routing after the local GameEntity is gone. 1019 func pendingDeletedZoneNames(scope: CKDatabase.Scope) -> [String] { 1020 let engine = scope == .shared ? sharedEngine : privateEngine 1021 guard let engine else { return [] } 1022 return engine.state.pendingDatabaseChanges.compactMap { 1023 if case .deleteZone(let id) = $0 { return id.zoneName } 1024 return nil 1025 } 1026 } 1027 1028 func diagnosticSnapshot() async -> DiagnosticSnapshot { 1029 let status: CKAccountStatus 1030 do { status = try await container.accountStatus() } 1031 catch { status = .couldNotDetermine } 1032 let running = privateEngine != nil 1033 let privateCount = privateEngine.map { $0.state.pendingRecordZoneChanges.count } ?? 0 1034 let sharedCount = sharedEngine.map { $0.state.pendingRecordZoneChanges.count } ?? 0 1035 return DiagnosticSnapshot( 1036 accountStatus: status, 1037 engineRunning: running, 1038 pendingChangesCount: privateCount + sharedCount, 1039 privatePendingCount: privateCount, 1040 sharedPendingCount: sharedCount 1041 ) 1042 } 1043 1044 /// Runs a series of lightweight CloudKit probes and returns human-readable 1045 /// (name, result) pairs for display in the diagnostics view. 1046 func probeContainer() async -> [(name: String, result: String)] { 1047 var results: [(String, String)] = [] 1048 results.append(("containerIdentifier", container.containerIdentifier ?? "nil")) 1049 do { 1050 let s = try await container.accountStatus() 1051 results.append(("accountStatus", describeStatus(s))) 1052 } catch { 1053 results.append(("accountStatus", describe(error))) 1054 } 1055 do { 1056 let id = try await container.userRecordID() 1057 results.append(("userRecordID", id.recordName)) 1058 } catch { 1059 results.append(("userRecordID", describe(error))) 1060 } 1061 do { 1062 let zones = try await container.privateCloudDatabase.allRecordZones() 1063 let names = zones.map(\.zoneID.zoneName).joined(separator: ", ") 1064 results.append(("privateZones", "\(zones.count) zone(s): [\(names)]")) 1065 } catch { 1066 results.append(("privateZones", describe(error))) 1067 } 1068 do { 1069 let zones = try await container.sharedCloudDatabase.allRecordZones() 1070 let names = zones.map(\.zoneID.zoneName).joined(separator: ", ") 1071 results.append(("sharedZones", "\(zones.count) zone(s): [\(names)]")) 1072 } catch { 1073 results.append(("sharedZones", describe(error))) 1074 } 1075 // CKSyncEngine creates a CKDatabaseSubscription per scope on first 1076 // start. If subscription creation silently failed, no push will ever 1077 // fire for that scope — surface what's actually present so a missing 1078 // entry is visible from the diagnostics view rather than diagnosed 1079 // by elimination. 1080 results.append(await probeSubscriptions(database: container.privateCloudDatabase, label: "privateSubs")) 1081 results.append(await probeSubscriptions(database: container.sharedCloudDatabase, label: "sharedSubs")) 1082 return results 1083 } 1084 1085 private func probeSubscriptions( 1086 database: CKDatabase, 1087 label: String 1088 ) async -> (String, String) { 1089 do { 1090 let subs = try await database.allSubscriptions() 1091 if subs.isEmpty { 1092 return (label, "0 subscriptions — pushes will not fire") 1093 } 1094 let descriptions = subs.map { sub -> String in 1095 let kind: String 1096 switch sub { 1097 case is CKDatabaseSubscription: kind = "database" 1098 case is CKQuerySubscription: kind = "query" 1099 case is CKRecordZoneSubscription: kind = "zone" 1100 default: kind = "other(\(type(of: sub)))" 1101 } 1102 let silent = sub.notificationInfo?.shouldSendContentAvailable == true ? "silent" : "alert-only" 1103 return "\(kind):\(sub.subscriptionID)[\(silent)]" 1104 } 1105 return (label, "\(subs.count): [\(descriptions.joined(separator: ", "))]") 1106 } catch { 1107 return (label, describe(error)) 1108 } 1109 } 1110 1111 /// Fetches a single record by ID for the in-app record editor. Bypasses 1112 /// CKSyncEngine's tracked changes — caller is responsible for triggering a 1113 /// reconciling fetch if the record corresponds to a tracked local entity. 1114 func fetchRecordForEdit( 1115 scope: CKDatabase.Scope, 1116 recordID: CKRecord.ID 1117 ) async throws -> CKRecord { 1118 let database = scope == .shared 1119 ? container.sharedCloudDatabase 1120 : container.privateCloudDatabase 1121 return try await database.record(for: recordID) 1122 } 1123 1124 /// Saves a record edited in the in-app record editor and runs a follow-up 1125 /// `fetchChanges` so any locally-tracked entity picks up the new server 1126 /// change tag via CKSyncEngine rather than going stale. 1127 func saveRecordForEdit( 1128 scope: CKDatabase.Scope, 1129 record: CKRecord 1130 ) async throws -> CKRecord { 1131 let database = scope == .shared 1132 ? container.sharedCloudDatabase 1133 : container.privateCloudDatabase 1134 let saved = try await database.save(record) 1135 try? await fetchChanges(source: "record-editor") 1136 return saved 1137 } 1138 1139 /// Clears the saved state for both engines and replaces the in-memory 1140 /// engine instances so subsequent fetches walk every zone from scratch. 1141 /// Clearing the persisted state alone is ineffective: the running engines 1142 /// hold their tokens in memory and the next `stateUpdate` event saves 1143 /// those tokens back, so the wipe is undone before the user can act on it. 1144 /// Pending records already in CloudKit are unaffected. Locally-unconfirmed 1145 /// moves are re-enqueued so the new engines push them on the next cycle. 1146 func resetSyncState() async { 1147 let ctx = persistence.container.newBackgroundContext() 1148 ctx.performAndWait { 1149 let entity = SyncStateEntity.current(in: ctx) 1150 entity.ckPrivateEngineState = nil 1151 entity.ckSharedEngineState = nil 1152 try? ctx.save() 1153 } 1154 privateEngine = CKSyncEngine(CKSyncEngine.Configuration( 1155 database: container.privateCloudDatabase, 1156 stateSerialization: nil, 1157 delegate: self 1158 )) 1159 sharedEngine = CKSyncEngine(CKSyncEngine.Configuration( 1160 database: container.sharedCloudDatabase, 1161 stateSerialization: nil, 1162 delegate: self 1163 )) 1164 pendingPings = [:] 1165 pingPushCheckpoints = [:] 1166 liveQueryCheckpoints = [:] 1167 loggedFirstSharedPushPayload = false 1168 _ = enqueueUnconfirmedMoves() 1169 } 1170 1171 // MARK: - Private helpers 1172 1173 private func currentLocalAuthorID() async -> String? { 1174 guard let localAuthorIDProvider else { return nil } 1175 return await MainActor.run { 1176 localAuthorIDProvider() 1177 } 1178 } 1179 1180 private struct ZoneInfo { 1181 let scope: Int16 1182 let zoneID: CKRecordZone.ID 1183 } 1184 1185 /// Looks up a game's scope and zone ID from Core Data. Returns `nil` if 1186 /// the entity can't be found. Not `async` — uses `performAndWait` so it 1187 /// can be called from non-async actor context. 1188 private nonisolated func zoneInfo( 1189 forGameID gameID: UUID, 1190 in ctx: NSManagedObjectContext 1191 ) -> ZoneInfo? { 1192 ctx.performAndWait { 1193 let req = NSFetchRequest<GameEntity>(entityName: "GameEntity") 1194 req.predicate = NSPredicate(format: "id == %@", gameID as CVarArg) 1195 req.fetchLimit = 1 1196 guard let entity = try? ctx.fetch(req).first else { return nil } 1197 let zoneName = entity.ckZoneName ?? "game-\(gameID.uuidString)" 1198 let ownerName = entity.ckZoneOwnerName ?? CKCurrentUserDefaultName 1199 return ZoneInfo( 1200 scope: entity.databaseScope, 1201 zoneID: CKRecordZone.ID(zoneName: zoneName, ownerName: ownerName) 1202 ) 1203 } 1204 } 1205 1206 /// Game UUIDs for every locally-known *in-progress* game in the given 1207 /// database scope. Used by the known-zone refresh path so each game can 1208 /// be routed through `fetchPushChangesDirect`. Games with a non-nil 1209 /// `completedAt` are excluded: once a game is completed no further moves 1210 /// or player updates can arrive, so refreshing those zones is wasted 1211 /// round-trips. 1212 private nonisolated func knownGameIDs( 1213 forScope scope: Int16, 1214 in ctx: NSManagedObjectContext 1215 ) -> [UUID] { 1216 ctx.performAndWait { 1217 let req = NSFetchRequest<GameEntity>(entityName: "GameEntity") 1218 req.predicate = NSPredicate( 1219 format: "databaseScope == %d AND completedAt == nil", 1220 scope 1221 ) 1222 guard let entities = try? ctx.fetch(req) else { return [] } 1223 return entities.compactMap(\.id) 1224 } 1225 } 1226 1227 /// Enumerates every known game zone for the given database scope, paired 1228 /// with the `createdAt` of the corresponding GameEntity. The createdAt 1229 /// timestamp is used as the per-zone floor for the ping fast path: pings 1230 /// older than the moment this device first knew about the game can't be 1231 /// of interest (for shared games, they pre-date our join; for owned 1232 /// games, they pre-date the game's existence). 1233 private nonisolated func knownZones( 1234 forScope scope: Int16, 1235 in ctx: NSManagedObjectContext 1236 ) -> [(zoneID: CKRecordZone.ID, createdAt: Date)] { 1237 ctx.performAndWait { 1238 let req = NSFetchRequest<GameEntity>(entityName: "GameEntity") 1239 req.predicate = NSPredicate(format: "databaseScope == %d", scope) 1240 guard let entities = try? ctx.fetch(req) else { return [] } 1241 var seen = Set<String>() 1242 var result: [(CKRecordZone.ID, Date)] = [] 1243 for entity in entities { 1244 guard let gameID = entity.id else { continue } 1245 let zoneName = entity.ckZoneName ?? "game-\(gameID.uuidString)" 1246 let ownerName = entity.ckZoneOwnerName ?? CKCurrentUserDefaultName 1247 let key = "\(ownerName)|\(zoneName)" 1248 guard seen.insert(key).inserted else { continue } 1249 let createdAt = entity.createdAt ?? Date(timeIntervalSince1970: 0) 1250 result.append((CKRecordZone.ID(zoneName: zoneName, ownerName: ownerName), createdAt)) 1251 } 1252 return result 1253 } 1254 } 1255 1256 /// Extracts the game UUID from any of our record name formats: 1257 /// `game-<UUID>`, `moves-<UUID>-…`, `player-<UUID>-…`, `ping-<UUID>-…`. 1258 private nonisolated func gameID(fromRecordName name: String) -> UUID? { 1259 if name.hasPrefix("game-") { 1260 return UUID(uuidString: String(name.dropFirst("game-".count))) 1261 } 1262 let prefix: String 1263 if name.hasPrefix("moves-") { prefix = "moves-" } 1264 else if name.hasPrefix("player-") { prefix = "player-" } 1265 else if name.hasPrefix("ping-") { prefix = "ping-" } 1266 else { return nil } 1267 let rest = name.dropFirst(prefix.count) 1268 return UUID(uuidString: String(rest.prefix(36))) 1269 } 1270 1271 private func trace(_ message: String) async { 1272 guard let tracer else { return } 1273 await tracer(message) 1274 } 1275 1276 private func saveEngineState( 1277 _ serialization: CKSyncEngine.State.Serialization, 1278 isPrivate: Bool 1279 ) { 1280 let ctx = persistence.container.newBackgroundContext() 1281 ctx.performAndWait { 1282 guard let data = try? JSONEncoder().encode(serialization) else { return } 1283 let entity = SyncStateEntity.current(in: ctx) 1284 if isPrivate { 1285 entity.ckPrivateEngineState = data 1286 } else { 1287 entity.ckSharedEngineState = data 1288 } 1289 try? ctx.save() 1290 } 1291 } 1292 1293 /// Builds the `CKRecord` for a pending change. Uses the zone ID already 1294 /// embedded in the `recordID` — set correctly at enqueue time. 1295 /// `pings` is a snapshot taken from the actor before this is invoked, 1296 /// since the framework calls back synchronously off-actor. 1297 private nonisolated func buildRecord( 1298 for recordID: CKRecord.ID, 1299 pings: [String: PingPayload] 1300 ) -> CKRecord? { 1301 let name = recordID.recordName 1302 let zoneID = recordID.zoneID 1303 if name.hasPrefix("ping-") { 1304 guard let payload = pings[name] else { return nil } 1305 return RecordSerializer.pingRecord( 1306 gameID: payload.gameID, 1307 authorID: payload.authorID, 1308 playerName: payload.playerName, 1309 puzzleTitle: payload.puzzleTitle, 1310 eventTimestampMs: payload.eventTimestampMs, 1311 kind: payload.kind, 1312 scope: payload.scope, 1313 zone: zoneID 1314 ) 1315 } 1316 let ctx = persistence.container.newBackgroundContext() 1317 return ctx.performAndWait { 1318 if name.hasPrefix("game-") { 1319 let req = NSFetchRequest<GameEntity>(entityName: "GameEntity") 1320 req.predicate = NSPredicate(format: "ckRecordName == %@", name) 1321 req.fetchLimit = 1 1322 guard let entity = try? ctx.fetch(req).first else { return nil } 1323 return RecordSerializer.gameRecord( 1324 from: entity, 1325 recordID: recordID, 1326 includePuzzleSource: entity.ckSystemFields == nil 1327 ) 1328 } else if name.hasPrefix("moves-") { 1329 let req = NSFetchRequest<MovesEntity>(entityName: "MovesEntity") 1330 req.predicate = NSPredicate(format: "ckRecordName == %@", name) 1331 req.fetchLimit = 1 1332 guard let entity = try? ctx.fetch(req).first, 1333 let gameID = entity.game?.id, 1334 let authorID = entity.authorID, 1335 let deviceID = entity.deviceID, 1336 let updatedAt = entity.updatedAt 1337 else { return nil } 1338 let cells = (entity.cells.flatMap { try? MovesCodec.decode($0) }) ?? [:] 1339 let value = MovesValue( 1340 gameID: gameID, 1341 authorID: authorID, 1342 deviceID: deviceID, 1343 cells: cells, 1344 updatedAt: updatedAt 1345 ) 1346 return try? RecordSerializer.movesRecord( 1347 from: value, 1348 zone: zoneID, 1349 systemFields: entity.ckSystemFields 1350 ) 1351 } else if name.hasPrefix("player-") { 1352 let req = NSFetchRequest<PlayerEntity>(entityName: "PlayerEntity") 1353 req.predicate = NSPredicate(format: "ckRecordName == %@", name) 1354 req.fetchLimit = 1 1355 guard let entity = try? ctx.fetch(req).first, 1356 let gameID = entity.game?.id, 1357 let authorID = entity.authorID, 1358 let renderedName = entity.name, 1359 let updatedAt = entity.updatedAt 1360 else { return nil } 1361 let selection: PlayerSelection? 1362 if let row = entity.selRow, 1363 let col = entity.selCol, 1364 let dir = entity.selDir, 1365 let direction = Puzzle.Direction(rawValue: dir.intValue) { 1366 selection = PlayerSelection( 1367 row: row.intValue, 1368 col: col.intValue, 1369 direction: direction 1370 ) 1371 } else { 1372 selection = nil 1373 } 1374 return RecordSerializer.playerRecord( 1375 gameID: gameID, 1376 authorID: authorID, 1377 name: renderedName, 1378 updatedAt: updatedAt, 1379 selection: selection, 1380 zone: zoneID, 1381 systemFields: entity.ckSystemFields 1382 ) 1383 } 1384 return nil 1385 } 1386 } 1387 1388 // MARK: - Incoming record application 1389 1390 private nonisolated func applyPlayerRecord( 1391 _ record: CKRecord, 1392 in ctx: NSManagedObjectContext 1393 ) { 1394 let ckName = record.recordID.recordName 1395 guard let (gameID, authorID) = RecordSerializer.parsePlayerRecordName(ckName) else { 1396 return 1397 } 1398 guard let renderedName = record["name"] as? String else { return } 1399 let updatedAt = record["updatedAt"] as? Date 1400 ?? record.modificationDate 1401 ?? Date() 1402 1403 let req = NSFetchRequest<PlayerEntity>(entityName: "PlayerEntity") 1404 req.predicate = NSPredicate(format: "ckRecordName == %@", ckName) 1405 req.fetchLimit = 1 1406 1407 let entity: PlayerEntity 1408 if let existing = try? ctx.fetch(req).first { 1409 entity = existing 1410 } else { 1411 let game = RecordSerializer.ensureGameEntity( 1412 forGameID: gameID, 1413 zoneID: record.recordID.zoneID, 1414 in: ctx 1415 ) 1416 entity = PlayerEntity(context: ctx) 1417 entity.game = game 1418 } 1419 1420 // Always adopt the server's system fields — that's etag tracking and 1421 // is independent of which side has the freshest data. The value 1422 // fields, however, are only adopted when the incoming record is at 1423 // least as new as what we have locally; otherwise a stale-but-current 1424 // server record (e.g. our own pending writes haven't landed yet) 1425 // would clobber the user's live selection on every fetch. 1426 entity.ckRecordName = ckName 1427 entity.ckSystemFields = RecordSerializer.encodeSystemFields(of: record) 1428 entity.authorID = authorID 1429 let localUpdatedAt = entity.updatedAt 1430 let incomingIsFresher = localUpdatedAt.map { updatedAt >= $0 } ?? true 1431 guard incomingIsFresher else { return } 1432 // An empty `name` is what older builds shipped from the selection publisher 1433 // before the fix; treat it as "no information" rather than letting it 1434 // clobber a previously-resolved name. 1435 if !renderedName.isEmpty { 1436 entity.name = renderedName 1437 } 1438 entity.updatedAt = updatedAt 1439 if let selection = RecordSerializer.parsePlayerSelection(from: record) { 1440 entity.selRow = NSNumber(value: Int64(selection.row)) 1441 entity.selCol = NSNumber(value: Int64(selection.col)) 1442 entity.selDir = NSNumber(value: Int64(selection.direction.rawValue)) 1443 } else { 1444 entity.selRow = nil 1445 entity.selCol = nil 1446 entity.selDir = nil 1447 } 1448 } 1449 1450 /// Merges every device's `MovesEntity` row for `gameID` and reconciles the 1451 /// `CellEntity` cache against the resulting grid. Must be called inside a 1452 /// `performAndWait` block on the same context. 1453 private nonisolated func replayCellCache( 1454 for gameID: UUID, 1455 in ctx: NSManagedObjectContext 1456 ) { 1457 let gameReq = NSFetchRequest<GameEntity>(entityName: "GameEntity") 1458 gameReq.predicate = NSPredicate(format: "id == %@", gameID as CVarArg) 1459 gameReq.fetchLimit = 1 1460 guard let game = try? ctx.fetch(gameReq).first else { return } 1461 1462 let movesReq = NSFetchRequest<MovesEntity>(entityName: "MovesEntity") 1463 movesReq.predicate = NSPredicate(format: "game == %@", game) 1464 let movesEntities = (try? ctx.fetch(movesReq)) ?? [] 1465 let values: [MovesValue] = movesEntities.compactMap { Self.movesValue(from: $0) } 1466 let gridState = GridStateMerger.merge(values) 1467 1468 let existingCells = (game.cells as? Set<CellEntity>) ?? [] 1469 var byPosition: [GridPosition: CellEntity] = [:] 1470 for cell in existingCells { 1471 byPosition[GridPosition(row: Int(cell.row), col: Int(cell.col))] = cell 1472 } 1473 1474 for (pos, gridCell) in gridState { 1475 let cell: CellEntity 1476 if let existing = byPosition[pos] { 1477 cell = existing 1478 } else { 1479 cell = CellEntity(context: ctx) 1480 cell.game = game 1481 cell.row = Int16(pos.row) 1482 cell.col = Int16(pos.col) 1483 } 1484 cell.letter = gridCell.letter 1485 cell.markKind = gridCell.markKind 1486 cell.checkedWrong = gridCell.checkedWrong 1487 cell.letterAuthorID = gridCell.authorID 1488 } 1489 1490 for (pos, cell) in byPosition where gridState[pos] == nil { 1491 cell.letter = "" 1492 cell.markKind = 0 1493 cell.checkedWrong = false 1494 cell.letterAuthorID = nil 1495 } 1496 } 1497 1498 /// Hydrates a `MovesValue` from a `MovesEntity`. Returns `nil` if the row 1499 /// is missing required fields (e.g. an unpopulated stub from a partial 1500 /// fetch). 1501 private nonisolated static func movesValue(from entity: MovesEntity) -> MovesValue? { 1502 guard let gameID = entity.game?.id, 1503 let authorID = entity.authorID, 1504 let deviceID = entity.deviceID, 1505 let updatedAt = entity.updatedAt 1506 else { return nil } 1507 let cells = (entity.cells.flatMap { try? MovesCodec.decode($0) }) ?? [:] 1508 return MovesValue( 1509 gameID: gameID, 1510 authorID: authorID, 1511 deviceID: deviceID, 1512 cells: cells, 1513 updatedAt: updatedAt 1514 ) 1515 } 1516 1517 // MARK: - Event handlers 1518 1519 private func handleFetchedDatabaseChanges( 1520 _ event: CKSyncEngine.Event.FetchedDatabaseChanges, 1521 isPrivate: Bool 1522 ) async { 1523 let src = currentFetchSource ?? "framework" 1524 await trace( 1525 "\(isPrivate ? "private" : "shared") db changes [src=\(src)]: " + 1526 "\(event.modifications.count) zone mods, \(event.deletions.count) zone deletions" 1527 ) 1528 1529 // Private-DB zone deletions reflect the user removing one of their own 1530 // games on another device — hard-delete locally so the row stops 1531 // hanging around forever. Shared-DB zone deletions reflect the owner 1532 // removing this account from the share — mark access-revoked instead 1533 // so the UI can surface "no longer have access" rather than silently 1534 // vanishing the row mid-game. Modifications on the shared DB also 1535 // create placeholder GameEntities for newly-joined shares. 1536 let ctx = persistence.container.newBackgroundContext() 1537 ctx.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump 1538 let (removedIDs, revokedIDs): ([UUID], [UUID]) = ctx.performAndWait { 1539 var removed: [UUID] = [] 1540 var revoked: [UUID] = [] 1541 if !isPrivate { 1542 for mod in event.modifications { 1543 let zoneID = mod.zoneID 1544 let zoneName = zoneID.zoneName 1545 guard zoneName.hasPrefix("game-") else { continue } 1546 let req = NSFetchRequest<GameEntity>(entityName: "GameEntity") 1547 req.predicate = NSPredicate(format: "ckZoneName == %@", zoneName) 1548 req.fetchLimit = 1 1549 if (try? ctx.fetch(req).first) == nil { 1550 // Placeholder until the Game record arrives. 1551 let entity = GameEntity(context: ctx) 1552 let uuidString = String(zoneName.dropFirst("game-".count)) 1553 entity.id = UUID(uuidString: uuidString) 1554 entity.ckRecordName = zoneName 1555 entity.ckZoneName = zoneName 1556 entity.ckZoneOwnerName = zoneID.ownerName 1557 entity.databaseScope = 1 1558 entity.title = "Joining\u{2026}" 1559 entity.puzzleSource = "" 1560 entity.createdAt = Date() 1561 entity.updatedAt = Date() 1562 } 1563 } 1564 } 1565 for deletion in event.deletions { 1566 let zoneName = deletion.zoneID.zoneName 1567 guard zoneName.hasPrefix("game-") else { continue } 1568 let req = NSFetchRequest<GameEntity>(entityName: "GameEntity") 1569 req.predicate = NSPredicate(format: "ckZoneName == %@", zoneName) 1570 req.fetchLimit = 1 1571 guard let entity = try? ctx.fetch(req).first else { continue } 1572 if isPrivate { 1573 if let id = entity.id { removed.append(id) } 1574 ctx.delete(entity) 1575 } else { 1576 entity.isAccessRevoked = true 1577 if let id = entity.id { revoked.append(id) } 1578 } 1579 } 1580 if ctx.hasChanges { 1581 do { 1582 try ctx.save() 1583 } catch { 1584 let nsError = error as NSError 1585 print( 1586 "SyncEngine: db-changes ctx.save failed — domain=\(nsError.domain) " + 1587 "code=\(nsError.code) \(nsError.localizedDescription)" 1588 ) 1589 } 1590 } 1591 return (removed, revoked) 1592 } 1593 1594 for id in removedIDs { 1595 if let cb = onGameRemoved { await cb(id) } 1596 } 1597 for id in revokedIDs { 1598 if let cb = onGameAccessRevoked { await cb(id) } 1599 } 1600 } 1601 1602 private func handleFetchedRecordZoneChanges( 1603 _ event: CKSyncEngine.Event.FetchedRecordZoneChanges, 1604 isPrivate: Bool 1605 ) async { 1606 let scope: Int16 = isPrivate ? 0 : 1 1607 let src = currentFetchSource ?? "framework" 1608 await trace( 1609 "\(isPrivate ? "private" : "shared") fetch [src=\(src)]: " + 1610 "\(event.modifications.count) modifications, \(event.deletions.count) deletions" 1611 ) 1612 if !isPrivate, !loggedFirstSharedPushPayload, src == "push", 1613 event.modifications.count + event.deletions.count > 0 { 1614 loggedFirstSharedPushPayload = true 1615 await trace("✅ first shared-DB push payload received — silent-push path is live") 1616 } 1617 1618 let ctx = persistence.container.newBackgroundContext() 1619 ctx.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump 1620 let localAuthorID = await currentLocalAuthorID() 1621 let (movesUpdatedGameIDs, affectedGameIDs, pings): (Set<UUID>, Set<UUID>, [Ping]) = ctx.performAndWait { 1622 var movesUpdated = Set<UUID>() 1623 var affected = Set<UUID>() 1624 var pings: [Ping] = [] 1625 for mod in event.modifications { 1626 let record = mod.record 1627 switch record.recordType { 1628 case "Game": 1629 let entity = RecordSerializer.applyGameRecord(record, to: ctx, databaseScope: scope) 1630 if let id = entity.id { affected.insert(id) } 1631 case "Moves": 1632 if let value = RecordSerializer.parseMovesRecord(record) { 1633 RecordSerializer.applyMovesRecord( 1634 record, 1635 value: value, 1636 to: ctx, 1637 localAuthorID: localAuthorID 1638 ) 1639 movesUpdated.insert(value.gameID) 1640 affected.insert(value.gameID) 1641 } 1642 case "Player": 1643 if let (gameID, _) = RecordSerializer.parsePlayerRecordName(record.recordID.recordName) { 1644 self.applyPlayerRecord(record, in: ctx) 1645 affected.insert(gameID) 1646 } 1647 case "Ping": 1648 if let ping = Self.parsePingRecord(record) { 1649 pings.append(ping) 1650 } 1651 default: 1652 break 1653 } 1654 } 1655 for deletion in event.deletions { 1656 self.applyDeletion( 1657 recordID: deletion.recordID, 1658 recordType: deletion.recordType, 1659 in: ctx 1660 ) 1661 if let id = self.gameID(fromRecordName: deletion.recordID.recordName) { 1662 affected.insert(id) 1663 } 1664 } 1665 for gameID in movesUpdated { 1666 self.replayCellCache(for: gameID, in: ctx) 1667 } 1668 // CKSyncEngine advances its change token whenever the delegate 1669 // returns from fetchedRecordZoneChanges, regardless of whether we 1670 // persisted anything. A silent failure here means the records are 1671 // gone from the engine's "to deliver" set — they won't come back 1672 // without a `resetSyncState`. Surface failures so we can act. 1673 if ctx.hasChanges { 1674 do { 1675 try ctx.save() 1676 } catch { 1677 let nsError = error as NSError 1678 print( 1679 "SyncEngine: fetchedRecordZoneChanges ctx.save failed " + 1680 "— domain=\(nsError.domain) code=\(nsError.code) " + 1681 "\(nsError.localizedDescription)" 1682 ) 1683 } 1684 } 1685 return (movesUpdated, affected, pings) 1686 } 1687 1688 if let onRemoteMovesUpdated, !movesUpdatedGameIDs.isEmpty { 1689 await onRemoteMovesUpdated(movesUpdatedGameIDs) 1690 } 1691 if let onPings, !pings.isEmpty { 1692 await onPings(pings) 1693 } 1694 if !affectedGameIDs.isEmpty { 1695 NotificationCenter.default.post( 1696 name: .playerRosterShouldRefresh, 1697 object: nil, 1698 userInfo: ["gameIDs": affectedGameIDs] 1699 ) 1700 } 1701 } 1702 1703 private nonisolated static func parsePingRecord(_ record: CKRecord) -> Ping? { 1704 let name = record.recordID.recordName 1705 let gameID: UUID? 1706 if name.hasPrefix("ping-") { 1707 let rest = name.dropFirst("ping-".count) 1708 gameID = UUID(uuidString: String(rest.prefix(36))) 1709 } else if record.recordID.zoneID.zoneName.hasPrefix("game-") { 1710 gameID = UUID(uuidString: String(record.recordID.zoneID.zoneName.dropFirst("game-".count))) 1711 } else { 1712 gameID = nil 1713 } 1714 guard let gameID, 1715 let authorID = record["authorID"] as? String, 1716 let kindRaw = record["kind"] as? String, 1717 let kind = PingKind(rawValue: kindRaw) 1718 else { return nil } 1719 let scope: PingScope? = (record["scope"] as? String).flatMap(PingScope.init(rawValue:)) 1720 return Ping( 1721 recordName: name, 1722 gameID: gameID, 1723 authorID: authorID, 1724 playerName: (record["playerName"] as? String) ?? "", 1725 puzzleTitle: (record["puzzleTitle"] as? String) ?? "", 1726 kind: kind, 1727 scope: scope 1728 ) 1729 } 1730 1731 private nonisolated func applyDeletion( 1732 recordID: CKRecord.ID, 1733 recordType: CKRecord.RecordType, 1734 in ctx: NSManagedObjectContext 1735 ) { 1736 let name = recordID.recordName 1737 let entityName: String 1738 if name.hasPrefix("moves-") { 1739 entityName = "MovesEntity" 1740 } else if name.hasPrefix("player-") { 1741 entityName = "PlayerEntity" 1742 } else if name.hasPrefix("game-") { 1743 entityName = "GameEntity" 1744 } else { 1745 switch recordType { 1746 case "Moves": entityName = "MovesEntity" 1747 case "Player": entityName = "PlayerEntity" 1748 case "Game": entityName = "GameEntity" 1749 default: return 1750 } 1751 } 1752 let req = NSFetchRequest<NSManagedObject>(entityName: entityName) 1753 req.predicate = NSPredicate(format: "ckRecordName == %@", name) 1754 req.fetchLimit = 1 1755 if let obj = try? ctx.fetch(req).first { 1756 ctx.delete(obj) 1757 } 1758 } 1759 1760 private func handleSentRecordZoneChanges( 1761 _ event: CKSyncEngine.Event.SentRecordZoneChanges, 1762 isPrivate: Bool 1763 ) async { 1764 await trace( 1765 "\(isPrivate ? "private" : "shared") sent: " + 1766 "\(event.savedRecords.count) saved, " + 1767 "\(event.failedRecordSaves.count) failed, " + 1768 "\(event.deletedRecordIDs.count) deleted" 1769 ) 1770 for record in event.savedRecords { 1771 let name = record.recordID.recordName 1772 if name.hasPrefix("ping-") { 1773 pendingPings.removeValue(forKey: name) 1774 } 1775 } 1776 let ctx = persistence.container.newBackgroundContext() 1777 ctx.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump 1778 let (failureMessages, orphanedZones): ([String], Set<CKRecordZone.ID>) = ctx.performAndWait { 1779 var messages: [String] = [] 1780 var orphaned = Set<CKRecordZone.ID>() 1781 for record in event.savedRecords { 1782 self.writeBackSystemFields(record: record, in: ctx) 1783 } 1784 for failure in event.failedRecordSaves { 1785 let name = failure.record.recordID.recordName 1786 let err = failure.error as NSError 1787 if err.domain == CKErrorDomain, 1788 err.code == CKError.zoneNotFound.rawValue { 1789 orphaned.insert(failure.record.recordID.zoneID) 1790 } else if self.recoverServerChangedSave(failure.error, failedRecordName: name, in: ctx) { 1791 messages.append( 1792 "send: recovered stale system fields for \(name) from CloudKit server record" 1793 ) 1794 } 1795 let userInfo = err.userInfo 1796 .map { "\($0.key)=\($0.value)" } 1797 .joined(separator: " | ") 1798 messages.append( 1799 "send: failed to save \(name) — domain=\(err.domain) code=\(err.code) \(err.localizedDescription) | userInfo: \(userInfo)" 1800 ) 1801 } 1802 if ctx.hasChanges { 1803 do { 1804 try ctx.save() 1805 } catch { 1806 let nsError = error as NSError 1807 messages.append( 1808 "send: writeBack ctx.save failed — domain=\(nsError.domain) code=\(nsError.code) \(nsError.localizedDescription)" 1809 ) 1810 } 1811 } 1812 return (messages, orphaned) 1813 } 1814 if !orphanedZones.isEmpty { 1815 await applyZoneOrphaning(orphanedZones, isPrivate: isPrivate) 1816 } 1817 for message in failureMessages { 1818 await trace(message) 1819 } 1820 } 1821 1822 /// Reacts to per-record `.zoneNotFound` failures discovered during a push 1823 /// by reflecting the missing-zone reality locally. The framework reports 1824 /// the same failure on every retry without ever clearing the queued change, 1825 /// so we drop pending sends that target the zone, mirror the fetch-side 1826 /// deletion handling on the local game (delete on private, mark 1827 /// access-revoked on shared), and notify upstream observers. Without this, 1828 /// `Last Error` stays stuck on `Failed to send changes` indefinitely. 1829 /// Internal-rather-than-private so the test suite can drive it directly; 1830 /// `CKSyncEngine.Event` payloads have no public initializer so we cannot 1831 /// exercise `handleSentRecordZoneChanges` end-to-end. 1832 func applyZoneOrphaning( 1833 _ zones: Set<CKRecordZone.ID>, 1834 isPrivate: Bool 1835 ) async { 1836 let engine = isPrivate ? privateEngine : sharedEngine 1837 if let engine { 1838 let toRemove = engine.state.pendingRecordZoneChanges.filter { change in 1839 switch change { 1840 case .saveRecord(let id): 1841 return zones.contains(id.zoneID) 1842 case .deleteRecord(let id): 1843 return zones.contains(id.zoneID) 1844 @unknown default: 1845 return false 1846 } 1847 } 1848 if !toRemove.isEmpty { 1849 engine.state.remove(pendingRecordZoneChanges: toRemove) 1850 } 1851 } 1852 1853 for (name, _) in pendingPings { 1854 guard let gameID = gameID(fromRecordName: name) else { continue } 1855 if zones.contains(where: { $0.zoneName == "game-\(gameID.uuidString)" }) { 1856 pendingPings.removeValue(forKey: name) 1857 } 1858 } 1859 1860 let ctx = persistence.container.newBackgroundContext() 1861 ctx.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump 1862 let (removedIDs, revokedIDs): ([UUID], [UUID]) = ctx.performAndWait { 1863 var removed: [UUID] = [] 1864 var revoked: [UUID] = [] 1865 for zone in zones { 1866 let zoneName = zone.zoneName 1867 guard zoneName.hasPrefix("game-") else { continue } 1868 let req = NSFetchRequest<GameEntity>(entityName: "GameEntity") 1869 req.predicate = NSPredicate(format: "ckZoneName == %@", zoneName) 1870 req.fetchLimit = 1 1871 guard let entity = try? ctx.fetch(req).first else { continue } 1872 if isPrivate { 1873 if let id = entity.id { removed.append(id) } 1874 ctx.delete(entity) 1875 } else { 1876 if !entity.isAccessRevoked, let id = entity.id { 1877 revoked.append(id) 1878 } 1879 entity.isAccessRevoked = true 1880 } 1881 } 1882 if ctx.hasChanges { 1883 do { 1884 try ctx.save() 1885 } catch { 1886 let nsError = error as NSError 1887 print( 1888 "SyncEngine: orphan-zone ctx.save failed — domain=\(nsError.domain) " + 1889 "code=\(nsError.code) \(nsError.localizedDescription)" 1890 ) 1891 } 1892 } 1893 return (removed, revoked) 1894 } 1895 1896 await trace( 1897 "\(isPrivate ? "private" : "shared") orphaned \(zones.count) zone(s) on send: " + 1898 zones.map(\.zoneName).sorted().joined(separator: ", ") 1899 ) 1900 1901 for id in removedIDs { 1902 if let cb = onGameRemoved { await cb(id) } 1903 } 1904 for id in revokedIDs { 1905 if let cb = onGameAccessRevoked { await cb(id) } 1906 } 1907 } 1908 1909 /// CKSyncEngine reports optimistic-lock conflicts as failed saves, but the 1910 /// error payload often includes the current server record. Adopt only that 1911 /// record's system fields so a retry can use the fresh change tag while 1912 /// preserving the local values that caused the pending save. 1913 private nonisolated func recoverServerChangedSave( 1914 _ error: Error, 1915 failedRecordName: String, 1916 in ctx: NSManagedObjectContext 1917 ) -> Bool { 1918 let nsError = error as NSError 1919 guard nsError.domain == CKErrorDomain, 1920 nsError.code == CKError.serverRecordChanged.rawValue, 1921 let serverRecord = nsError.userInfo[CKRecordChangedErrorServerRecordKey] as? CKRecord, 1922 serverRecord.recordID.recordName == failedRecordName 1923 else { return false } 1924 1925 writeBackSystemFields(record: serverRecord, in: ctx) 1926 return true 1927 } 1928 1929 private nonisolated func writeBackSystemFields( 1930 record: CKRecord, 1931 in ctx: NSManagedObjectContext 1932 ) { 1933 let name = record.recordID.recordName 1934 let entityName: String 1935 if name.hasPrefix("moves-") { entityName = "MovesEntity" } 1936 else if name.hasPrefix("player-") { entityName = "PlayerEntity" } 1937 else if name.hasPrefix("game-") { entityName = "GameEntity" } 1938 else { return } 1939 1940 let req = NSFetchRequest<NSManagedObject>(entityName: entityName) 1941 req.predicate = NSPredicate(format: "ckRecordName == %@", name) 1942 req.fetchLimit = 1 1943 guard let obj = try? ctx.fetch(req).first else { return } 1944 obj.setValue(RecordSerializer.encodeSystemFields(of: record), forKey: "ckSystemFields") 1945 if entityName == "GameEntity" { 1946 obj.setValue(Date(), forKey: "lastSyncedAt") 1947 } 1948 } 1949 1950 // MARK: - Logging helpers 1951 1952 private nonisolated func trace(_ message: String) { 1953 print("SyncEngine: \(message)") 1954 } 1955 1956 private nonisolated func describe(_ error: Error) -> String { 1957 let nsError = error as NSError 1958 return "ERROR domain=\(nsError.domain) code=\(nsError.code) \(nsError.localizedDescription)" 1959 } 1960 1961 private nonisolated func describeStatus(_ status: CKAccountStatus) -> String { 1962 switch status { 1963 case .available: return "available" 1964 case .noAccount: return "noAccount" 1965 case .restricted: return "restricted" 1966 case .couldNotDetermine: return "couldNotDetermine" 1967 case .temporarilyUnavailable: return "temporarilyUnavailable" 1968 @unknown default: return "unknown(\(status.rawValue))" 1969 } 1970 } 1971 } 1972 1973 // MARK: - CKSyncEngineDelegate 1974 1975 extension SyncEngine: CKSyncEngineDelegate { 1976 func handleEvent(_ event: CKSyncEngine.Event, syncEngine: CKSyncEngine) async { 1977 let isPrivate = syncEngine === privateEngine 1978 switch event { 1979 case .stateUpdate(let e): 1980 saveEngineState(e.stateSerialization, isPrivate: isPrivate) 1981 1982 case .accountChange(let e): 1983 await trace("account change: \(e.changeType)") 1984 if let onAccountChange { await onAccountChange() } 1985 1986 case .fetchedDatabaseChanges(let e): 1987 await handleFetchedDatabaseChanges(e, isPrivate: isPrivate) 1988 1989 case .fetchedRecordZoneChanges(let e): 1990 await handleFetchedRecordZoneChanges(e, isPrivate: isPrivate) 1991 1992 case .sentDatabaseChanges: 1993 break 1994 1995 case .sentRecordZoneChanges(let e): 1996 await handleSentRecordZoneChanges(e, isPrivate: isPrivate) 1997 1998 case .willFetchChanges, .didFetchChanges, 1999 .willFetchRecordZoneChanges, .didFetchRecordZoneChanges, 2000 .willSendChanges, .didSendChanges: 2001 break 2002 2003 @unknown default: 2004 break 2005 } 2006 } 2007 2008 func nextRecordZoneChangeBatch( 2009 _ context: CKSyncEngine.SendChangesContext, 2010 syncEngine: CKSyncEngine 2011 ) async -> CKSyncEngine.RecordZoneChangeBatch? { 2012 let pending = syncEngine.state.pendingRecordZoneChanges 2013 guard !pending.isEmpty else { return nil } 2014 let pingSnapshot = pendingPings 2015 return await CKSyncEngine.RecordZoneChangeBatch(pendingChanges: pending) { [weak self] recordID in 2016 guard let self else { return nil } 2017 return self.buildRecord(for: recordID, pings: pingSnapshot) 2018 } 2019 } 2020 }