crossmate

A collaborative crossword app for iOS
Log | Files | Refs | LICENSE

commit 6ebde24ee34e1edd57e22e7665f963aff85d2a55
parent 954a77b8f840e36c1e7ce34cc6e12f6949607eb9
Author: Michael Camilleri <[email protected]>
Date:   Wed, 20 May 2026 19:55:50 +0900

Tidy sync engine support code

This commit splits the sync engine helpers into focused extension files for
direct CloudKit queries, zone lookup, diagnostics, record application, record
building and presence records. Ping and Session parsing is grouped together as
presence-related, notification title formatting goes through
PuzzleNotificationText and the generated project picks up the new files via
xcodegen.

This also wires removed shared game IDs through the existing game-removed
callback, matching the intended departure propagation path.

Co-Authored-By: Codex GPT 5.5 <[email protected]>

Diffstat:
MCrossmate.xcodeproj/project.pbxproj | 24++++++++++++++++++++++++
MCrossmate/Models/PuzzleNotificationText.swift | 9+++++++++
ACrossmate/Sync/CloudDiagnostics.swift | 162+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
ACrossmate/Sync/CloudQuery.swift | 803+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
ACrossmate/Sync/CloudZones.swift | 200+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
ACrossmate/Sync/Presence.swift | 147+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
ACrossmate/Sync/RecordApplier.swift | 283+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
ACrossmate/Sync/RecordBuilder.swift | 110+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
MCrossmate/Sync/SyncEngine.swift | 1786++-----------------------------------------------------------------------------
9 files changed, 1777 insertions(+), 1747 deletions(-)

diff --git a/Crossmate.xcodeproj/project.pbxproj b/Crossmate.xcodeproj/project.pbxproj @@ -13,6 +13,7 @@ 02943BA53D2130B910E6DC00 /* EnsureGameEntityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94CEBA27A8AC4FCC92ADE1B4 /* EnsureGameEntityTests.swift */; }; 04062BCD473ED244159B1066 /* PlayerRosterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1813630FA05C194AFF43855C /* PlayerRosterTests.swift */; }; 0C39CA21BE50E49F9F06C5F2 /* PlayerRoster.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3292748EAE27B608C769D393 /* PlayerRoster.swift */; }; + 1016604FBD4D63A0B9AAE503 /* CloudQuery.swift in Sources */ = {isa = PBXBuildFile; fileRef = 16AAC1E8D2CB3B5117159934 /* CloudQuery.swift */; }; 128915DB37018EE4CC16C856 /* GameCursorStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8D2AD5021F1AF0DB44FA4540 /* GameCursorStore.swift */; }; 17A754692F05B97DBDD645F2 /* PlayerSelection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F0B4F65D017C1FBAC3B23DF /* PlayerSelection.swift */; }; 18D5BB584DBF92A2EC580AEA /* NotificationNavigationBrokerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEDD63AD5E33E2B0399780EF /* NotificationNavigationBrokerTests.swift */; }; @@ -90,12 +91,17 @@ CF0CA17ABE211DAE4DD35AFD /* RecordSerializer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C0A7348E1283E7CD2486E2A /* RecordSerializer.swift */; }; CF56BBB90855367CB85FEB43 /* PUZToXDConverter.swift in Sources */ = {isa = PBXBuildFile; fileRef = B369788E0FEA0DCE1B125816 /* PUZToXDConverter.swift */; }; CFCA3C2C3CF6D88AE844D7AD /* CellView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8E50E7BA98C88B4CAB39DC1 /* CellView.swift */; }; + D13ECFAE05DB508577D2FF66 /* RecordBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5267DDA1A330DCBD07303D44 /* RecordBuilder.swift */; }; D219A9ACC7C1FB305DA6A4CE /* NYTLoginView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 07C57DEE9E0EFA684D8BD00B /* NYTLoginView.swift */; }; D5150033DB80810F93BE0B5F /* RecordEditorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E30C592ECAF9B51BC7F1D297 /* RecordEditorView.swift */; }; D58980B92C99122C368D4216 /* GameStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 93EE5BA78566EDED68D846AB /* GameStore.swift */; }; + D94FF5DFB9412D2DC24F6574 /* RecordApplier.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD63A9B20168F3B81AF4348F /* RecordApplier.swift */; }; DE2F9B91A6A68594491182E3 /* NewGameSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = F2F7D62E5E9EE2AEFC8940F4 /* NewGameSheet.swift */; }; DE90CC8BE23A0EFC4A32FFA5 /* MovesInboundTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EF1254FE7BE3672AEC1607B1 /* MovesInboundTests.swift */; }; DE9E4FAB098731A650F2D306 /* CrossmateApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 14F2AC5C3B50F4178859E9AC /* CrossmateApp.swift */; }; + E15A40AA623B60279E8DDF43 /* CloudDiagnostics.swift in Sources */ = {isa = PBXBuildFile; fileRef = 07E5E4B165E374FEE732068B /* CloudDiagnostics.swift */; }; + E16A8FE849A8E8BCC0F32280 /* CloudZones.swift in Sources */ = {isa = PBXBuildFile; fileRef = 44F86F0F1883A93F9622FB67 /* CloudZones.swift */; }; + E354A588DBA74627A9CD5591 /* Presence.swift in Sources */ = {isa = PBXBuildFile; fileRef = CFC4FF046BF772646B5CA73F /* Presence.swift */; }; E632562D090D8BE907F28C53 /* NotificationStateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47532AED239AEF476D8E9206 /* NotificationStateTests.swift */; }; E91FB8101E1927CA567DE825 /* PuzzleSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = E7AFD37B03A1C2E23E5766E6 /* PuzzleSource.swift */; }; ECC1A5C3623F50B67185CFFB /* RecordSerializerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E4DEAF9F7887CBB46A99E8E /* RecordSerializerTests.swift */; }; @@ -119,11 +125,13 @@ /* Begin PBXFileReference section */ 07C57DEE9E0EFA684D8BD00B /* NYTLoginView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NYTLoginView.swift; sourceTree = "<group>"; }; + 07E5E4B165E374FEE732068B /* CloudDiagnostics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudDiagnostics.swift; sourceTree = "<group>"; }; 0BF60C84D92A9024AC1A53FC /* Media.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Media.xcassets; sourceTree = "<group>"; }; 0C0A7348E1283E7CD2486E2A /* RecordSerializer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecordSerializer.swift; sourceTree = "<group>"; }; 11BF168D5C1CD85DAE5CAF9E /* PlayerSelectionPublisher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerSelectionPublisher.swift; sourceTree = "<group>"; }; 14B05C19BD4705876B3DF0EC /* GridStateMerger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GridStateMerger.swift; sourceTree = "<group>"; }; 14F2AC5C3B50F4178859E9AC /* CrossmateApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CrossmateApp.swift; sourceTree = "<group>"; }; + 16AAC1E8D2CB3B5117159934 /* CloudQuery.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudQuery.swift; sourceTree = "<group>"; }; 16E1DA8C1B4E73AFB779CC06 /* DebuggingMonitors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DebuggingMonitors.swift; sourceTree = "<group>"; }; 1813630FA05C194AFF43855C /* PlayerRosterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerRosterTests.swift; sourceTree = "<group>"; }; 1F2BE43E18B1CC6AAD27DC6D /* NYTBrowseView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NYTBrowseView.swift; sourceTree = "<group>"; }; @@ -138,6 +146,7 @@ 434862125EC5C0C0F3717ECA /* DiagnosticsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiagnosticsView.swift; sourceTree = "<group>"; }; 43DC132D49361C56DE79C13E /* GameMutator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameMutator.swift; sourceTree = "<group>"; }; 443BF6DF77C8226313EE9564 /* RecordSerializerMovesTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecordSerializerMovesTests.swift; sourceTree = "<group>"; }; + 44F86F0F1883A93F9622FB67 /* CloudZones.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudZones.swift; sourceTree = "<group>"; }; 457B06DBFDC358D213A7CE54 /* AuthorIdentityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthorIdentityTests.swift; sourceTree = "<group>"; }; 46148CF0F4D719692F81A6EC /* PlayerPreferences.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerPreferences.swift; sourceTree = "<group>"; }; 462CE0FD356F6137C9BFD30F /* ImportService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImportService.swift; sourceTree = "<group>"; }; @@ -147,6 +156,7 @@ 4AF633D73818BD59F759FAC4 /* AboutView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboutView.swift; sourceTree = "<group>"; }; 4DC7784917397BCD6B8D679D /* PuzzleCatalog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PuzzleCatalog.swift; sourceTree = "<group>"; }; 4F4EBC0F07FF815274C028CA /* XDAcceptTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XDAcceptTests.swift; sourceTree = "<group>"; }; + 5267DDA1A330DCBD07303D44 /* RecordBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecordBuilder.swift; sourceTree = "<group>"; }; 56BC76178319D0D669CD50FF /* CloudService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudService.swift; sourceTree = "<group>"; }; 5ABB557BA10CBE9909056882 /* GameShareItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameShareItem.swift; sourceTree = "<group>"; }; 5C74683332956B0D1CA37589 /* ShareController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareController.swift; sourceTree = "<group>"; }; @@ -193,6 +203,7 @@ B9031A1574C21866940F6A2C /* XD.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XD.swift; sourceTree = "<group>"; }; BA67C509B467132D1B7510A4 /* Puzzles */ = {isa = PBXFileReference; lastKnownFileType = folder; path = Puzzles; sourceTree = SOURCE_ROOT; }; BAEDA3C3765CD8D8897FE5D5 /* PendingChangeReapTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PendingChangeReapTests.swift; sourceTree = "<group>"; }; + BD63A9B20168F3B81AF4348F /* RecordApplier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecordApplier.swift; sourceTree = "<group>"; }; BF6F111BE8750697C4BC7A17 /* NYTToXDConverter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NYTToXDConverter.swift; sourceTree = "<group>"; }; BFC1C59A30FB2571598273E4 /* GameMutatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameMutatorTests.swift; sourceTree = "<group>"; }; C0CAA5E17BD406AFEEF96196 /* CalendarDayCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalendarDayCell.swift; sourceTree = "<group>"; }; @@ -200,6 +211,7 @@ C90E94A01FEA77A5C9A2BC94 /* PuzzleNotificationTextTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PuzzleNotificationTextTests.swift; sourceTree = "<group>"; }; CAB4BB9E160C3A59C653E7A9 /* GridView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GridView.swift; sourceTree = "<group>"; }; CBDC81CA6A9C80EB31E7F493 /* AppServices.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppServices.swift; sourceTree = "<group>"; }; + CFC4FF046BF772646B5CA73F /* Presence.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Presence.swift; sourceTree = "<group>"; }; D2F03A9F357672533E2A8DB0 /* PuzzleNotificationText.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PuzzleNotificationText.swift; sourceTree = "<group>"; }; D491B7232333AA8957732387 /* PendingEditFlagTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PendingEditFlagTests.swift; sourceTree = "<group>"; }; D97CBA409832A24D64DF0F5C /* Crossmate Unit Tests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = "Crossmate Unit Tests.xctest"; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -237,12 +249,18 @@ isa = PBXGroup; children = ( B1F1471BE4D6D84361DD692B /* AuthorIdentity.swift */, + 07E5E4B165E374FEE732068B /* CloudDiagnostics.swift */, + 16AAC1E8D2CB3B5117159934 /* CloudQuery.swift */, + 44F86F0F1883A93F9622FB67 /* CloudZones.swift */, E655698481325C92EF5C348B /* FriendController.swift */, 7A4AFF292381C9B33C0F2CD6 /* FriendZone.swift */, 14B05C19BD4705876B3DF0EC /* GridStateMerger.swift */, 86470163BFF956F3DE438506 /* Moves.swift */, 7DD270E16E00145EF2807EA9 /* MovesUpdater.swift */, 11BF168D5C1CD85DAE5CAF9E /* PlayerSelectionPublisher.swift */, + CFC4FF046BF772646B5CA73F /* Presence.swift */, + BD63A9B20168F3B81AF4348F /* RecordApplier.swift */, + 5267DDA1A330DCBD07303D44 /* RecordBuilder.swift */, 0C0A7348E1283E7CD2486E2A /* RecordSerializer.swift */, 5C74683332956B0D1CA37589 /* ShareController.swift */, 73DDDED719CFFDD6035C3B48 /* SyncEngine.swift */, @@ -570,7 +588,10 @@ C944A5BD871C6ECC64DE8A5B /* CalendarDayCell.swift in Sources */, 6BE7E91158F4DF1F71247C6D /* CellMark.swift in Sources */, CFCA3C2C3CF6D88AE844D7AD /* CellView.swift in Sources */, + E15A40AA623B60279E8DDF43 /* CloudDiagnostics.swift in Sources */, + 1016604FBD4D63A0B9AAE503 /* CloudQuery.swift in Sources */, CC250D6BA9B41CB722D8A62E /* CloudService.swift in Sources */, + E16A8FE849A8E8BCC0F32280 /* CloudZones.swift in Sources */, B94919176DEC6EC31637B037 /* ClueList.swift in Sources */, DE9E4FAB098731A650F2D306 /* CrossmateApp.swift in Sources */, C30C0C4E54E4209A22843872 /* CrossmateModel.xcdatamodeld in Sources */, @@ -614,11 +635,14 @@ 17A754692F05B97DBDD645F2 /* PlayerSelection.swift in Sources */, B6AB531F4E0C4031B627C539 /* PlayerSelectionPublisher.swift in Sources */, 8F5CB2F94E083D06D7E04280 /* PlayerSession.swift in Sources */, + E354A588DBA74627A9CD5591 /* Presence.swift in Sources */, 503229FF89FF7C29CEF4C16D /* Puzzle.swift in Sources */, 350722635E9A17324148CACC /* PuzzleCatalog.swift in Sources */, 40256E08EE741F4C414B842B /* PuzzleNotificationText.swift in Sources */, E91FB8101E1927CA567DE825 /* PuzzleSource.swift in Sources */, 2F43F24C98D7FF00CA486753 /* PuzzleView.swift in Sources */, + D94FF5DFB9412D2DC24F6574 /* RecordApplier.swift in Sources */, + D13ECFAE05DB508577D2FF66 /* RecordBuilder.swift in Sources */, D5150033DB80810F93BE0B5F /* RecordEditorView.swift in Sources */, CF0CA17ABE211DAE4DD35AFD /* RecordSerializer.swift in Sources */, 54464FDFB8C71B0D3B4B61A2 /* SettingsView.swift in Sources */, diff --git a/Crossmate/Models/PuzzleNotificationText.swift b/Crossmate/Models/PuzzleNotificationText.swift @@ -7,6 +7,15 @@ enum PuzzleNotificationText { return "\(title) – \(subtitle)" } + static func title(for entity: GameEntity?) -> String { + guard let entity else { return "" } + return title( + entity.title ?? "", + publisher: entity.cachedPublisher, + date: entity.cachedPuzzleDate + ) + } + private static func subtitle(publisher: String?, date: Date?) -> String? { let formattedDate = date?.formatted(date: .long, time: .omitted) if let publisher, !publisher.isEmpty, let formattedDate { diff --git a/Crossmate/Sync/CloudDiagnostics.swift b/Crossmate/Sync/CloudDiagnostics.swift @@ -0,0 +1,162 @@ +import CloudKit +import Foundation + +extension SyncEngine { + struct DiagnosticSnapshot: Sendable { + let accountStatus: CKAccountStatus + let engineRunning: Bool + let pendingChangesCount: Int + let privatePendingCount: Int + let sharedPendingCount: Int + } + + /// Record names of pending `.saveRecord` changes queued on the given + /// scope's engine. Used by tests to verify that outbound enqueues route + /// to the correct database. + func pendingSaveRecordNames(scope: CKDatabase.Scope) -> [String] { + let engine = scope == .shared ? sharedEngine : privateEngine + guard let engine else { return [] } + return engine.state.pendingRecordZoneChanges.compactMap { + if case .saveRecord(let id) = $0 { return id.recordName } + return nil + } + } + + /// Zone names queued for deletion on the given scope's engine. Used by + /// tests to verify delete routing after the local GameEntity is gone. + func pendingDeletedZoneNames(scope: CKDatabase.Scope) -> [String] { + let engine = scope == .shared ? sharedEngine : privateEngine + guard let engine else { return [] } + return engine.state.pendingDatabaseChanges.compactMap { + if case .deleteZone(let id) = $0 { return id.zoneName } + return nil + } + } + + func diagnosticSnapshot() async -> DiagnosticSnapshot { + let status: CKAccountStatus + do { status = try await container.accountStatus() } + catch { status = .couldNotDetermine } + let running = privateEngine != nil + let privateCount = privateEngine.map { $0.state.pendingRecordZoneChanges.count } ?? 0 + let sharedCount = sharedEngine.map { $0.state.pendingRecordZoneChanges.count } ?? 0 + return DiagnosticSnapshot( + accountStatus: status, + engineRunning: running, + pendingChangesCount: privateCount + sharedCount, + privatePendingCount: privateCount, + sharedPendingCount: sharedCount + ) + } + + /// Runs a series of lightweight CloudKit probes and returns human-readable + /// (name, result) pairs for display in the diagnostics view. + func probeContainer() async -> [(name: String, result: String)] { + var results: [(String, String)] = [] + results.append(("containerIdentifier", container.containerIdentifier ?? "nil")) + do { + let s = try await container.accountStatus() + results.append(("accountStatus", describeStatus(s))) + } catch { + results.append(("accountStatus", describe(error))) + } + do { + let id = try await container.userRecordID() + results.append(("userRecordID", id.recordName)) + } catch { + results.append(("userRecordID", describe(error))) + } + do { + let zones = try await container.privateCloudDatabase.allRecordZones() + let names = zones.map(\.zoneID.zoneName).joined(separator: ", ") + results.append(("privateZones", "\(zones.count) zone(s): [\(names)]")) + } catch { + results.append(("privateZones", describe(error))) + } + do { + let zones = try await container.sharedCloudDatabase.allRecordZones() + let names = zones.map(\.zoneID.zoneName).joined(separator: ", ") + results.append(("sharedZones", "\(zones.count) zone(s): [\(names)]")) + } catch { + results.append(("sharedZones", describe(error))) + } + // CKSyncEngine creates a CKDatabaseSubscription per scope on first + // start. If subscription creation silently failed, no push will ever + // fire for that scope — surface what's actually present so a missing + // entry is visible from the diagnostics view rather than diagnosed + // by elimination. + results.append(await probeSubscriptions(database: container.privateCloudDatabase, label: "privateSubs")) + results.append(await probeSubscriptions(database: container.sharedCloudDatabase, label: "sharedSubs")) + return results + } + + private func probeSubscriptions( + database: CKDatabase, + label: String + ) async -> (String, String) { + do { + let subs = try await database.allSubscriptions() + if subs.isEmpty { + return (label, "0 subscriptions — pushes will not fire") + } + let descriptions = subs.map { sub -> String in + let kind: String + switch sub { + case is CKDatabaseSubscription: kind = "database" + case is CKQuerySubscription: kind = "query" + case is CKRecordZoneSubscription: kind = "zone" + default: kind = "other(\(type(of: sub)))" + } + let silent = sub.notificationInfo?.shouldSendContentAvailable == true ? "silent" : "alert-only" + return "\(kind):\(sub.subscriptionID)[\(silent)]" + } + return (label, "\(subs.count): [\(descriptions.joined(separator: ", "))]") + } catch { + return (label, describe(error)) + } + } + + /// Fetches a single record by ID for the in-app record editor. Bypasses + /// CKSyncEngine's tracked changes — caller is responsible for triggering a + /// reconciling fetch if the record corresponds to a tracked local entity. + func fetchRecordForEdit( + scope: CKDatabase.Scope, + recordID: CKRecord.ID + ) async throws -> CKRecord { + let database = scope == .shared + ? container.sharedCloudDatabase + : container.privateCloudDatabase + return try await database.record(for: recordID) + } + + /// Saves a record edited in the in-app record editor and runs a follow-up + /// `fetchChanges` so any locally-tracked entity picks up the new server + /// change tag via CKSyncEngine rather than going stale. + func saveRecordForEdit( + scope: CKDatabase.Scope, + record: CKRecord + ) async throws -> CKRecord { + let database = scope == .shared + ? container.sharedCloudDatabase + : container.privateCloudDatabase + let saved = try await database.save(record) + try? await fetchChanges(source: "record-editor") + return saved + } + + nonisolated func describe(_ error: Error) -> String { + let nsError = error as NSError + return "ERROR domain=\(nsError.domain) code=\(nsError.code) \(nsError.localizedDescription)" + } + + private nonisolated func describeStatus(_ status: CKAccountStatus) -> String { + switch status { + case .available: return "available" + case .noAccount: return "noAccount" + case .restricted: return "restricted" + case .couldNotDetermine: return "couldNotDetermine" + case .temporarilyUnavailable: return "temporarilyUnavailable" + @unknown default: return "unknown(\(status.rawValue))" + } + } +} diff --git a/Crossmate/Sync/CloudQuery.swift b/Crossmate/Sync/CloudQuery.swift @@ -0,0 +1,803 @@ +import CloudKit +import CoreData +import Foundation + +extension SyncEngine { + func fetchLiveGameDirect(scope: CKDatabase.Scope, gameID: UUID) async throws -> Bool { + let database: CKDatabase + let scopeValue: Int16 + let label: String + switch scope { + case .private: + database = container.privateCloudDatabase + scopeValue = 0 + label = "private" + case .shared: + database = container.sharedCloudDatabase + scopeValue = 1 + label = "shared" + case .public: + return false + @unknown default: + return false + } + + let ctx = persistence.container.newBackgroundContext() + guard let info = zoneInfo(forGameID: gameID, in: ctx), + info.scope == scopeValue + else { + await trace("\(label) live query skipped: no active game in scope") + return false + } + + let checkpointKey = "\(scopeValue):\(gameID.uuidString)" + let since = liveQueryCheckpoints[checkpointKey]? + .addingTimeInterval(-liveQueryCheckpointOverlap) + + let gameRecordID = CKRecord.ID( + recordName: RecordSerializer.recordName(forGameID: gameID), + zoneID: info.zoneID + ) + // The Game fetch and the Moves/Player queries are independent CK + // round-trips. Fire them in parallel so total latency is bounded by + // the slowest of the three rather than their sum. + async let gameResultsTask = database.records( + for: [gameRecordID], + desiredKeys: ["title", "completedAt", "shareRecordName"] + ) + async let movesTask = queryLiveRecords( + type: "Moves", + database: database, + zoneID: info.zoneID, + since: since, + desiredKeys: ["authorID", "deviceID", "cells", "updatedAt"] + ) + async let playersTask = queryLiveRecords( + type: "Player", + database: database, + zoneID: info.zoneID, + since: since, + desiredKeys: ["authorID", "name", "updatedAt", "selRow", "selCol", "selDir", "readAt"] + ) + let gameResults = try await gameResultsTask + let moves = try await movesTask + let players = try await playersTask + + var records: [CKRecord] = [] + let fetchedGameRecord: Bool + if case .success(let record)? = gameResults[gameRecordID] { + records.append(record) + fetchedGameRecord = true + } else { + fetchedGameRecord = false + } + records.append(contentsOf: moves) + records.append(contentsOf: players) + + await applyDirectRecordZoneChanges( + records: records, + deletions: [], + scopeValue: scopeValue + ) + + let latestModification = records.compactMap(\.modificationDate).max() + if let latestModification { + liveQueryCheckpoints[checkpointKey] = latestModification + } + + await trace( + "\(label) live query fetch \(gameID.uuidString.prefix(8)): " + + "game=\(fetchedGameRecord ? 1 : 0), " + + "moves=\(moves.count), players=\(players.count)" + ) + return true + } + + /// Background-wake fast path for surfacing collaborator notifications. + /// Queries every known zone in the given scope for Ping records modified + /// since the per-scope checkpoint and feeds them to `onPings`. Bypasses + /// `CKSyncEngine.fetchChanges()` because that path can return without + /// delivering events from a silent-push wake (same Apple quirk that + /// motivated `fetchLiveGameDirect`). Moves / Player / Game records are + /// deliberately left for the engine-driven or foreground fetch. + @discardableResult + func fetchPushPingsDirect(scope: CKDatabase.Scope) async throws -> Int { + let database: CKDatabase + let scopeValue: Int16 + let label: String + switch scope { + case .private: + database = container.privateCloudDatabase + scopeValue = 0 + label = "private" + case .shared: + database = container.sharedCloudDatabase + scopeValue = 1 + label = "shared" + case .public: + return 0 + @unknown default: + return 0 + } + + let ctx = persistence.container.newBackgroundContext() + // Completed puzzles are excluded: the fast path only shaves push + // latency for live collaboration, and finished zones' late pings + // still land via CKSyncEngine's own change fetch. This trims the + // per-push fan-out from every known zone to just the active ones. + let zones = knownZones( + forScope: scopeValue, + onlyIncomplete: true, + in: ctx + ) + guard !zones.isEmpty else { + await trace("\(label) ping fast-path: no known zones") + return 0 + } + + let scopeCheckpoint = pingPushCheckpoints[scopeValue]? + .addingTimeInterval(-pingPushCheckpointOverlap) + + // Fan the per-zone Ping queries out concurrently. The actor's await + // points release isolation between round-trips, so the per-zone CK + // requests overlap; a serial N-zone scan becomes a single parallel + // batch. Per-zone errors are caught and traced so one transient + // failure doesn't suppress notifications from healthy zones. + struct PerZonePings: Sendable { + let records: [CKRecord] + let orphanedZone: CKRecordZone.ID? + } + let perZoneRecords = await withTaskGroup(of: PerZonePings.self) { group in + for (zoneID, createdAt) in zones { + // Scope checkpoint (if present) wins — it's forward-moving + // across all zones. On first run for a given scope we fall + // back to the game's createdAt floor so the ping that + // triggered this wake is still in range, but pings older + // than the device's first knowledge of the game are not. + let since = scopeCheckpoint + ?? createdAt.addingTimeInterval(-pingPushCheckpointOverlap) + group.addTask { [weak self] in + guard let self else { return PerZonePings(records: [], orphanedZone: nil) } + do { + let records = try await self.queryLiveRecords( + type: "Ping", + database: database, + zoneID: zoneID, + since: since, + desiredKeys: ["authorID", "deviceID", "playerName", "puzzleTitle", "kind", "payload"] + ) + return PerZonePings(records: records, orphanedZone: nil) + } catch { + let orphan: CKRecordZone.ID? + if scope == .shared, + self.isInvalidSharedZoneOwnerError(error as NSError) { + orphan = zoneID + } else { + orphan = nil + } + await self.trace( + "\(label) ping fast-path: zone \(zoneID.zoneName) failed: " + + "\(error.localizedDescription)" + ) + return PerZonePings(records: [], orphanedZone: orphan) + } + } + } + var all: [PerZonePings] = [] + for await batch in group { + all.append(batch) + } + return all + } + let collected: [CKRecord] = perZoneRecords.flatMap(\.records) + + // Dedupe by record name: the overlap window re-fetches recent pings on + // every push, so emit each only on first sighting. Without this the + // newest ping re-fires forever — the floor is the stored checkpoint + // minus the overlap, so `modificationDate > floor` always re-matches it. + var seen = seenPingRecords[scopeValue] ?? [:] + var pings: [Ping] = [] + var fetchedCount = 0 + for record in collected { + guard let ping = Ping.parseRecord(record) else { continue } + fetchedCount += 1 + let modDate = record.modificationDate ?? Date() + if seen.updateValue(modDate, forKey: record.recordID.recordName) == nil { + pings.append(ping) + } + } + + // Advance the checkpoint monotonically — `max(prior, latest)`, never + // `= latest` — so a slow zone's older batch can't drag every zone's + // window backward. + if let latest = collected.compactMap(\.modificationDate).max() { + let prior = pingPushCheckpoints[scopeValue] ?? .distantPast + pingPushCheckpoints[scopeValue] = max(prior, latest) + } + // Forget names the next query's floor can no longer return, keeping + // the seen set bounded to the overlap window rather than the session. + if let checkpoint = pingPushCheckpoints[scopeValue] { + let floor = checkpoint.addingTimeInterval(-pingPushCheckpointOverlap) + seen = seen.filter { $0.value >= floor } + } + seenPingRecords[scopeValue] = seen + + let orphans = Set(perZoneRecords.compactMap(\.orphanedZone)) + if !orphans.isEmpty { + await applyZoneOrphaning(orphans, isPrivate: scope == .private) + } + + await trace( + "\(label) ping fast-path: zones=\(zones.count), " + + "pings=\(pings.count), dup=\(fetchedCount - pings.count)" + ) + + if !pings.isEmpty, let onPings { + await onPings(pings) + } + return pings.count + } + + @discardableResult + func fetchBackgroundSessionsDirect(scope: CKDatabase.Scope) async throws -> ([Ping], [Session]) { + let database: CKDatabase + let scopeValue: Int16 + let label: String + switch scope { + case .private: + database = container.privateCloudDatabase + scopeValue = 0 + label = "private" + case .shared: + database = container.sharedCloudDatabase + scopeValue = 1 + label = "shared" + case .public: + return ([], []) + @unknown default: + return ([], []) + } + + let ctx = persistence.container.newBackgroundContext() + let zones = incompleteKnownZones(forScope: scopeValue, in: ctx) + guard !zones.isEmpty else { + await trace("\(label) background session scan: no incomplete zones") + return ([], []) + } + + let since = Date().addingTimeInterval(-backgroundSessionLookback) + struct PerZoneActivity: Sendable { + let records: [CKRecord] + let pings: [Ping] + let players: [Session] + let orphanedZone: CKRecordZone.ID? + } + + let perZone = await withTaskGroup(of: PerZoneActivity.self) { group in + for zone in zones { + group.addTask { [weak self] in + guard let self else { + return PerZoneActivity(records: [], pings: [], players: [], orphanedZone: nil) + } + do { + async let pingRecords = self.queryLiveRecords( + type: "Ping", + database: database, + zoneID: zone.zoneID, + since: since, + desiredKeys: ["authorID", "deviceID", "playerName", "puzzleTitle", "kind", "payload"] + ) + async let playerRecords = self.queryLiveRecords( + type: "Player", + database: database, + zoneID: zone.zoneID, + since: since, + desiredKeys: ["authorID", "name", "updatedAt", "selRow", "selCol", "selDir", "readAt"] + ) + let (pings, players) = try await (pingRecords, playerRecords) + let activities = players.compactMap { record in + Session.parseRecord(record, puzzleTitle: zone.title) + } + return PerZoneActivity( + records: players, + pings: pings.compactMap(Ping.parseRecord), + players: activities, + orphanedZone: nil + ) + } catch { + let orphan: CKRecordZone.ID? + if scope == .shared, + self.isInvalidSharedZoneOwnerError(error as NSError) { + orphan = zone.zoneID + } else { + orphan = nil + } + await self.trace( + "\(label) background session scan: zone \(zone.zoneID.zoneName) failed: " + + "\(error.localizedDescription)" + ) + return PerZoneActivity( + records: [], + pings: [], + players: [], + orphanedZone: orphan + ) + } + } + } + var all: [PerZoneActivity] = [] + for await result in group { + all.append(result) + } + return all + } + + let records = perZone.flatMap(\.records) + if !records.isEmpty { + await applyDirectRecordZoneChanges( + records: records, + deletions: [], + scopeValue: scopeValue + ) + } + + let orphans = Set(perZone.compactMap(\.orphanedZone)) + if !orphans.isEmpty { + await applyZoneOrphaning(orphans, isPrivate: scope == .private) + } + + let pings = perZone.flatMap(\.pings) + let players = perZone.flatMap(\.players) + await trace( + "\(label) background session scan: zones=\(zones.count), " + + "players=\(players.count), pings=\(pings.count)" + ) + return (pings, players) + } + + /// Discovers games whose zones the device has never seen and pulls their + /// Game / Moves / Player records directly, bypassing CKSyncEngine. + /// + /// CKSyncEngine is supposed to deliver database-scope change events that + /// announce new zones, but on a silent-push wake those events can be + /// withheld until the next foreground (the same quirk that motivated + /// `fetchLiveGameDirect` and `fetchPushPingsDirect`). Without zone + /// discovery, a game created on one device only appears on a second + /// device after CKSyncEngine eventually catches up — which can be a long + /// time if the second device only ever opens the app briefly. + /// + /// Enumerates zones via `CKDatabase.allRecordZones()`, diffs against + /// `knownZones`, and pulls every record type we care about for each new + /// zone. The pull is unbounded in time because, by definition, the + /// device has no checkpoint for a zone it hasn't seen. + /// + /// Returns the number of newly-discovered zones. + @discardableResult + func discoverNewZonesDirect(scope: CKDatabase.Scope) async throws -> Int { + let database: CKDatabase + let scopeValue: Int16 + let label: String + switch scope { + case .private: + database = container.privateCloudDatabase + scopeValue = 0 + label = "private" + case .shared: + database = container.sharedCloudDatabase + scopeValue = 1 + label = "shared" + case .public: + return 0 + @unknown default: + return 0 + } + + let serverZones = try await database.allRecordZones() + let ctx = persistence.container.newBackgroundContext() + let known = knownZones(forScope: scopeValue, in: ctx) + let knownKeys = Set(known.map { "\($0.zoneID.ownerName)|\($0.zoneID.zoneName)" }) + + let candidates: [CKRecordZone.ID] = serverZones + .map(\.zoneID) + .filter { id in + id != CKRecordZone.ID.default && + !knownKeys.contains("\(id.ownerName)|\(id.zoneName)") + } + + guard !candidates.isEmpty else { + // Count non-default server zones so the figure matches what the + // candidate filter actually compares: the private DB's + // allRecordZones() always includes _defaultZone, which knownZones + // never tracks, so a raw serverZones.count reads a permanent +1. + let serverNonDefault = serverZones.lazy + .filter { $0.zoneID != .default } + .count + await trace( + "\(label) zone discovery: nothing new " + + "(server=\(serverNonDefault), known=\(known.count))" + ) + return 0 + } + + // Two layers of concurrency. Outer: fan the per-zone work out + // through a TaskGroup so N candidate zones don't serialize. Inner: + // fire Game / Moves / Player against each zone with `async let` so + // a single zone's three round-trips also overlap. The Game query + // gates whether the zone hosts a Crossmate puzzle, but Moves and + // Player against a non-puzzle zone simply return empty, so always + // pulling all three in parallel and discarding when Game is empty + // is cheaper than waiting on Game first. Per-zone errors are + // caught and traced so one bad zone doesn't abort the rest. + struct PerZoneResult: Sendable { + let records: [CKRecord] + let hasGame: Bool + } + let perZoneResults = await withTaskGroup(of: PerZoneResult.self) { group in + for zoneID in candidates { + group.addTask { [weak self] in + guard let self else { + return PerZoneResult(records: [], hasGame: false) + } + do { + async let games = try await self.queryLiveRecords( + type: "Game", + database: database, + zoneID: zoneID, + since: nil, + desiredKeys: ["title", "completedAt", "shareRecordName", "puzzleSource"] + ) + async let moves = try await self.queryLiveRecords( + type: "Moves", + database: database, + zoneID: zoneID, + since: nil, + desiredKeys: ["authorID", "deviceID", "cells", "updatedAt"] + ) + async let players = try await self.queryLiveRecords( + type: "Player", + database: database, + zoneID: zoneID, + since: nil, + desiredKeys: ["authorID", "name", "updatedAt", "selRow", "selCol", "selDir", "readAt"] + ) + let (g, m, p) = try await (games, moves, players) + guard !g.isEmpty else { + return PerZoneResult(records: [], hasGame: false) + } + return PerZoneResult(records: g + m + p, hasGame: true) + } catch { + await self.trace( + "\(label) zone discovery: zone \(zoneID.zoneName) failed: " + + "\(error.localizedDescription)" + ) + return PerZoneResult(records: [], hasGame: false) + } + } + } + var all: [PerZoneResult] = [] + for await result in group { + all.append(result) + } + return all + } + let collected: [CKRecord] = perZoneResults.flatMap(\.records) + let zonesWithGame = perZoneResults.reduce(into: 0) { $0 += $1.hasGame ? 1 : 0 } + + await applyDirectRecordZoneChanges( + records: collected, + deletions: [], + scopeValue: scopeValue + ) + + await trace( + "\(label) zone discovery: candidates=\(candidates.count), " + + "withGame=\(zonesWithGame), records=\(collected.count)" + ) + return zonesWithGame + } + + /// Pulls incremental updates for every game the device already knows + /// about in the given scope, bypassing CKSyncEngine. Pairs with + /// `discoverNewZonesDirect` so that pull-to-refresh covers both halves + /// of "what might have changed elsewhere": new zones *and* updates to + /// existing ones. Each game is dispatched to the existing + /// `fetchLiveGameDirect`, which uses the `liveQueryCheckpoints` + /// cursor so we only pull Moves/Player records newer than the last + /// direct fetch. Per-game errors are caught and traced so one bad zone + /// doesn't abort the rest. + /// + /// Returns the number of games for which the direct fetch reported + /// records were applied. + @discardableResult + func fetchKnownZoneUpdatesDirect(scope: CKDatabase.Scope) async throws -> Int { + let scopeValue: Int16 + let label: String + switch scope { + case .private: + scopeValue = 0 + label = "private" + case .shared: + scopeValue = 1 + label = "shared" + case .public: + return 0 + @unknown default: + return 0 + } + + let ctx = persistence.container.newBackgroundContext() + let gameIDs = knownGameIDs(forScope: scopeValue, in: ctx) + guard !gameIDs.isEmpty else { + await trace("\(label) known-zone refresh: no known games") + return 0 + } + + // Fan the per-game fetches out concurrently. Each fetchLiveGameDirect + // call hits a different zone with a different checkpoint key, so they + // don't race on shared state. The actor still serializes access to + // liveQueryCheckpoints at non-await points, but the actual CK round- + // trips overlap, turning a serial 1s-per-game wait into a single + // parallel batch. + let handled = await withTaskGroup(of: Bool.self) { group in + for gameID in gameIDs { + group.addTask { [weak self] in + guard let self else { return false } + do { + return try await self.fetchLiveGameDirect( + scope: scope, + gameID: gameID + ) + } catch { + await self.trace( + "\(label) known-zone refresh: game " + + "\(gameID.uuidString.prefix(8)) failed: " + + "\(error.localizedDescription)" + ) + return false + } + } + } + var count = 0 + for await result in group where result { + count += 1 + } + return count + } + await trace( + "\(label) known-zone refresh: games=\(gameIDs.count), handled=\(handled)" + ) + return handled + } + + /// Background-push catch-up for library freshness. Unlike + /// `fetchKnownZoneUpdatesDirect`, this intentionally skips Player records + /// because the immediate background session scan already covers presence. + /// The delayed caller exists to catch the common ordering where a cursor + /// save triggers the silent push before the corresponding Moves record is + /// visible in CloudKit. + /// + /// Returns the number of Moves records fetched. Game records are always + /// fetched for metadata freshness, but delayed push catch-up uses the + /// Moves count to decide whether a later safety pass is still useful. + @discardableResult + func fetchKnownGameMovesDirect(scope: CKDatabase.Scope) async throws -> Int { + let database: CKDatabase + let scopeValue: Int16 + let label: String + switch scope { + case .private: + database = container.privateCloudDatabase + scopeValue = 0 + label = "private" + case .shared: + database = container.sharedCloudDatabase + scopeValue = 1 + label = "shared" + case .public: + return 0 + @unknown default: + return 0 + } + + let ctx = persistence.container.newBackgroundContext() + let zones = incompleteKnownZones(forScope: scopeValue, in: ctx) + guard !zones.isEmpty else { + await trace("\(label) game/moves catch-up: no incomplete zones") + return 0 + } + + struct PerZoneGameMoves: Sendable { + let records: [CKRecord] + let gameCount: Int + let moveCount: Int + let orphanedZone: CKRecordZone.ID? + } + let perZone = await withTaskGroup(of: PerZoneGameMoves.self) { group in + for zone in zones { + group.addTask { [weak self] in + guard let self else { + return PerZoneGameMoves( + records: [], + gameCount: 0, + moveCount: 0, + orphanedZone: nil + ) + } + do { + let checkpointKey = "\(scopeValue):\(zone.gameID.uuidString)" + let since = await self.liveQueryCheckpoints[checkpointKey]? + .addingTimeInterval(-self.liveQueryCheckpointOverlap) + let gameRecordID = CKRecord.ID( + recordName: RecordSerializer.recordName(forGameID: zone.gameID), + zoneID: zone.zoneID + ) + async let gameResultsTask = database.records( + for: [gameRecordID], + desiredKeys: ["title", "completedAt", "shareRecordName"] + ) + async let movesTask = self.queryLiveRecords( + type: "Moves", + database: database, + zoneID: zone.zoneID, + since: since, + desiredKeys: ["authorID", "deviceID", "cells", "updatedAt"] + ) + let (gameResults, moves) = try await (gameResultsTask, movesTask) + + var records = moves + let gameCount: Int + if case .success(let record)? = gameResults[gameRecordID] { + records.append(record) + gameCount = 1 + } else { + gameCount = 0 + } + + if let latestModification = records.compactMap(\.modificationDate).max() { + await self.setLiveQueryCheckpoint( + latestModification, + scopeValue: scopeValue, + gameID: zone.gameID + ) + } + + return PerZoneGameMoves( + records: records, + gameCount: gameCount, + moveCount: moves.count, + orphanedZone: nil + ) + } catch { + let orphan: CKRecordZone.ID? + if scope == .shared, + self.isInvalidSharedZoneOwnerError(error as NSError) { + orphan = zone.zoneID + } else { + orphan = nil + } + await self.trace( + "\(label) game/moves catch-up: zone \(zone.zoneID.zoneName) failed: " + + "\(error.localizedDescription)" + ) + return PerZoneGameMoves( + records: [], + gameCount: 0, + moveCount: 0, + orphanedZone: orphan + ) + } + } + } + + var all: [PerZoneGameMoves] = [] + for await result in group { + all.append(result) + } + return all + } + + let records = perZone.flatMap(\.records) + await applyDirectRecordZoneChanges( + records: records, + deletions: [], + scopeValue: scopeValue + ) + + let orphans = Set(perZone.compactMap(\.orphanedZone)) + if !orphans.isEmpty { + await applyZoneOrphaning(orphans, isPrivate: scope == .private) + } + + let gameCount = perZone.reduce(0) { $0 + $1.gameCount } + let moveCount = perZone.reduce(0) { $0 + $1.moveCount } + await trace( + "\(label) game/moves catch-up: zones=\(zones.count), " + + "game=\(gameCount), moves=\(moveCount)" + ) + return moveCount + } + + private func queryLiveRecords( + type: CKRecord.RecordType, + database: CKDatabase, + zoneID: CKRecordZone.ID, + since: Date?, + desiredKeys: [CKRecord.FieldKey] + ) async throws -> [CKRecord] { + let since = since ?? Date(timeIntervalSince1970: 0) + return try await queryRecords( + type: type, + database: database, + zoneID: zoneID, + predicate: NSPredicate(format: "modificationDate > %@", since as NSDate), + desiredKeys: desiredKeys + ) + } + + func queryRecords( + type: CKRecord.RecordType, + database: CKDatabase, + zoneID: CKRecordZone.ID, + predicate: NSPredicate, + desiredKeys: [CKRecord.FieldKey] + ) async throws -> [CKRecord] { + let query = CKQuery(recordType: type, predicate: predicate) + + var records: [CKRecord] = [] + var result = try await database.records( + matching: query, + inZoneWith: zoneID, + desiredKeys: desiredKeys, + resultsLimit: CKQueryOperation.maximumResults + ) + records.append(contentsOf: result.matchResults.compactMap { _, recordResult in + try? recordResult.get() + }) + + while let cursor = result.queryCursor { + result = try await database.records( + continuingMatchFrom: cursor, + desiredKeys: desiredKeys, + resultsLimit: CKQueryOperation.maximumResults + ) + records.append(contentsOf: result.matchResults.compactMap { _, recordResult in + try? recordResult.get() + }) + } + return records + } + + private func setLiveQueryCheckpoint( + _ date: Date, + scopeValue: Int16, + gameID: UUID + ) { + liveQueryCheckpoints["\(scopeValue):\(gameID.uuidString)"] = date + } + + func deleteRecords( + withIDs recordIDs: [CKRecord.ID], + in database: CKDatabase + ) async throws { + guard !recordIDs.isEmpty else { return } + let batchSize = 200 + var index = recordIDs.startIndex + while index < recordIDs.endIndex { + let end = recordIDs.index(index, offsetBy: batchSize, limitedBy: recordIDs.endIndex) + ?? recordIDs.endIndex + let batch = Array(recordIDs[index..<end]) + try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Void, Error>) in + let op = CKModifyRecordsOperation( + recordsToSave: nil, + recordIDsToDelete: batch + ) + op.qualityOfService = .utility + op.modifyRecordsResultBlock = { result in + cont.resume(with: result) + } + database.add(op) + } + index = end + } + } +} diff --git a/Crossmate/Sync/CloudZones.swift b/Crossmate/Sync/CloudZones.swift @@ -0,0 +1,200 @@ +import CloudKit +import CoreData +import Foundation + +extension SyncEngine { + struct ZoneInfo { + let scope: Int16 + let zoneID: CKRecordZone.ID + } + + struct ActivityZoneInfo: Sendable { + let gameID: UUID + let zoneID: CKRecordZone.ID + let title: String + } + + /// Looks up a game's scope and zone ID from Core Data. Returns `nil` if + /// the entity can't be found. Not `async` — uses `performAndWait` so it + /// can be called from non-async actor context. + nonisolated func zoneInfo( + forGameID gameID: UUID, + in ctx: NSManagedObjectContext + ) -> ZoneInfo? { + ctx.performAndWait { + let req = NSFetchRequest<GameEntity>(entityName: "GameEntity") + req.predicate = NSPredicate(format: "id == %@", gameID as CVarArg) + req.fetchLimit = 1 + guard let entity = try? ctx.fetch(req).first else { return nil } + let zoneName = entity.ckZoneName ?? "game-\(gameID.uuidString)" + let ownerName = entity.ckZoneOwnerName ?? CKCurrentUserDefaultName + return ZoneInfo( + scope: entity.databaseScope, + zoneID: CKRecordZone.ID(zoneName: zoneName, ownerName: ownerName) + ) + } + } + + /// Game UUIDs for every locally-known *in-progress* game in the given + /// database scope. Used by the known-zone refresh path so each game can + /// be routed through `fetchLiveGameDirect`. Games with a non-nil + /// `completedAt` are excluded: once a game is completed no further moves + /// or player updates can arrive, so refreshing those zones is wasted + /// round-trips. + nonisolated func knownGameIDs( + forScope scope: Int16, + in ctx: NSManagedObjectContext + ) -> [UUID] { + ctx.performAndWait { + let req = NSFetchRequest<GameEntity>(entityName: "GameEntity") + req.predicate = NSPredicate( + format: "databaseScope == %d AND completedAt == nil AND isAccessRevoked == NO", + scope + ) + guard let entities = try? ctx.fetch(req) else { return [] } + return entities.compactMap(\.id) + } + } + + /// Enumerates every known game zone for the given database scope, paired + /// with the `createdAt` of the corresponding GameEntity. The createdAt + /// timestamp is used as the per-zone floor for the ping fast path: pings + /// older than the moment this device first knew about the game can't be + /// of interest (for shared games, they pre-date our join; for owned + /// games, they pre-date the game's existence). + nonisolated func knownZones( + forScope scope: Int16, + onlyIncomplete: Bool = false, + in ctx: NSManagedObjectContext + ) -> [(zoneID: CKRecordZone.ID, createdAt: Date)] { + ctx.performAndWait { + let req = NSFetchRequest<GameEntity>(entityName: "GameEntity") + // Skip access-revoked entries so an orphaned shared zone — e.g. + // one whose participant binding became invalid and now returns + // "Cannot convert userId to dsId" on every query — stops being + // re-queried by the direct fetch paths. + // + // `onlyIncomplete` additionally drops finished puzzles' game + // zones (keeps only completedAt == nil). Only the ping fast path + // passes it: that path is a + // latency shortcut, and a completed puzzle has no live + // collaboration, so a late .win/.opened there still arrives via + // CKSyncEngine's own push-driven fetchedRecordZoneChanges, which + // surfaces Ping records for every tracked zone regardless of + // completion. It must stay opt-in — discoverNewZonesDirect diffs + // the *full* known set against the server to spot new zones, so + // excluding completed games there would make every finished zone + // look new and re-pull it on each discovery. The account and + // friend zones below are appended unconditionally: they carry + // .opened/.invite/.friend, have no GameEntity, and no completion. + req.predicate = NSPredicate( + format: onlyIncomplete + ? "databaseScope == %d AND completedAt == nil AND isAccessRevoked == NO" + : "databaseScope == %d AND isAccessRevoked == NO", + scope + ) + guard let entities = try? ctx.fetch(req) else { return [] } + var seen = Set<String>() + var result: [(CKRecordZone.ID, Date)] = [] + for entity in entities { + guard let gameID = entity.id else { continue } + let zoneName = entity.ckZoneName ?? "game-\(gameID.uuidString)" + let ownerName = entity.ckZoneOwnerName ?? CKCurrentUserDefaultName + let key = "\(ownerName)|\(zoneName)" + guard seen.insert(key).inserted else { continue } + let createdAt = entity.createdAt ?? Date(timeIntervalSince1970: 0) + result.append((CKRecordZone.ID(zoneName: zoneName, ownerName: ownerName), createdAt)) + } + // Friend zones carry `.invite` / `.friend` pings but no + // GameEntity, so they're appended explicitly. The owner sees the + // zone in the private DB + // (scope 0); the participant sees it in the shared DB + // (scope 1). Blocked friends are skipped so we stop reading + // anything from them. Floor is `.distantPast`: any unseen + // invite should be processed. + let friendReq = NSFetchRequest<FriendEntity>(entityName: "FriendEntity") + friendReq.predicate = NSPredicate( + format: "databaseScope == %d AND isBlocked == NO", + scope + ) + for friend in (try? ctx.fetch(friendReq)) ?? [] { + guard let zoneName = friend.friendZoneName, + let ownerName = friend.friendZoneOwnerName + else { continue } + let key = "\(ownerName)|\(zoneName)" + guard seen.insert(key).inserted else { continue } + result.append(( + CKRecordZone.ID(zoneName: zoneName, ownerName: ownerName), + Date(timeIntervalSince1970: 0) + )) + } + return result + } + } + + nonisolated func incompleteKnownZones( + forScope scope: Int16, + in ctx: NSManagedObjectContext + ) -> [ActivityZoneInfo] { + ctx.performAndWait { + let req = NSFetchRequest<GameEntity>(entityName: "GameEntity") + req.predicate = NSPredicate( + format: "databaseScope == %d AND completedAt == nil AND isAccessRevoked == NO", + scope + ) + guard let entities = try? ctx.fetch(req) else { return [] } + var seen = Set<String>() + var result: [ActivityZoneInfo] = [] + for entity in entities { + guard let gameID = entity.id else { continue } + let zoneName = entity.ckZoneName ?? "game-\(gameID.uuidString)" + let ownerName = entity.ckZoneOwnerName ?? CKCurrentUserDefaultName + let key = "\(ownerName)|\(zoneName)" + guard seen.insert(key).inserted else { continue } + result.append(ActivityZoneInfo( + gameID: gameID, + zoneID: CKRecordZone.ID(zoneName: zoneName, ownerName: ownerName), + title: PuzzleNotificationText.title(for: entity) + )) + } + return result + } + } + + nonisolated func friendZoneIDs(forScope scope: Int16) -> [CKRecordZone.ID] { + let ctx = persistence.container.newBackgroundContext() + return ctx.performAndWait { + let req = NSFetchRequest<FriendEntity>(entityName: "FriendEntity") + req.predicate = NSPredicate( + format: "databaseScope == %d AND isBlocked == NO", + scope + ) + var seen = Set<String>() + var result: [CKRecordZone.ID] = [] + for friend in (try? ctx.fetch(req)) ?? [] { + guard let zoneName = friend.friendZoneName, + let ownerName = friend.friendZoneOwnerName + else { continue } + let key = "\(ownerName)|\(zoneName)" + guard seen.insert(key).inserted else { continue } + result.append(CKRecordZone.ID(zoneName: zoneName, ownerName: ownerName)) + } + return result + } + } + + /// Extracts the game UUID from any of our record name formats: + /// `game-<UUID>`, `moves-<UUID>-…`, `player-<UUID>-…`, `ping-<UUID>-…`. + nonisolated func gameID(fromRecordName name: String) -> UUID? { + if name.hasPrefix("game-") { + return UUID(uuidString: String(name.dropFirst("game-".count))) + } + let prefix: String + if name.hasPrefix("moves-") { prefix = "moves-" } + else if name.hasPrefix("player-") { prefix = "player-" } + else if name.hasPrefix("ping-") { prefix = "ping-" } + else { return nil } + let rest = name.dropFirst(prefix.count) + return UUID(uuidString: String(rest.prefix(36))) + } +} diff --git a/Crossmate/Sync/Presence.swift b/Crossmate/Sync/Presence.swift @@ -0,0 +1,147 @@ +import CloudKit +import Foundation + +/// What a Ping record represents. Stored as a string in the CKRecord's +/// `kind` field. Every kind flows between players in a per-game zone (or, for +/// `.invite`, in a friend zone); there is no longer an own-devices presence +/// kind — `Player.readAt` carries cross-device read horizon state instead. +enum PingKind: String, Sendable { + case join + case win + /// The completing player gave up (revealed the grid). Directed, like + /// `.win`: one per other roster player via `addressee`. The Game record's + /// `completedBy` stays nil so resigned games stay distinguishable. + case resign + case check + case reveal + /// Friendship bootstrap. Written into a shared *game* zone; carries the + /// friend-zone share URL in `payload`. System-only — never user-facing. + case friend + /// Re-invite to a game. Written into a *friend* zone; carries the game's + /// share URL in `payload`. Surfaces in the "Invited" section. + case invite +} + +/// Granularity of a check/reveal action; nil for kinds where it doesn't apply. +/// +/// LEGACY SCHEMA NOTE: this used to be its own `Ping.scope` CKRecord field, +/// predating the generic `payload` slot. It now rides in `payload` as +/// `{"scope":"…"}` like every other kind-specific datum. The old `Ping.scope` +/// CKRecord field is DEAD: no code writes or reads it. It still exists in the +/// deployed production CloudKit schema only because CloudKit cannot delete +/// fields from a deployed type. +/// +/// ┌─────────────────────────────────────────────────────────────────┐ +/// │ When the CloudKit container/schema is next rebuilt clean, do NOT │ +/// │ recreate the `Ping.scope` field. It is intentionally gone. │ +/// └─────────────────────────────────────────────────────────────────┘ +enum PingScope: String, Sendable { + case square + case word + case puzzle +} + +struct Ping: Sendable { + let recordName: String + let gameID: UUID + let authorID: String + let deviceID: String + let playerName: String + let puzzleTitle: String + let kind: PingKind + let scope: PingScope? + /// Kind-specific JSON. `.friend`: `{friendShareURL,pairKey,ownerAuthorID}`; + /// `.invite`: `{gameShareURL}`; `.check`/`.reveal`: `{scope}` (see + /// PingScope). nil for join/win/resign. + let payload: String? + /// Recipient authorID for a directed ping (`.win`/`.resign`); nil ⇒ + /// broadcast — every recipient acts on it. A device ignores a ping whose + /// `addressee` is set to someone other than its own author. + let addressee: String? + + static func parseRecord(_ record: CKRecord) -> Ping? { + let name = record.recordID.recordName + let gameID: UUID? + if name.hasPrefix("ping-") { + let rest = name.dropFirst("ping-".count) + gameID = UUID(uuidString: String(rest.prefix(36))) + } else if record.recordID.zoneID.zoneName.hasPrefix("game-") { + gameID = UUID(uuidString: String(record.recordID.zoneID.zoneName.dropFirst("game-".count))) + } else { + gameID = nil + } + guard let gameID, + let authorID = record["authorID"] as? String, + let kindRaw = record["kind"] as? String, + let kind = PingKind(rawValue: kindRaw) + else { return nil } + // Scope rides in `payload` as `{"scope":"…"}`. The old top-level + // `scope` field is dead — nothing reads or writes it (see PingScope). + let payloadString = record["payload"] as? String + let scope: PingScope? = + PingScopePayload.decode(payloadString).flatMap { PingScope(rawValue: $0.scope) } + // Legacy records written before the schema added `deviceID` won't have + // the field. Parse-tolerant: empty string can never equal a real + // localDeviceID, so the self-send filter stays safe. + let deviceID = (record["deviceID"] as? String) ?? "" + return Ping( + recordName: name, + gameID: gameID, + authorID: authorID, + deviceID: deviceID, + playerName: (record["playerName"] as? String) ?? "", + puzzleTitle: (record["puzzleTitle"] as? String) ?? "", + kind: kind, + scope: scope, + payload: payloadString, + addressee: record["addressee"] as? String + ) + } +} + +struct Session: Sendable { + let recordName: String + let gameID: UUID + let authorID: String + let playerName: String + let puzzleTitle: String + let updatedAt: Date + + static func parseRecord(_ record: CKRecord, puzzleTitle: String) -> Session? { + guard let (gameID, authorIDFromName) = RecordSerializer.parsePlayerRecordName(record.recordID.recordName) + else { return nil } + // A cleared selection is the player leaving the puzzle, not starting + // or actively navigating it. + guard RecordSerializer.parsePlayerSelection(from: record) != nil else { return nil } + let authorID = (record["authorID"] as? String) ?? authorIDFromName + let updatedAt = (record["updatedAt"] as? Date) + ?? record.modificationDate + ?? Date() + return Session( + recordName: record.recordID.recordName, + gameID: gameID, + authorID: authorID, + playerName: (record["name"] as? String) ?? "", + puzzleTitle: puzzleTitle, + updatedAt: updatedAt + ) + } +} + + +/// `payload` JSON for `.check`/`.reveal` pings — the check/reveal granularity +/// that used to live in the legacy `Ping.scope` field (see PingScope). The +/// `scope` key is required, so decoding a different kind's payload (lease, +/// friend, invite) yields nil rather than a false match. +struct PingScopePayload: Codable, Sendable { + let scope: String + + func encoded() -> String? { + (try? JSONEncoder().encode(self)).flatMap { String(data: $0, encoding: .utf8) } + } + + static func decode(_ string: String?) -> PingScopePayload? { + guard let data = string?.data(using: .utf8) else { return nil } + return try? JSONDecoder().decode(PingScopePayload.self, from: data) + } +} diff --git a/Crossmate/Sync/RecordApplier.swift b/Crossmate/Sync/RecordApplier.swift @@ -0,0 +1,283 @@ +import CloudKit +import CoreData +import Foundation + +extension SyncEngine { + func applyDirectRecordZoneChanges( + records: [CKRecord], + deletions: [(CKRecord.ID, CKRecord.RecordType)], + scopeValue: Int16 + ) async { + guard !records.isEmpty || !deletions.isEmpty else { return } + let ctx = persistence.container.newBackgroundContext() + ctx.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump + let localAuthorID = await currentLocalAuthorID() + let (movesUpdatedGameIDs, affectedGameIDs, playersUpdatedGameIDs, readCursors): + (Set<UUID>, Set<UUID>, Set<UUID>, [(UUID, Date)]) = ctx.performAndWait { + var movesUpdated = Set<UUID>() + var affected = Set<UUID>() + var playersUpdated = Set<UUID>() + var read: [(UUID, Date)] = [] + for record in records { + switch record.recordType { + case "Game": + let entity = RecordSerializer.applyGameRecord(record, to: ctx, databaseScope: scopeValue) + if let id = entity.id { affected.insert(id) } + case "Moves": + if let value = RecordSerializer.parseMovesRecord(record) { + let cellsChanged = RecordSerializer.applyMovesRecord( + record, + value: value, + to: ctx, + localAuthorID: localAuthorID + ) + if cellsChanged { movesUpdated.insert(value.gameID) } + affected.insert(value.gameID) + } + case "Player": + if let (gameID, _) = RecordSerializer.parsePlayerRecordName(record.recordID.recordName) { + self.applyPlayerRecord( + record, + in: ctx, + localAuthorID: localAuthorID, + onFirstTime: { playersUpdated.insert($0) }, + onReadCursor: { read.append(($0, $1)) } + ) + affected.insert(gameID) + } + default: + break + } + } + for deletion in deletions { + self.applyDeletion( + recordID: deletion.0, + recordType: deletion.1, + in: ctx + ) + if let id = self.gameID(fromRecordName: deletion.0.recordName) { + affected.insert(id) + } + } + for gameID in movesUpdated { + self.replayCellCache(for: gameID, in: ctx) + } + if ctx.hasChanges { + do { + try ctx.save() + } catch { + let nsError = error as NSError + print( + "SyncEngine: direct-push ctx.save failed " + + "— domain=\(nsError.domain) code=\(nsError.code) " + + "\(nsError.localizedDescription)" + ) + } + } + return (movesUpdated, affected, playersUpdated, read) + } + + if let onRemoteMovesUpdated, !movesUpdatedGameIDs.isEmpty { + await onRemoteMovesUpdated(movesUpdatedGameIDs) + } + if let onRemotePlayersUpdated, !playersUpdatedGameIDs.isEmpty { + await onRemotePlayersUpdated(playersUpdatedGameIDs) + } + if let onIncomingReadCursor, !readCursors.isEmpty { + await onIncomingReadCursor(readCursors) + } + let pingDeletedGameIDs = Set(deletions.compactMap { deletion -> UUID? in + deletion.0.recordName.hasPrefix("ping-") + ? gameID(fromRecordName: deletion.0.recordName) : nil + }) + if let onPingDeleted, !pingDeletedGameIDs.isEmpty { + await onPingDeleted(pingDeletedGameIDs) + } + if !affectedGameIDs.isEmpty { + NotificationCenter.default.post( + name: .playerRosterShouldRefresh, + object: nil, + userInfo: ["gameIDs": affectedGameIDs] + ) + } + } + + nonisolated func applyPlayerRecord( + _ record: CKRecord, + in ctx: NSManagedObjectContext, + localAuthorID: String?, + onFirstTime: (UUID) -> Void, + onReadCursor: (UUID, Date) -> Void + ) { + let ckName = record.recordID.recordName + guard let (gameID, authorID) = RecordSerializer.parsePlayerRecordName(ckName) else { + return + } + guard let renderedName = record["name"] as? String else { return } + let updatedAt = record["updatedAt"] as? Date + ?? record.modificationDate + ?? Date() + + let req = NSFetchRequest<PlayerEntity>(entityName: "PlayerEntity") + req.predicate = NSPredicate(format: "ckRecordName == %@", ckName) + req.fetchLimit = 1 + + let entity: PlayerEntity + let foundExisting: Bool + if let existing = try? ctx.fetch(req).first { + entity = existing + foundExisting = true + } else { + let game = RecordSerializer.ensureGameEntity( + forGameID: gameID, + zoneID: record.recordID.zoneID, + in: ctx + ) + entity = PlayerEntity(context: ctx) + entity.game = game + foundExisting = false + } + + // Drop fetched snapshots older than what we already have. After a + // successful push the writeback adopts the new etag; if a query + // that started before the push lands later, applying its older + // snapshot would downgrade our local etag and OpLock-fail the next + // save (see `applyMovesRecord` for the same guard). + if foundExisting, + !RecordSerializer.incomingIsAtLeastAsFresh(record, existingFields: entity.ckSystemFields) { + return + } + + // Adopt the server's system fields — that's etag tracking and is + // independent of which side has the freshest data. The value fields, + // however, are only adopted when the incoming record is at least as + // new as what we have locally; otherwise a stale-but-current server + // record (e.g. our own pending writes haven't landed yet) would + // clobber the user's live selection on every fetch. + entity.ckRecordName = ckName + entity.ckSystemFields = RecordSerializer.encodeSystemFields(of: record) + entity.authorID = authorID + let localUpdatedAt = entity.updatedAt + let incomingIsFresher = localUpdatedAt.map { updatedAt >= $0 } ?? true + guard incomingIsFresher else { return } + // An empty `name` is what older builds shipped from the selection publisher + // before the fix; treat it as "no information" rather than letting it + // clobber a previously-resolved name. + if !renderedName.isEmpty { + entity.name = renderedName + } + entity.updatedAt = updatedAt + if let selection = RecordSerializer.parsePlayerSelection(from: record) { + entity.selRow = NSNumber(value: Int64(selection.row)) + entity.selCol = NSNumber(value: Int64(selection.col)) + entity.selDir = NSNumber(value: Int64(selection.direction.rawValue)) + } else { + entity.selRow = nil + entity.selCol = nil + entity.selDir = nil + } + if authorID == localAuthorID, + let readAt = RecordSerializer.parsePlayerReadAt(from: record) { + onReadCursor(gameID, readAt) + } + if !foundExisting { + onFirstTime(gameID) + } + } + + /// Merges every device's `MovesEntity` row for `gameID` and reconciles the + /// `CellEntity` cache against the resulting grid. Must be called inside a + /// `performAndWait` block on the same context. + nonisolated func replayCellCache( + for gameID: UUID, + in ctx: NSManagedObjectContext + ) { + let gameReq = NSFetchRequest<GameEntity>(entityName: "GameEntity") + gameReq.predicate = NSPredicate(format: "id == %@", gameID as CVarArg) + gameReq.fetchLimit = 1 + guard let game = try? ctx.fetch(gameReq).first else { return } + + let movesReq = NSFetchRequest<MovesEntity>(entityName: "MovesEntity") + movesReq.predicate = NSPredicate(format: "game == %@", game) + let movesEntities = (try? ctx.fetch(movesReq)) ?? [] + let values: [MovesValue] = movesEntities.compactMap { Self.movesValue(from: $0) } + let gridState = GridStateMerger.merge(values) + + let existingCells = (game.cells as? Set<CellEntity>) ?? [] + var byPosition: [GridPosition: CellEntity] = [:] + for cell in existingCells { + byPosition[GridPosition(row: Int(cell.row), col: Int(cell.col))] = cell + } + + for (pos, gridCell) in gridState { + let cell: CellEntity + if let existing = byPosition[pos] { + cell = existing + } else { + cell = CellEntity(context: ctx) + cell.game = game + cell.row = Int16(pos.row) + cell.col = Int16(pos.col) + } + cell.letter = gridCell.letter + cell.markKind = gridCell.markKind + cell.checkedWrong = gridCell.checkedWrong + cell.letterAuthorID = gridCell.authorID + } + + for (pos, cell) in byPosition where gridState[pos] == nil { + cell.letter = "" + cell.markKind = 0 + cell.checkedWrong = false + cell.letterAuthorID = nil + } + } + + /// Hydrates a `MovesValue` from a `MovesEntity`. Returns `nil` if the row + /// is missing required fields (e.g. an unpopulated stub from a partial + /// fetch). + nonisolated static func movesValue(from entity: MovesEntity) -> MovesValue? { + guard let gameID = entity.game?.id, + let authorID = entity.authorID, + let deviceID = entity.deviceID, + let updatedAt = entity.updatedAt + else { return nil } + let cells = (entity.cells.flatMap { try? MovesCodec.decode($0) }) ?? [:] + return MovesValue( + gameID: gameID, + authorID: authorID, + deviceID: deviceID, + cells: cells, + updatedAt: updatedAt + ) + } + + nonisolated func applyDeletion( + recordID: CKRecord.ID, + recordType: CKRecord.RecordType, + in ctx: NSManagedObjectContext + ) { + let name = recordID.recordName + let entityName: String + if name.hasPrefix("moves-") { + entityName = "MovesEntity" + } else if name.hasPrefix("player-") { + entityName = "PlayerEntity" + } else if name.hasPrefix("game-") { + entityName = "GameEntity" + } else { + switch recordType { + case "Moves": entityName = "MovesEntity" + case "Player": entityName = "PlayerEntity" + case "Game": entityName = "GameEntity" + default: return + } + } + let req = NSFetchRequest<NSManagedObject>(entityName: entityName) + req.predicate = NSPredicate(format: "ckRecordName == %@", name) + req.fetchLimit = 1 + if let obj = try? ctx.fetch(req).first { + ctx.delete(obj) + } + } +} diff --git a/Crossmate/Sync/RecordBuilder.swift b/Crossmate/Sync/RecordBuilder.swift @@ -0,0 +1,110 @@ +import CloudKit +import CoreData +import Foundation + +extension SyncEngine { + /// Builds the `CKRecord` for a pending change. Uses the zone ID already + /// embedded in the `recordID` — set correctly at enqueue time. + /// `pings` is a snapshot taken from the actor before this is invoked, + /// since the framework calls back synchronously off-actor. + nonisolated func buildRecord( + for recordID: CKRecord.ID, + pings: [String: PingPayload] + ) -> CKRecord? { + let name = recordID.recordName + let zoneID = recordID.zoneID + if name.hasPrefix("ping-") { + guard let payload = pings[name] else { return nil } + return RecordSerializer.pingRecord( + gameID: payload.gameID, + authorID: payload.authorID, + deviceID: payload.deviceID, + playerName: payload.playerName, + puzzleTitle: payload.puzzleTitle, + eventTimestampMs: payload.eventTimestampMs, + kind: payload.kind, + scope: payload.scope, + payload: payload.payload, + addressee: payload.addressee, + zone: zoneID + ) + } + if name.hasPrefix("decision-") { + guard let (kind, key) = RecordSerializer.parseDecisionRecordName(name) else { + return nil + } + return RecordSerializer.decisionRecord(kind: kind, key: key, zone: zoneID) + } + let ctx = persistence.container.newBackgroundContext() + return ctx.performAndWait { + if name.hasPrefix("game-") { + let req = NSFetchRequest<GameEntity>(entityName: "GameEntity") + req.predicate = NSPredicate(format: "ckRecordName == %@", name) + req.fetchLimit = 1 + guard let entity = try? ctx.fetch(req).first else { return nil } + return RecordSerializer.gameRecord( + from: entity, + recordID: recordID, + includePuzzleSource: entity.ckSystemFields == nil + ) + } else if name.hasPrefix("moves-") { + let req = NSFetchRequest<MovesEntity>(entityName: "MovesEntity") + req.predicate = NSPredicate(format: "ckRecordName == %@", name) + req.fetchLimit = 1 + guard let entity = try? ctx.fetch(req).first, + let gameID = entity.game?.id, + let authorID = entity.authorID, + let deviceID = entity.deviceID, + let updatedAt = entity.updatedAt + else { return nil } + let cells = (entity.cells.flatMap { try? MovesCodec.decode($0) }) ?? [:] + let value = MovesValue( + gameID: gameID, + authorID: authorID, + deviceID: deviceID, + cells: cells, + updatedAt: updatedAt + ) + return try? RecordSerializer.movesRecord( + from: value, + zone: zoneID, + systemFields: entity.ckSystemFields + ) + } else if name.hasPrefix("player-") { + let req = NSFetchRequest<PlayerEntity>(entityName: "PlayerEntity") + req.predicate = NSPredicate(format: "ckRecordName == %@", name) + req.fetchLimit = 1 + guard let entity = try? ctx.fetch(req).first, + let gameID = entity.game?.id, + let authorID = entity.authorID, + let renderedName = entity.name, + let updatedAt = entity.updatedAt + else { return nil } + let selection: PlayerSelection? + if let row = entity.selRow, + let col = entity.selCol, + let dir = entity.selDir, + let direction = Puzzle.Direction(rawValue: dir.intValue) { + selection = PlayerSelection( + row: row.intValue, + col: col.intValue, + direction: direction + ) + } else { + selection = nil + } + return RecordSerializer.playerRecord( + gameID: gameID, + authorID: authorID, + name: renderedName, + updatedAt: updatedAt, + selection: selection, + readAt: entity.game?.lastReadOtherMoveAt, + zone: zoneID, + systemFields: entity.ckSystemFields + ) + } + return nil + } + } +} diff --git a/Crossmate/Sync/SyncEngine.swift b/Crossmate/Sync/SyncEngine.swift @@ -26,91 +26,6 @@ extension Notification.Name { static let playerRosterShouldRefresh = Notification.Name("playerRosterShouldRefresh") } -/// What a Ping record represents. Stored as a string in the CKRecord's -/// `kind` field. Every kind flows between players in a per-game zone (or, for -/// `.invite`, in a friend zone); there is no longer an own-devices presence -/// kind — `Player.readAt` carries cross-device read horizon state instead. -enum PingKind: String, Sendable { - case join - case win - /// The completing player gave up (revealed the grid). Directed, like - /// `.win`: one per other roster player via `addressee`. The Game record's - /// `completedBy` stays nil so resigned games stay distinguishable. - case resign - case check - case reveal - /// Friendship bootstrap. Written into a shared *game* zone; carries the - /// friend-zone share URL in `payload`. System-only — never user-facing. - case friend - /// Re-invite to a game. Written into a *friend* zone; carries the game's - /// share URL in `payload`. Surfaces in the "Invited" section. - case invite -} - -/// Granularity of a check/reveal action; nil for kinds where it doesn't apply. -/// -/// LEGACY SCHEMA NOTE: this used to be its own `Ping.scope` CKRecord field, -/// predating the generic `payload` slot. It now rides in `payload` as -/// `{"scope":"…"}` like every other kind-specific datum. The old `Ping.scope` -/// CKRecord field is DEAD: no code writes or reads it. It still exists in the -/// deployed production CloudKit schema only because CloudKit cannot delete -/// fields from a deployed type. -/// -/// ┌─────────────────────────────────────────────────────────────────┐ -/// │ When the CloudKit container/schema is next rebuilt clean, do NOT │ -/// │ recreate the `Ping.scope` field. It is intentionally gone. │ -/// └─────────────────────────────────────────────────────────────────┘ -enum PingScope: String, Sendable { - case square - case word - case puzzle -} - -struct Ping: Sendable { - let recordName: String - let gameID: UUID - let authorID: String - let deviceID: String - let playerName: String - let puzzleTitle: String - let kind: PingKind - let scope: PingScope? - /// Kind-specific JSON. `.friend`: `{friendShareURL,pairKey,ownerAuthorID}`; - /// `.invite`: `{gameShareURL}`; `.check`/`.reveal`: `{scope}` (see - /// PingScope). nil for join/win/resign. - let payload: String? - /// Recipient authorID for a directed ping (`.win`/`.resign`); nil ⇒ - /// broadcast — every recipient acts on it. A device ignores a ping whose - /// `addressee` is set to someone other than its own author. - let addressee: String? -} - -struct Session: Sendable { - let recordName: String - let gameID: UUID - let authorID: String - let playerName: String - let puzzleTitle: String - let updatedAt: Date -} - - -/// `payload` JSON for `.check`/`.reveal` pings — the check/reveal granularity -/// that used to live in the legacy `Ping.scope` field (see PingScope). The -/// `scope` key is required, so decoding a different kind's payload (lease, -/// friend, invite) yields nil rather than a false match. -struct PingScopePayload: Codable, Sendable { - let scope: String - - func encoded() -> String? { - (try? JSONEncoder().encode(self)).flatMap { String(data: $0, encoding: .utf8) } - } - - static func decode(_ string: String?) -> PingScopePayload? { - guard let data = string?.data(using: .utf8) else { return nil } - return try? JSONDecoder().decode(PingScopePayload.self, from: data) - } -} /// Owns the CloudKit sync lifecycle via two `CKSyncEngine` instances — one for /// the private database (owned games and shares) and one for the shared @@ -128,15 +43,15 @@ actor SyncEngine { let container: CKContainer let persistence: PersistenceController - private var privateEngine: CKSyncEngine? - private var sharedEngine: CKSyncEngine? + var privateEngine: CKSyncEngine? + var sharedEngine: CKSyncEngine? /// In-memory map for Ping records pending send. Pings have no Core Data /// backing — they're write-once-and-forget — so we stash the minimal data /// here keyed by record name and look it up in `buildRecord`. private var pendingPings: [String: PingPayload] = [:] - private struct PingPayload { + struct PingPayload { let gameID: UUID let authorID: String let deviceID: String @@ -158,45 +73,45 @@ actor SyncEngine { /// actually wired up end-to-end. private var loggedFirstSharedPushPayload = false - private var onRemoteMovesUpdated: (@MainActor @Sendable (Set<UUID>) async -> Void)? + var onRemoteMovesUpdated: (@MainActor @Sendable (Set<UUID>) async -> Void)? /// Fires with the game IDs for which a collaborator's `Player` record was /// seen for the **first time** (a new `PlayerEntity` was created) — not on /// their subsequent name / cursor updates. Independent of moves; the /// friendship bootstrap keys off this so a collaborator becomes a friend /// once, as soon as their identity syncs, without waiting for a move. - private var onRemotePlayersUpdated: (@MainActor @Sendable (Set<UUID>) async -> Void)? - private var onPings: (@MainActor @Sendable ([Ping]) async -> Void)? + var onRemotePlayersUpdated: (@MainActor @Sendable (Set<UUID>) async -> Void)? + var onPings: (@MainActor @Sendable ([Ping]) async -> Void)? private var onAccountChange: (@MainActor @Sendable () async -> Void)? private var onGameAccessRevoked: (@MainActor @Sendable (UUID) async -> Void)? private var onGameRemoved: (@MainActor @Sendable (UUID) async -> Void)? /// Fires with the game IDs whose Ping record(s) were just deleted on the /// server (a sibling device consumed a directed ping). Drives cross-device /// withdrawal of the notification this device may have shown for it. - private var onPingDeleted: (@MainActor @Sendable (Set<UUID>) async -> Void)? + var onPingDeleted: (@MainActor @Sendable (Set<UUID>) async -> Void)? /// Fires with (gameID, readAt) pairs lifted from inbound Player records /// whose authorID matches the local user. A sibling device has recorded /// the account's read horizon; active sessions may move it into the near /// future and later close it with a lower current-time value. - private var onIncomingReadCursor: (@MainActor @Sendable ([(UUID, Date)]) async -> Void)? + var onIncomingReadCursor: (@MainActor @Sendable ([(UUID, Date)]) async -> Void)? private var localAuthorIDProvider: (@MainActor @Sendable () -> String?)? private var tracer: (@MainActor @Sendable (String) -> Void)? - private var liveQueryCheckpoints: [String: Date] = [:] - private let liveQueryCheckpointOverlap: TimeInterval = 5 + var liveQueryCheckpoints: [String: Date] = [:] + let liveQueryCheckpointOverlap: TimeInterval = 5 /// Per-scope checkpoint for the background ping fast path. Independent of /// CKSyncEngine's change tokens and of `liveQueryCheckpoints` (which are /// per-zone and Moves/Player oriented). Keyed by databaseScope value /// (0 = private, 1 = shared). - private var pingPushCheckpoints: [Int16: Date] = [:] - private let pingPushCheckpointOverlap: TimeInterval = 30 + var pingPushCheckpoints: [Int16: Date] = [:] + let pingPushCheckpointOverlap: TimeInterval = 30 /// Per-scope record-name → modificationDate of Ping records already /// surfaced by the fast path. The time-window query deliberately re-fetches /// anything within `pingPushCheckpointOverlap` of the floor (skew safety), /// so without this a record — unboundedly, the newest one — would re-emit /// on every push. Pruned to the overlap window each scan, so it stays /// small. Mirrors the presentation-layer dedupe in `NotificationState`. - private var seenPingRecords: [Int16: [String: Date]] = [:] - private let backgroundSessionLookback: TimeInterval = 10 * 60 + var seenPingRecords: [Int16: [String: Date]] = [:] + let backgroundSessionLookback: TimeInterval = 10 * 60 func setTracer(_ t: @MainActor @Sendable @escaping (String) -> Void) { tracer = t @@ -357,15 +272,13 @@ actor SyncEngine { /// while before deciding to ship it. func enqueueMoves(gameIDs: Set<UUID>) { guard !gameIDs.isEmpty else { return } - var kickPrivate = false - var kickShared = false let ctx = persistence.container.newBackgroundContext() - ctx.performAndWait { + let (privateRecordIDs, sharedRecordIDs): ([CKRecord.ID], [CKRecord.ID]) = ctx.performAndWait { + var privateIDs: [CKRecord.ID] = [] + var sharedIDs: [CKRecord.ID] = [] for gameID in gameIDs { guard let info = zoneInfo(forGameID: gameID, in: ctx) else { continue } let isShared = info.scope == 1 - let engine = isShared ? sharedEngine : privateEngine - guard let engine else { continue } let req = NSFetchRequest<MovesEntity>(entityName: "MovesEntity") req.predicate = NSPredicate( format: "game.id == %@ AND deviceID == %@", @@ -377,14 +290,24 @@ actor SyncEngine { let recordName = entity.ckRecordName else { continue } let recordID = CKRecord.ID(recordName: recordName, zoneID: info.zoneID) - engine.state.add(pendingRecordZoneChanges: [.saveRecord(recordID)]) - if isShared { kickShared = true } else { kickPrivate = true } + if isShared { + sharedIDs.append(recordID) + } else { + privateIDs.append(recordID) + } } + return (privateIDs, sharedIDs) } - if kickPrivate, let engine = privateEngine { + if !privateRecordIDs.isEmpty, let engine = privateEngine { + engine.state.add( + pendingRecordZoneChanges: privateRecordIDs.map { .saveRecord($0) } + ) sendChangesDetached(on: engine) } - if kickShared, let engine = sharedEngine { + if !sharedRecordIDs.isEmpty, let engine = sharedEngine { + engine.state.add( + pendingRecordZoneChanges: sharedRecordIDs.map { .saveRecord($0) } + ) sendChangesDetached(on: engine) } } @@ -444,7 +367,7 @@ actor SyncEngine { req.predicate = NSPredicate(format: "id == %@", gameID as CVarArg) req.fetchLimit = 1 let entity = try? ctx.fetch(req).first - let title = Self.notificationTitle(for: entity) + let title = PuzzleNotificationText.title(for: entity) return (info, title) } guard let zoneAndTitle else { @@ -702,15 +625,6 @@ actor SyncEngine { sendChangesDetached(on: engine) } - private nonisolated static func notificationTitle(for entity: GameEntity?) -> String { - guard let entity else { return "" } - return PuzzleNotificationText.title( - entity.title ?? "", - publisher: entity.cachedPublisher, - date: entity.cachedPuzzleDate - ) - } - /// Registers a Player record as a pending send. Used by `PlayerNamePublisher` /// when the local user renames; one record per (game, authorID), so /// participants only ever write their own slot. @@ -750,913 +664,6 @@ actor SyncEngine { _ = try await (p, s) } - /// Live-read path for the currently open game. On device, - /// CKSyncEngine.fetchChanges() can return successfully without delivering - /// the active zone's record changes until a later broad reconciliation. - /// This direct pull is deliberately narrow: it refreshes the active - /// game's Game record and recently-modified Moves/Player records in that - /// game's zone, then applies them through the same idempotent merge path - /// used by CKSyncEngine events. Event-like records such as Ping are - /// intentionally ignored because live play only needs the current - /// collaboration state. - @discardableResult - func fetchLiveGameDirect(scope: CKDatabase.Scope, gameID: UUID) async throws -> Bool { - let database: CKDatabase - let scopeValue: Int16 - let label: String - switch scope { - case .private: - database = container.privateCloudDatabase - scopeValue = 0 - label = "private" - case .shared: - database = container.sharedCloudDatabase - scopeValue = 1 - label = "shared" - case .public: - return false - @unknown default: - return false - } - - let ctx = persistence.container.newBackgroundContext() - guard let info = zoneInfo(forGameID: gameID, in: ctx), - info.scope == scopeValue - else { - await trace("\(label) live query skipped: no active game in scope") - return false - } - - let checkpointKey = "\(scopeValue):\(gameID.uuidString)" - let since = liveQueryCheckpoints[checkpointKey]? - .addingTimeInterval(-liveQueryCheckpointOverlap) - - let gameRecordID = CKRecord.ID( - recordName: RecordSerializer.recordName(forGameID: gameID), - zoneID: info.zoneID - ) - // The Game fetch and the Moves/Player queries are independent CK - // round-trips. Fire them in parallel so total latency is bounded by - // the slowest of the three rather than their sum. - async let gameResultsTask = database.records( - for: [gameRecordID], - desiredKeys: ["title", "completedAt", "shareRecordName"] - ) - async let movesTask = queryLiveRecords( - type: "Moves", - database: database, - zoneID: info.zoneID, - since: since, - desiredKeys: ["authorID", "deviceID", "cells", "updatedAt"] - ) - async let playersTask = queryLiveRecords( - type: "Player", - database: database, - zoneID: info.zoneID, - since: since, - desiredKeys: ["authorID", "name", "updatedAt", "selRow", "selCol", "selDir", "readAt"] - ) - let gameResults = try await gameResultsTask - let moves = try await movesTask - let players = try await playersTask - - var records: [CKRecord] = [] - let fetchedGameRecord: Bool - if case .success(let record)? = gameResults[gameRecordID] { - records.append(record) - fetchedGameRecord = true - } else { - fetchedGameRecord = false - } - records.append(contentsOf: moves) - records.append(contentsOf: players) - - await applyDirectRecordZoneChanges( - records: records, - deletions: [], - scopeValue: scopeValue - ) - - let latestModification = records.compactMap(\.modificationDate).max() - if let latestModification { - liveQueryCheckpoints[checkpointKey] = latestModification - } - - await trace( - "\(label) live query fetch \(gameID.uuidString.prefix(8)): " + - "game=\(fetchedGameRecord ? 1 : 0), " + - "moves=\(moves.count), players=\(players.count)" - ) - return true - } - - /// Background-wake fast path for surfacing collaborator notifications. - /// Queries every known zone in the given scope for Ping records modified - /// since the per-scope checkpoint and feeds them to `onPings`. Bypasses - /// `CKSyncEngine.fetchChanges()` because that path can return without - /// delivering events from a silent-push wake (same Apple quirk that - /// motivated `fetchLiveGameDirect`). Moves / Player / Game records are - /// deliberately left for the engine-driven or foreground fetch. - @discardableResult - func fetchPushPingsDirect(scope: CKDatabase.Scope) async throws -> Int { - let database: CKDatabase - let scopeValue: Int16 - let label: String - switch scope { - case .private: - database = container.privateCloudDatabase - scopeValue = 0 - label = "private" - case .shared: - database = container.sharedCloudDatabase - scopeValue = 1 - label = "shared" - case .public: - return 0 - @unknown default: - return 0 - } - - let ctx = persistence.container.newBackgroundContext() - // Completed puzzles are excluded: the fast path only shaves push - // latency for live collaboration, and finished zones' late pings - // still land via CKSyncEngine's own change fetch. This trims the - // per-push fan-out from every known zone to just the active ones. - let zones = knownZones( - forScope: scopeValue, - onlyIncomplete: true, - in: ctx - ) - guard !zones.isEmpty else { - await trace("\(label) ping fast-path: no known zones") - return 0 - } - - let scopeCheckpoint = pingPushCheckpoints[scopeValue]? - .addingTimeInterval(-pingPushCheckpointOverlap) - - // Fan the per-zone Ping queries out concurrently. The actor's await - // points release isolation between round-trips, so the per-zone CK - // requests overlap; a serial N-zone scan becomes a single parallel - // batch. Per-zone errors are caught and traced so one transient - // failure doesn't suppress notifications from healthy zones. - struct PerZonePings: Sendable { - let records: [CKRecord] - let orphanedZone: CKRecordZone.ID? - } - let perZoneRecords = await withTaskGroup(of: PerZonePings.self) { group in - for (zoneID, createdAt) in zones { - // Scope checkpoint (if present) wins — it's forward-moving - // across all zones. On first run for a given scope we fall - // back to the game's createdAt floor so the ping that - // triggered this wake is still in range, but pings older - // than the device's first knowledge of the game are not. - let since = scopeCheckpoint - ?? createdAt.addingTimeInterval(-pingPushCheckpointOverlap) - group.addTask { [weak self] in - guard let self else { return PerZonePings(records: [], orphanedZone: nil) } - do { - let records = try await self.queryLiveRecords( - type: "Ping", - database: database, - zoneID: zoneID, - since: since, - desiredKeys: ["authorID", "deviceID", "playerName", "puzzleTitle", "kind", "payload"] - ) - return PerZonePings(records: records, orphanedZone: nil) - } catch { - let orphan: CKRecordZone.ID? - if scope == .shared, - self.isInvalidSharedZoneOwnerError(error as NSError) { - orphan = zoneID - } else { - orphan = nil - } - await self.trace( - "\(label) ping fast-path: zone \(zoneID.zoneName) failed: " + - "\(error.localizedDescription)" - ) - return PerZonePings(records: [], orphanedZone: orphan) - } - } - } - var all: [PerZonePings] = [] - for await batch in group { - all.append(batch) - } - return all - } - let collected: [CKRecord] = perZoneRecords.flatMap(\.records) - - // Dedupe by record name: the overlap window re-fetches recent pings on - // every push, so emit each only on first sighting. Without this the - // newest ping re-fires forever — the floor is the stored checkpoint - // minus the overlap, so `modificationDate > floor` always re-matches it. - var seen = seenPingRecords[scopeValue] ?? [:] - var pings: [Ping] = [] - var fetchedCount = 0 - for record in collected { - guard let ping = Self.parsePingRecord(record) else { continue } - fetchedCount += 1 - let modDate = record.modificationDate ?? Date() - if seen.updateValue(modDate, forKey: record.recordID.recordName) == nil { - pings.append(ping) - } - } - - // Advance the checkpoint monotonically — `max(prior, latest)`, never - // `= latest` — so a slow zone's older batch can't drag every zone's - // window backward. - if let latest = collected.compactMap(\.modificationDate).max() { - let prior = pingPushCheckpoints[scopeValue] ?? .distantPast - pingPushCheckpoints[scopeValue] = max(prior, latest) - } - // Forget names the next query's floor can no longer return, keeping - // the seen set bounded to the overlap window rather than the session. - if let checkpoint = pingPushCheckpoints[scopeValue] { - let floor = checkpoint.addingTimeInterval(-pingPushCheckpointOverlap) - seen = seen.filter { $0.value >= floor } - } - seenPingRecords[scopeValue] = seen - - let orphans = Set(perZoneRecords.compactMap(\.orphanedZone)) - if !orphans.isEmpty { - await applyZoneOrphaning(orphans, isPrivate: scope == .private) - } - - await trace( - "\(label) ping fast-path: zones=\(zones.count), " + - "pings=\(pings.count), dup=\(fetchedCount - pings.count)" - ) - - if !pings.isEmpty, let onPings { - await onPings(pings) - } - return pings.count - } - - @discardableResult - func fetchBackgroundSessionsDirect(scope: CKDatabase.Scope) async throws -> ([Ping], [Session]) { - let database: CKDatabase - let scopeValue: Int16 - let label: String - switch scope { - case .private: - database = container.privateCloudDatabase - scopeValue = 0 - label = "private" - case .shared: - database = container.sharedCloudDatabase - scopeValue = 1 - label = "shared" - case .public: - return ([], []) - @unknown default: - return ([], []) - } - - let ctx = persistence.container.newBackgroundContext() - let zones = incompleteKnownZones(forScope: scopeValue, in: ctx) - guard !zones.isEmpty else { - await trace("\(label) background session scan: no incomplete zones") - return ([], []) - } - - let since = Date().addingTimeInterval(-backgroundSessionLookback) - struct PerZoneActivity: Sendable { - let records: [CKRecord] - let pings: [Ping] - let players: [Session] - let orphanedZone: CKRecordZone.ID? - } - - let perZone = await withTaskGroup(of: PerZoneActivity.self) { group in - for zone in zones { - group.addTask { [weak self] in - guard let self else { - return PerZoneActivity(records: [], pings: [], players: [], orphanedZone: nil) - } - do { - async let pingRecords = self.queryLiveRecords( - type: "Ping", - database: database, - zoneID: zone.zoneID, - since: since, - desiredKeys: ["authorID", "deviceID", "playerName", "puzzleTitle", "kind", "payload"] - ) - async let playerRecords = self.queryLiveRecords( - type: "Player", - database: database, - zoneID: zone.zoneID, - since: since, - desiredKeys: ["authorID", "name", "updatedAt", "selRow", "selCol", "selDir", "readAt"] - ) - let (pings, players) = try await (pingRecords, playerRecords) - let activities = players.compactMap { record in - Self.parseSessionRecord(record, puzzleTitle: zone.title) - } - return PerZoneActivity( - records: players, - pings: pings.compactMap(Self.parsePingRecord), - players: activities, - orphanedZone: nil - ) - } catch { - let orphan: CKRecordZone.ID? - if scope == .shared, - self.isInvalidSharedZoneOwnerError(error as NSError) { - orphan = zone.zoneID - } else { - orphan = nil - } - await self.trace( - "\(label) background session scan: zone \(zone.zoneID.zoneName) failed: " + - "\(error.localizedDescription)" - ) - return PerZoneActivity( - records: [], - pings: [], - players: [], - orphanedZone: orphan - ) - } - } - } - var all: [PerZoneActivity] = [] - for await result in group { - all.append(result) - } - return all - } - - let records = perZone.flatMap(\.records) - if !records.isEmpty { - await applyDirectRecordZoneChanges( - records: records, - deletions: [], - scopeValue: scopeValue - ) - } - - let orphans = Set(perZone.compactMap(\.orphanedZone)) - if !orphans.isEmpty { - await applyZoneOrphaning(orphans, isPrivate: scope == .private) - } - - let pings = perZone.flatMap(\.pings) - let players = perZone.flatMap(\.players) - await trace( - "\(label) background session scan: zones=\(zones.count), " + - "players=\(players.count), pings=\(pings.count)" - ) - return (pings, players) - } - - /// Discovers games whose zones the device has never seen and pulls their - /// Game / Moves / Player records directly, bypassing CKSyncEngine. - /// - /// CKSyncEngine is supposed to deliver database-scope change events that - /// announce new zones, but on a silent-push wake those events can be - /// withheld until the next foreground (the same quirk that motivated - /// `fetchLiveGameDirect` and `fetchPushPingsDirect`). Without zone - /// discovery, a game created on one device only appears on a second - /// device after CKSyncEngine eventually catches up — which can be a long - /// time if the second device only ever opens the app briefly. - /// - /// Enumerates zones via `CKDatabase.allRecordZones()`, diffs against - /// `knownZones`, and pulls every record type we care about for each new - /// zone. The pull is unbounded in time because, by definition, the - /// device has no checkpoint for a zone it hasn't seen. - /// - /// Returns the number of newly-discovered zones. - @discardableResult - func discoverNewZonesDirect(scope: CKDatabase.Scope) async throws -> Int { - let database: CKDatabase - let scopeValue: Int16 - let label: String - switch scope { - case .private: - database = container.privateCloudDatabase - scopeValue = 0 - label = "private" - case .shared: - database = container.sharedCloudDatabase - scopeValue = 1 - label = "shared" - case .public: - return 0 - @unknown default: - return 0 - } - - let serverZones = try await database.allRecordZones() - let ctx = persistence.container.newBackgroundContext() - let known = knownZones(forScope: scopeValue, in: ctx) - let knownKeys = Set(known.map { "\($0.zoneID.ownerName)|\($0.zoneID.zoneName)" }) - - let candidates: [CKRecordZone.ID] = serverZones - .map(\.zoneID) - .filter { id in - id != CKRecordZone.ID.default && - !knownKeys.contains("\(id.ownerName)|\(id.zoneName)") - } - - guard !candidates.isEmpty else { - // Count non-default server zones so the figure matches what the - // candidate filter actually compares: the private DB's - // allRecordZones() always includes _defaultZone, which knownZones - // never tracks, so a raw serverZones.count reads a permanent +1. - let serverNonDefault = serverZones.lazy - .filter { $0.zoneID != .default } - .count - await trace( - "\(label) zone discovery: nothing new " + - "(server=\(serverNonDefault), known=\(known.count))" - ) - return 0 - } - - // Two layers of concurrency. Outer: fan the per-zone work out - // through a TaskGroup so N candidate zones don't serialize. Inner: - // fire Game / Moves / Player against each zone with `async let` so - // a single zone's three round-trips also overlap. The Game query - // gates whether the zone hosts a Crossmate puzzle, but Moves and - // Player against a non-puzzle zone simply return empty, so always - // pulling all three in parallel and discarding when Game is empty - // is cheaper than waiting on Game first. Per-zone errors are - // caught and traced so one bad zone doesn't abort the rest. - struct PerZoneResult: Sendable { - let records: [CKRecord] - let hasGame: Bool - } - let perZoneResults = await withTaskGroup(of: PerZoneResult.self) { group in - for zoneID in candidates { - group.addTask { [weak self] in - guard let self else { - return PerZoneResult(records: [], hasGame: false) - } - do { - async let games = try await self.queryLiveRecords( - type: "Game", - database: database, - zoneID: zoneID, - since: nil, - desiredKeys: ["title", "completedAt", "shareRecordName", "puzzleSource"] - ) - async let moves = try await self.queryLiveRecords( - type: "Moves", - database: database, - zoneID: zoneID, - since: nil, - desiredKeys: ["authorID", "deviceID", "cells", "updatedAt"] - ) - async let players = try await self.queryLiveRecords( - type: "Player", - database: database, - zoneID: zoneID, - since: nil, - desiredKeys: ["authorID", "name", "updatedAt", "selRow", "selCol", "selDir", "readAt"] - ) - let (g, m, p) = try await (games, moves, players) - guard !g.isEmpty else { - return PerZoneResult(records: [], hasGame: false) - } - return PerZoneResult(records: g + m + p, hasGame: true) - } catch { - await self.trace( - "\(label) zone discovery: zone \(zoneID.zoneName) failed: " + - "\(error.localizedDescription)" - ) - return PerZoneResult(records: [], hasGame: false) - } - } - } - var all: [PerZoneResult] = [] - for await result in group { - all.append(result) - } - return all - } - let collected: [CKRecord] = perZoneResults.flatMap(\.records) - let zonesWithGame = perZoneResults.reduce(into: 0) { $0 += $1.hasGame ? 1 : 0 } - - await applyDirectRecordZoneChanges( - records: collected, - deletions: [], - scopeValue: scopeValue - ) - - await trace( - "\(label) zone discovery: candidates=\(candidates.count), " + - "withGame=\(zonesWithGame), records=\(collected.count)" - ) - return zonesWithGame - } - - /// Pulls incremental updates for every game the device already knows - /// about in the given scope, bypassing CKSyncEngine. Pairs with - /// `discoverNewZonesDirect` so that pull-to-refresh covers both halves - /// of "what might have changed elsewhere": new zones *and* updates to - /// existing ones. Each game is dispatched to the existing - /// `fetchLiveGameDirect`, which uses the `liveQueryCheckpoints` - /// cursor so we only pull Moves/Player records newer than the last - /// direct fetch. Per-game errors are caught and traced so one bad zone - /// doesn't abort the rest. - /// - /// Returns the number of games for which the direct fetch reported - /// records were applied. - @discardableResult - func fetchKnownZoneUpdatesDirect(scope: CKDatabase.Scope) async throws -> Int { - let scopeValue: Int16 - let label: String - switch scope { - case .private: - scopeValue = 0 - label = "private" - case .shared: - scopeValue = 1 - label = "shared" - case .public: - return 0 - @unknown default: - return 0 - } - - let ctx = persistence.container.newBackgroundContext() - let gameIDs = knownGameIDs(forScope: scopeValue, in: ctx) - guard !gameIDs.isEmpty else { - await trace("\(label) known-zone refresh: no known games") - return 0 - } - - // Fan the per-game fetches out concurrently. Each fetchLiveGameDirect - // call hits a different zone with a different checkpoint key, so they - // don't race on shared state. The actor still serializes access to - // liveQueryCheckpoints at non-await points, but the actual CK round- - // trips overlap, turning a serial 1s-per-game wait into a single - // parallel batch. - let handled = await withTaskGroup(of: Bool.self) { group in - for gameID in gameIDs { - group.addTask { [weak self] in - guard let self else { return false } - do { - return try await self.fetchLiveGameDirect( - scope: scope, - gameID: gameID - ) - } catch { - await self.trace( - "\(label) known-zone refresh: game " + - "\(gameID.uuidString.prefix(8)) failed: " + - "\(error.localizedDescription)" - ) - return false - } - } - } - var count = 0 - for await result in group where result { - count += 1 - } - return count - } - await trace( - "\(label) known-zone refresh: games=\(gameIDs.count), handled=\(handled)" - ) - return handled - } - - /// Background-push catch-up for library freshness. Unlike - /// `fetchKnownZoneUpdatesDirect`, this intentionally skips Player records - /// because the immediate background session scan already covers presence. - /// The delayed caller exists to catch the common ordering where a cursor - /// save triggers the silent push before the corresponding Moves record is - /// visible in CloudKit. - /// - /// Returns the number of Moves records fetched. Game records are always - /// fetched for metadata freshness, but delayed push catch-up uses the - /// Moves count to decide whether a later safety pass is still useful. - @discardableResult - func fetchKnownGameMovesDirect(scope: CKDatabase.Scope) async throws -> Int { - let database: CKDatabase - let scopeValue: Int16 - let label: String - switch scope { - case .private: - database = container.privateCloudDatabase - scopeValue = 0 - label = "private" - case .shared: - database = container.sharedCloudDatabase - scopeValue = 1 - label = "shared" - case .public: - return 0 - @unknown default: - return 0 - } - - let ctx = persistence.container.newBackgroundContext() - let zones = incompleteKnownZones(forScope: scopeValue, in: ctx) - guard !zones.isEmpty else { - await trace("\(label) game/moves catch-up: no incomplete zones") - return 0 - } - - struct PerZoneGameMoves: Sendable { - let records: [CKRecord] - let gameCount: Int - let moveCount: Int - let orphanedZone: CKRecordZone.ID? - } - let perZone = await withTaskGroup(of: PerZoneGameMoves.self) { group in - for zone in zones { - group.addTask { [weak self] in - guard let self else { - return PerZoneGameMoves( - records: [], - gameCount: 0, - moveCount: 0, - orphanedZone: nil - ) - } - do { - let checkpointKey = "\(scopeValue):\(zone.gameID.uuidString)" - let since = await self.liveQueryCheckpoints[checkpointKey]? - .addingTimeInterval(-self.liveQueryCheckpointOverlap) - let gameRecordID = CKRecord.ID( - recordName: RecordSerializer.recordName(forGameID: zone.gameID), - zoneID: zone.zoneID - ) - async let gameResultsTask = database.records( - for: [gameRecordID], - desiredKeys: ["title", "completedAt", "shareRecordName"] - ) - async let movesTask = self.queryLiveRecords( - type: "Moves", - database: database, - zoneID: zone.zoneID, - since: since, - desiredKeys: ["authorID", "deviceID", "cells", "updatedAt"] - ) - let (gameResults, moves) = try await (gameResultsTask, movesTask) - - var records = moves - let gameCount: Int - if case .success(let record)? = gameResults[gameRecordID] { - records.append(record) - gameCount = 1 - } else { - gameCount = 0 - } - - if let latestModification = records.compactMap(\.modificationDate).max() { - await self.setLiveQueryCheckpoint( - latestModification, - scopeValue: scopeValue, - gameID: zone.gameID - ) - } - - return PerZoneGameMoves( - records: records, - gameCount: gameCount, - moveCount: moves.count, - orphanedZone: nil - ) - } catch { - let orphan: CKRecordZone.ID? - if scope == .shared, - self.isInvalidSharedZoneOwnerError(error as NSError) { - orphan = zone.zoneID - } else { - orphan = nil - } - await self.trace( - "\(label) game/moves catch-up: zone \(zone.zoneID.zoneName) failed: " + - "\(error.localizedDescription)" - ) - return PerZoneGameMoves( - records: [], - gameCount: 0, - moveCount: 0, - orphanedZone: orphan - ) - } - } - } - - var all: [PerZoneGameMoves] = [] - for await result in group { - all.append(result) - } - return all - } - - let records = perZone.flatMap(\.records) - await applyDirectRecordZoneChanges( - records: records, - deletions: [], - scopeValue: scopeValue - ) - - let orphans = Set(perZone.compactMap(\.orphanedZone)) - if !orphans.isEmpty { - await applyZoneOrphaning(orphans, isPrivate: scope == .private) - } - - let gameCount = perZone.reduce(0) { $0 + $1.gameCount } - let moveCount = perZone.reduce(0) { $0 + $1.moveCount } - await trace( - "\(label) game/moves catch-up: zones=\(zones.count), " + - "game=\(gameCount), moves=\(moveCount)" - ) - return moveCount - } - - private func queryLiveRecords( - type: CKRecord.RecordType, - database: CKDatabase, - zoneID: CKRecordZone.ID, - since: Date?, - desiredKeys: [CKRecord.FieldKey] - ) async throws -> [CKRecord] { - let since = since ?? Date(timeIntervalSince1970: 0) - return try await queryRecords( - type: type, - database: database, - zoneID: zoneID, - predicate: NSPredicate(format: "modificationDate > %@", since as NSDate), - desiredKeys: desiredKeys - ) - } - - private func queryRecords( - type: CKRecord.RecordType, - database: CKDatabase, - zoneID: CKRecordZone.ID, - predicate: NSPredicate, - desiredKeys: [CKRecord.FieldKey] - ) async throws -> [CKRecord] { - let query = CKQuery(recordType: type, predicate: predicate) - - var records: [CKRecord] = [] - var result = try await database.records( - matching: query, - inZoneWith: zoneID, - desiredKeys: desiredKeys, - resultsLimit: CKQueryOperation.maximumResults - ) - records.append(contentsOf: result.matchResults.compactMap { _, recordResult in - try? recordResult.get() - }) - - while let cursor = result.queryCursor { - result = try await database.records( - continuingMatchFrom: cursor, - desiredKeys: desiredKeys, - resultsLimit: CKQueryOperation.maximumResults - ) - records.append(contentsOf: result.matchResults.compactMap { _, recordResult in - try? recordResult.get() - }) - } - return records - } - - private func setLiveQueryCheckpoint( - _ date: Date, - scopeValue: Int16, - gameID: UUID - ) { - liveQueryCheckpoints["\(scopeValue):\(gameID.uuidString)"] = date - } - - private func deleteRecords( - withIDs recordIDs: [CKRecord.ID], - in database: CKDatabase - ) async throws { - guard !recordIDs.isEmpty else { return } - let batchSize = 200 - var index = recordIDs.startIndex - while index < recordIDs.endIndex { - let end = recordIDs.index(index, offsetBy: batchSize, limitedBy: recordIDs.endIndex) - ?? recordIDs.endIndex - let batch = Array(recordIDs[index..<end]) - try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Void, Error>) in - let op = CKModifyRecordsOperation( - recordsToSave: nil, - recordIDsToDelete: batch - ) - op.qualityOfService = .utility - op.modifyRecordsResultBlock = { result in - cont.resume(with: result) - } - database.add(op) - } - index = end - } - } - - private func applyDirectRecordZoneChanges( - records: [CKRecord], - deletions: [(CKRecord.ID, CKRecord.RecordType)], - scopeValue: Int16 - ) async { - guard !records.isEmpty || !deletions.isEmpty else { return } - let ctx = persistence.container.newBackgroundContext() - ctx.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump - let localAuthorID = await currentLocalAuthorID() - let (movesUpdatedGameIDs, affectedGameIDs, playersUpdatedGameIDs, readCursors): - (Set<UUID>, Set<UUID>, Set<UUID>, [(UUID, Date)]) = ctx.performAndWait { - var movesUpdated = Set<UUID>() - var affected = Set<UUID>() - var playersUpdated = Set<UUID>() - var read: [(UUID, Date)] = [] - for record in records { - switch record.recordType { - case "Game": - let entity = RecordSerializer.applyGameRecord(record, to: ctx, databaseScope: scopeValue) - if let id = entity.id { affected.insert(id) } - case "Moves": - if let value = RecordSerializer.parseMovesRecord(record) { - let cellsChanged = RecordSerializer.applyMovesRecord( - record, - value: value, - to: ctx, - localAuthorID: localAuthorID - ) - if cellsChanged { movesUpdated.insert(value.gameID) } - affected.insert(value.gameID) - } - case "Player": - if let (gameID, _) = RecordSerializer.parsePlayerRecordName(record.recordID.recordName) { - self.applyPlayerRecord( - record, - in: ctx, - localAuthorID: localAuthorID, - onFirstTime: { playersUpdated.insert($0) }, - onReadCursor: { read.append(($0, $1)) } - ) - affected.insert(gameID) - } - default: - break - } - } - for deletion in deletions { - self.applyDeletion( - recordID: deletion.0, - recordType: deletion.1, - in: ctx - ) - if let id = self.gameID(fromRecordName: deletion.0.recordName) { - affected.insert(id) - } - } - for gameID in movesUpdated { - self.replayCellCache(for: gameID, in: ctx) - } - if ctx.hasChanges { - do { - try ctx.save() - } catch { - let nsError = error as NSError - print( - "SyncEngine: direct-push ctx.save failed " + - "— domain=\(nsError.domain) code=\(nsError.code) " + - "\(nsError.localizedDescription)" - ) - } - } - return (movesUpdated, affected, playersUpdated, read) - } - - if let onRemoteMovesUpdated, !movesUpdatedGameIDs.isEmpty { - await onRemoteMovesUpdated(movesUpdatedGameIDs) - } - if let onRemotePlayersUpdated, !playersUpdatedGameIDs.isEmpty { - await onRemotePlayersUpdated(playersUpdatedGameIDs) - } - if let onIncomingReadCursor, !readCursors.isEmpty { - await onIncomingReadCursor(readCursors) - } - let pingDeletedGameIDs = Set(deletions.compactMap { deletion -> UUID? in - deletion.0.recordName.hasPrefix("ping-") - ? gameID(fromRecordName: deletion.0.recordName) : nil - }) - if let onPingDeleted, !pingDeletedGameIDs.isEmpty { - await onPingDeleted(pingDeletedGameIDs) - } - if !affectedGameIDs.isEmpty { - NotificationCenter.default.post( - name: .playerRosterShouldRefresh, - object: nil, - userInfo: ["gameIDs": affectedGameIDs] - ) - } - } - func pushChanges() async throws { async let p: Void = privateEngine?.sendChanges() ?? () async let s: Void = sharedEngine?.sendChanges() ?? () @@ -1665,147 +672,6 @@ actor SyncEngine { // MARK: - Diagnostics - struct DiagnosticSnapshot: Sendable { - let accountStatus: CKAccountStatus - let engineRunning: Bool - let pendingChangesCount: Int - let privatePendingCount: Int - let sharedPendingCount: Int - } - - /// Record names of pending `.saveRecord` changes queued on the given - /// scope's engine. Used by tests to verify that outbound enqueues route - /// to the correct database. - func pendingSaveRecordNames(scope: CKDatabase.Scope) -> [String] { - let engine = scope == .shared ? sharedEngine : privateEngine - guard let engine else { return [] } - return engine.state.pendingRecordZoneChanges.compactMap { - if case .saveRecord(let id) = $0 { return id.recordName } - return nil - } - } - - /// Zone names queued for deletion on the given scope's engine. Used by - /// tests to verify delete routing after the local GameEntity is gone. - func pendingDeletedZoneNames(scope: CKDatabase.Scope) -> [String] { - let engine = scope == .shared ? sharedEngine : privateEngine - guard let engine else { return [] } - return engine.state.pendingDatabaseChanges.compactMap { - if case .deleteZone(let id) = $0 { return id.zoneName } - return nil - } - } - - func diagnosticSnapshot() async -> DiagnosticSnapshot { - let status: CKAccountStatus - do { status = try await container.accountStatus() } - catch { status = .couldNotDetermine } - let running = privateEngine != nil - let privateCount = privateEngine.map { $0.state.pendingRecordZoneChanges.count } ?? 0 - let sharedCount = sharedEngine.map { $0.state.pendingRecordZoneChanges.count } ?? 0 - return DiagnosticSnapshot( - accountStatus: status, - engineRunning: running, - pendingChangesCount: privateCount + sharedCount, - privatePendingCount: privateCount, - sharedPendingCount: sharedCount - ) - } - - /// Runs a series of lightweight CloudKit probes and returns human-readable - /// (name, result) pairs for display in the diagnostics view. - func probeContainer() async -> [(name: String, result: String)] { - var results: [(String, String)] = [] - results.append(("containerIdentifier", container.containerIdentifier ?? "nil")) - do { - let s = try await container.accountStatus() - results.append(("accountStatus", describeStatus(s))) - } catch { - results.append(("accountStatus", describe(error))) - } - do { - let id = try await container.userRecordID() - results.append(("userRecordID", id.recordName)) - } catch { - results.append(("userRecordID", describe(error))) - } - do { - let zones = try await container.privateCloudDatabase.allRecordZones() - let names = zones.map(\.zoneID.zoneName).joined(separator: ", ") - results.append(("privateZones", "\(zones.count) zone(s): [\(names)]")) - } catch { - results.append(("privateZones", describe(error))) - } - do { - let zones = try await container.sharedCloudDatabase.allRecordZones() - let names = zones.map(\.zoneID.zoneName).joined(separator: ", ") - results.append(("sharedZones", "\(zones.count) zone(s): [\(names)]")) - } catch { - results.append(("sharedZones", describe(error))) - } - // CKSyncEngine creates a CKDatabaseSubscription per scope on first - // start. If subscription creation silently failed, no push will ever - // fire for that scope — surface what's actually present so a missing - // entry is visible from the diagnostics view rather than diagnosed - // by elimination. - results.append(await probeSubscriptions(database: container.privateCloudDatabase, label: "privateSubs")) - results.append(await probeSubscriptions(database: container.sharedCloudDatabase, label: "sharedSubs")) - return results - } - - private func probeSubscriptions( - database: CKDatabase, - label: String - ) async -> (String, String) { - do { - let subs = try await database.allSubscriptions() - if subs.isEmpty { - return (label, "0 subscriptions — pushes will not fire") - } - let descriptions = subs.map { sub -> String in - let kind: String - switch sub { - case is CKDatabaseSubscription: kind = "database" - case is CKQuerySubscription: kind = "query" - case is CKRecordZoneSubscription: kind = "zone" - default: kind = "other(\(type(of: sub)))" - } - let silent = sub.notificationInfo?.shouldSendContentAvailable == true ? "silent" : "alert-only" - return "\(kind):\(sub.subscriptionID)[\(silent)]" - } - return (label, "\(subs.count): [\(descriptions.joined(separator: ", "))]") - } catch { - return (label, describe(error)) - } - } - - /// Fetches a single record by ID for the in-app record editor. Bypasses - /// CKSyncEngine's tracked changes — caller is responsible for triggering a - /// reconciling fetch if the record corresponds to a tracked local entity. - func fetchRecordForEdit( - scope: CKDatabase.Scope, - recordID: CKRecord.ID - ) async throws -> CKRecord { - let database = scope == .shared - ? container.sharedCloudDatabase - : container.privateCloudDatabase - return try await database.record(for: recordID) - } - - /// Saves a record edited in the in-app record editor and runs a follow-up - /// `fetchChanges` so any locally-tracked entity picks up the new server - /// change tag via CKSyncEngine rather than going stale. - func saveRecordForEdit( - scope: CKDatabase.Scope, - record: CKRecord - ) async throws -> CKRecord { - let database = scope == .shared - ? container.sharedCloudDatabase - : container.privateCloudDatabase - let saved = try await database.save(record) - try? await fetchChanges(source: "record-editor") - return saved - } /// Clears the saved state for both engines and replaces the in-memory /// engine instances so subsequent fetches walk every zone from scratch. @@ -1842,209 +708,15 @@ actor SyncEngine { // MARK: - Private helpers - private func currentLocalAuthorID() async -> String? { + func currentLocalAuthorID() async -> String? { guard let localAuthorIDProvider else { return nil } return await MainActor.run { localAuthorIDProvider() } } - private struct ZoneInfo { - let scope: Int16 - let zoneID: CKRecordZone.ID - } - - private struct ActivityZoneInfo: Sendable { - let gameID: UUID - let zoneID: CKRecordZone.ID - let title: String - } - - /// Looks up a game's scope and zone ID from Core Data. Returns `nil` if - /// the entity can't be found. Not `async` — uses `performAndWait` so it - /// can be called from non-async actor context. - private nonisolated func zoneInfo( - forGameID gameID: UUID, - in ctx: NSManagedObjectContext - ) -> ZoneInfo? { - ctx.performAndWait { - let req = NSFetchRequest<GameEntity>(entityName: "GameEntity") - req.predicate = NSPredicate(format: "id == %@", gameID as CVarArg) - req.fetchLimit = 1 - guard let entity = try? ctx.fetch(req).first else { return nil } - let zoneName = entity.ckZoneName ?? "game-\(gameID.uuidString)" - let ownerName = entity.ckZoneOwnerName ?? CKCurrentUserDefaultName - return ZoneInfo( - scope: entity.databaseScope, - zoneID: CKRecordZone.ID(zoneName: zoneName, ownerName: ownerName) - ) - } - } - - /// Game UUIDs for every locally-known *in-progress* game in the given - /// database scope. Used by the known-zone refresh path so each game can - /// be routed through `fetchLiveGameDirect`. Games with a non-nil - /// `completedAt` are excluded: once a game is completed no further moves - /// or player updates can arrive, so refreshing those zones is wasted - /// round-trips. - private nonisolated func knownGameIDs( - forScope scope: Int16, - in ctx: NSManagedObjectContext - ) -> [UUID] { - ctx.performAndWait { - let req = NSFetchRequest<GameEntity>(entityName: "GameEntity") - req.predicate = NSPredicate( - format: "databaseScope == %d AND completedAt == nil AND isAccessRevoked == NO", - scope - ) - guard let entities = try? ctx.fetch(req) else { return [] } - return entities.compactMap(\.id) - } - } - - /// Enumerates every known game zone for the given database scope, paired - /// with the `createdAt` of the corresponding GameEntity. The createdAt - /// timestamp is used as the per-zone floor for the ping fast path: pings - /// older than the moment this device first knew about the game can't be - /// of interest (for shared games, they pre-date our join; for owned - /// games, they pre-date the game's existence). - private nonisolated func knownZones( - forScope scope: Int16, - onlyIncomplete: Bool = false, - in ctx: NSManagedObjectContext - ) -> [(zoneID: CKRecordZone.ID, createdAt: Date)] { - ctx.performAndWait { - let req = NSFetchRequest<GameEntity>(entityName: "GameEntity") - // Skip access-revoked entries so an orphaned shared zone — e.g. - // one whose participant binding became invalid and now returns - // "Cannot convert userId to dsId" on every query — stops being - // re-queried by the direct fetch paths. - // - // `onlyIncomplete` additionally drops finished puzzles' game - // zones (keeps only completedAt == nil). Only the ping fast path - // passes it: that path is a - // latency shortcut, and a completed puzzle has no live - // collaboration, so a late .win/.opened there still arrives via - // CKSyncEngine's own push-driven fetchedRecordZoneChanges, which - // surfaces Ping records for every tracked zone regardless of - // completion. It must stay opt-in — discoverNewZonesDirect diffs - // the *full* known set against the server to spot new zones, so - // excluding completed games there would make every finished zone - // look new and re-pull it on each discovery. The account and - // friend zones below are appended unconditionally: they carry - // .opened/.invite/.friend, have no GameEntity, and no completion. - req.predicate = NSPredicate( - format: onlyIncomplete - ? "databaseScope == %d AND completedAt == nil AND isAccessRevoked == NO" - : "databaseScope == %d AND isAccessRevoked == NO", - scope - ) - guard let entities = try? ctx.fetch(req) else { return [] } - var seen = Set<String>() - var result: [(CKRecordZone.ID, Date)] = [] - for entity in entities { - guard let gameID = entity.id else { continue } - let zoneName = entity.ckZoneName ?? "game-\(gameID.uuidString)" - let ownerName = entity.ckZoneOwnerName ?? CKCurrentUserDefaultName - let key = "\(ownerName)|\(zoneName)" - guard seen.insert(key).inserted else { continue } - let createdAt = entity.createdAt ?? Date(timeIntervalSince1970: 0) - result.append((CKRecordZone.ID(zoneName: zoneName, ownerName: ownerName), createdAt)) - } - // Friend zones carry `.invite` / `.friend` pings but no - // GameEntity, so they're appended explicitly. The owner sees the - // zone in the private DB - // (scope 0); the participant sees it in the shared DB - // (scope 1). Blocked friends are skipped so we stop reading - // anything from them. Floor is `.distantPast`: any unseen - // invite should be processed. - let friendReq = NSFetchRequest<FriendEntity>(entityName: "FriendEntity") - friendReq.predicate = NSPredicate( - format: "databaseScope == %d AND isBlocked == NO", - scope - ) - for friend in (try? ctx.fetch(friendReq)) ?? [] { - guard let zoneName = friend.friendZoneName, - let ownerName = friend.friendZoneOwnerName - else { continue } - let key = "\(ownerName)|\(zoneName)" - guard seen.insert(key).inserted else { continue } - result.append(( - CKRecordZone.ID(zoneName: zoneName, ownerName: ownerName), - Date(timeIntervalSince1970: 0) - )) - } - return result - } - } - - private nonisolated func incompleteKnownZones( - forScope scope: Int16, - in ctx: NSManagedObjectContext - ) -> [ActivityZoneInfo] { - ctx.performAndWait { - let req = NSFetchRequest<GameEntity>(entityName: "GameEntity") - req.predicate = NSPredicate( - format: "databaseScope == %d AND completedAt == nil AND isAccessRevoked == NO", - scope - ) - guard let entities = try? ctx.fetch(req) else { return [] } - var seen = Set<String>() - var result: [ActivityZoneInfo] = [] - for entity in entities { - guard let gameID = entity.id else { continue } - let zoneName = entity.ckZoneName ?? "game-\(gameID.uuidString)" - let ownerName = entity.ckZoneOwnerName ?? CKCurrentUserDefaultName - let key = "\(ownerName)|\(zoneName)" - guard seen.insert(key).inserted else { continue } - result.append(ActivityZoneInfo( - gameID: gameID, - zoneID: CKRecordZone.ID(zoneName: zoneName, ownerName: ownerName), - title: Self.notificationTitle(for: entity) - )) - } - return result - } - } - - private nonisolated func friendZoneIDs(forScope scope: Int16) -> [CKRecordZone.ID] { - let ctx = persistence.container.newBackgroundContext() - return ctx.performAndWait { - let req = NSFetchRequest<FriendEntity>(entityName: "FriendEntity") - req.predicate = NSPredicate( - format: "databaseScope == %d AND isBlocked == NO", - scope - ) - var seen = Set<String>() - var result: [CKRecordZone.ID] = [] - for friend in (try? ctx.fetch(req)) ?? [] { - guard let zoneName = friend.friendZoneName, - let ownerName = friend.friendZoneOwnerName - else { continue } - let key = "\(ownerName)|\(zoneName)" - guard seen.insert(key).inserted else { continue } - result.append(CKRecordZone.ID(zoneName: zoneName, ownerName: ownerName)) - } - return result - } - } - /// Extracts the game UUID from any of our record name formats: - /// `game-<UUID>`, `moves-<UUID>-…`, `player-<UUID>-…`, `ping-<UUID>-…`. - private nonisolated func gameID(fromRecordName name: String) -> UUID? { - if name.hasPrefix("game-") { - return UUID(uuidString: String(name.dropFirst("game-".count))) - } - let prefix: String - if name.hasPrefix("moves-") { prefix = "moves-" } - else if name.hasPrefix("player-") { prefix = "player-" } - else if name.hasPrefix("ping-") { prefix = "ping-" } - else { return nil } - let rest = name.dropFirst(prefix.count) - return UUID(uuidString: String(rest.prefix(36))) - } - - private func trace(_ message: String) async { + func trace(_ message: String) async { guard let tracer else { return } await tracer(message) } @@ -2066,284 +738,6 @@ actor SyncEngine { } } - /// Builds the `CKRecord` for a pending change. Uses the zone ID already - /// embedded in the `recordID` — set correctly at enqueue time. - /// `pings` is a snapshot taken from the actor before this is invoked, - /// since the framework calls back synchronously off-actor. - private nonisolated func buildRecord( - for recordID: CKRecord.ID, - pings: [String: PingPayload] - ) -> CKRecord? { - let name = recordID.recordName - let zoneID = recordID.zoneID - if name.hasPrefix("ping-") { - guard let payload = pings[name] else { return nil } - return RecordSerializer.pingRecord( - gameID: payload.gameID, - authorID: payload.authorID, - deviceID: payload.deviceID, - playerName: payload.playerName, - puzzleTitle: payload.puzzleTitle, - eventTimestampMs: payload.eventTimestampMs, - kind: payload.kind, - scope: payload.scope, - payload: payload.payload, - addressee: payload.addressee, - zone: zoneID - ) - } - if name.hasPrefix("decision-") { - guard let (kind, key) = RecordSerializer.parseDecisionRecordName(name) else { - return nil - } - return RecordSerializer.decisionRecord(kind: kind, key: key, zone: zoneID) - } - let ctx = persistence.container.newBackgroundContext() - return ctx.performAndWait { - if name.hasPrefix("game-") { - let req = NSFetchRequest<GameEntity>(entityName: "GameEntity") - req.predicate = NSPredicate(format: "ckRecordName == %@", name) - req.fetchLimit = 1 - guard let entity = try? ctx.fetch(req).first else { return nil } - return RecordSerializer.gameRecord( - from: entity, - recordID: recordID, - includePuzzleSource: entity.ckSystemFields == nil - ) - } else if name.hasPrefix("moves-") { - let req = NSFetchRequest<MovesEntity>(entityName: "MovesEntity") - req.predicate = NSPredicate(format: "ckRecordName == %@", name) - req.fetchLimit = 1 - guard let entity = try? ctx.fetch(req).first, - let gameID = entity.game?.id, - let authorID = entity.authorID, - let deviceID = entity.deviceID, - let updatedAt = entity.updatedAt - else { return nil } - let cells = (entity.cells.flatMap { try? MovesCodec.decode($0) }) ?? [:] - let value = MovesValue( - gameID: gameID, - authorID: authorID, - deviceID: deviceID, - cells: cells, - updatedAt: updatedAt - ) - return try? RecordSerializer.movesRecord( - from: value, - zone: zoneID, - systemFields: entity.ckSystemFields - ) - } else if name.hasPrefix("player-") { - let req = NSFetchRequest<PlayerEntity>(entityName: "PlayerEntity") - req.predicate = NSPredicate(format: "ckRecordName == %@", name) - req.fetchLimit = 1 - guard let entity = try? ctx.fetch(req).first, - let gameID = entity.game?.id, - let authorID = entity.authorID, - let renderedName = entity.name, - let updatedAt = entity.updatedAt - else { return nil } - let selection: PlayerSelection? - if let row = entity.selRow, - let col = entity.selCol, - let dir = entity.selDir, - let direction = Puzzle.Direction(rawValue: dir.intValue) { - selection = PlayerSelection( - row: row.intValue, - col: col.intValue, - direction: direction - ) - } else { - selection = nil - } - return RecordSerializer.playerRecord( - gameID: gameID, - authorID: authorID, - name: renderedName, - updatedAt: updatedAt, - selection: selection, - readAt: entity.game?.lastReadOtherMoveAt, - zone: zoneID, - systemFields: entity.ckSystemFields - ) - } - return nil - } - } - - // MARK: - Incoming record application - - /// Applies a remote `Player` record. When this is the **first** time we've - /// seen this player (a new `PlayerEntity` row is created) — the single - /// point at which a collaborator's identity becomes known, and the trigger - /// for friendship bootstrap — `onFirstTime` is invoked with the game ID - /// just before the function returns. It is *not* called on the player's - /// later name / cursor updates. - /// - /// `onFirstTime` only records *that* the sighting happened; the actual - /// bootstrap runs later, from the post-save `onRemotePlayersUpdated` - /// callback, because the freshly created `PlayerEntity` is not visible to - /// that work until this background context has been saved and merged into - /// the view context. - /// - /// `onReadCursor` fires with `(gameID, readAt)` when the inbound record - /// carries a `readAt` field and its `authorID` matches the local user — - /// i.e. a sibling device of this account updated its read horizon. It is - /// emitted only after the Player record passes the same freshness checks - /// as the row's value fields, because active read leases can move the - /// horizon backward when they close. - private nonisolated func applyPlayerRecord( - _ record: CKRecord, - in ctx: NSManagedObjectContext, - localAuthorID: String?, - onFirstTime: (UUID) -> Void, - onReadCursor: (UUID, Date) -> Void - ) { - let ckName = record.recordID.recordName - guard let (gameID, authorID) = RecordSerializer.parsePlayerRecordName(ckName) else { - return - } - guard let renderedName = record["name"] as? String else { return } - let updatedAt = record["updatedAt"] as? Date - ?? record.modificationDate - ?? Date() - - let req = NSFetchRequest<PlayerEntity>(entityName: "PlayerEntity") - req.predicate = NSPredicate(format: "ckRecordName == %@", ckName) - req.fetchLimit = 1 - - let entity: PlayerEntity - let foundExisting: Bool - if let existing = try? ctx.fetch(req).first { - entity = existing - foundExisting = true - } else { - let game = RecordSerializer.ensureGameEntity( - forGameID: gameID, - zoneID: record.recordID.zoneID, - in: ctx - ) - entity = PlayerEntity(context: ctx) - entity.game = game - foundExisting = false - } - - // Drop fetched snapshots older than what we already have. After a - // successful push the writeback adopts the new etag; if a query - // that started before the push lands later, applying its older - // snapshot would downgrade our local etag and OpLock-fail the next - // save (see `applyMovesRecord` for the same guard). - if foundExisting, - !RecordSerializer.incomingIsAtLeastAsFresh(record, existingFields: entity.ckSystemFields) { - return - } - - // Adopt the server's system fields — that's etag tracking and is - // independent of which side has the freshest data. The value fields, - // however, are only adopted when the incoming record is at least as - // new as what we have locally; otherwise a stale-but-current server - // record (e.g. our own pending writes haven't landed yet) would - // clobber the user's live selection on every fetch. - entity.ckRecordName = ckName - entity.ckSystemFields = RecordSerializer.encodeSystemFields(of: record) - entity.authorID = authorID - let localUpdatedAt = entity.updatedAt - let incomingIsFresher = localUpdatedAt.map { updatedAt >= $0 } ?? true - guard incomingIsFresher else { return } - // An empty `name` is what older builds shipped from the selection publisher - // before the fix; treat it as "no information" rather than letting it - // clobber a previously-resolved name. - if !renderedName.isEmpty { - entity.name = renderedName - } - entity.updatedAt = updatedAt - if let selection = RecordSerializer.parsePlayerSelection(from: record) { - entity.selRow = NSNumber(value: Int64(selection.row)) - entity.selCol = NSNumber(value: Int64(selection.col)) - entity.selDir = NSNumber(value: Int64(selection.direction.rawValue)) - } else { - entity.selRow = nil - entity.selCol = nil - entity.selDir = nil - } - if authorID == localAuthorID, - let readAt = RecordSerializer.parsePlayerReadAt(from: record) { - onReadCursor(gameID, readAt) - } - if !foundExisting { - onFirstTime(gameID) - } - } - - /// Merges every device's `MovesEntity` row for `gameID` and reconciles the - /// `CellEntity` cache against the resulting grid. Must be called inside a - /// `performAndWait` block on the same context. - private nonisolated func replayCellCache( - for gameID: UUID, - in ctx: NSManagedObjectContext - ) { - let gameReq = NSFetchRequest<GameEntity>(entityName: "GameEntity") - gameReq.predicate = NSPredicate(format: "id == %@", gameID as CVarArg) - gameReq.fetchLimit = 1 - guard let game = try? ctx.fetch(gameReq).first else { return } - - let movesReq = NSFetchRequest<MovesEntity>(entityName: "MovesEntity") - movesReq.predicate = NSPredicate(format: "game == %@", game) - let movesEntities = (try? ctx.fetch(movesReq)) ?? [] - let values: [MovesValue] = movesEntities.compactMap { Self.movesValue(from: $0) } - let gridState = GridStateMerger.merge(values) - - let existingCells = (game.cells as? Set<CellEntity>) ?? [] - var byPosition: [GridPosition: CellEntity] = [:] - for cell in existingCells { - byPosition[GridPosition(row: Int(cell.row), col: Int(cell.col))] = cell - } - - for (pos, gridCell) in gridState { - let cell: CellEntity - if let existing = byPosition[pos] { - cell = existing - } else { - cell = CellEntity(context: ctx) - cell.game = game - cell.row = Int16(pos.row) - cell.col = Int16(pos.col) - } - cell.letter = gridCell.letter - cell.markKind = gridCell.markKind - cell.checkedWrong = gridCell.checkedWrong - cell.letterAuthorID = gridCell.authorID - } - - for (pos, cell) in byPosition where gridState[pos] == nil { - cell.letter = "" - cell.markKind = 0 - cell.checkedWrong = false - cell.letterAuthorID = nil - } - } - - /// Hydrates a `MovesValue` from a `MovesEntity`. Returns `nil` if the row - /// is missing required fields (e.g. an unpopulated stub from a partial - /// fetch). - private nonisolated static func movesValue(from entity: MovesEntity) -> MovesValue? { - guard let gameID = entity.game?.id, - let authorID = entity.authorID, - let deviceID = entity.deviceID, - let updatedAt = entity.updatedAt - else { return nil } - let cells = (entity.cells.flatMap { try? MovesCodec.decode($0) }) ?? [:] - return MovesValue( - gameID: gameID, - authorID: authorID, - deviceID: deviceID, - cells: cells, - updatedAt: updatedAt - ) - } - - // MARK: - Event handlers - private func handleFetchedDatabaseChanges( _ event: CKSyncEngine.Event.FetchedDatabaseChanges, isPrivate: Bool @@ -2496,7 +890,7 @@ actor SyncEngine { affected.insert(gameID) } case "Ping": - if let ping = Self.parsePingRecord(record) { + if let ping = Ping.parseRecord(record) { pings.append(ping) } case "Decision": @@ -2565,6 +959,9 @@ actor SyncEngine { if let onPings, !pings.isEmpty { await onPings(pings) } + for id in removedGameIDs { + if let cb = onGameRemoved { await cb(id) } + } let pingDeletedGameIDs = Set(event.deletions.compactMap { deletion -> UUID? in deletion.recordID.recordName.hasPrefix("ping-") ? gameID(fromRecordName: deletion.recordID.recordName) : nil @@ -2581,96 +978,6 @@ actor SyncEngine { } } - private nonisolated static func parsePingRecord(_ record: CKRecord) -> Ping? { - let name = record.recordID.recordName - let gameID: UUID? - if name.hasPrefix("ping-") { - let rest = name.dropFirst("ping-".count) - gameID = UUID(uuidString: String(rest.prefix(36))) - } else if record.recordID.zoneID.zoneName.hasPrefix("game-") { - gameID = UUID(uuidString: String(record.recordID.zoneID.zoneName.dropFirst("game-".count))) - } else { - gameID = nil - } - guard let gameID, - let authorID = record["authorID"] as? String, - let kindRaw = record["kind"] as? String, - let kind = PingKind(rawValue: kindRaw) - else { return nil } - // Scope rides in `payload` as `{"scope":"…"}`. The old top-level - // `scope` field is dead — nothing reads or writes it (see PingScope). - let payloadString = record["payload"] as? String - let scope: PingScope? = - PingScopePayload.decode(payloadString).flatMap { PingScope(rawValue: $0.scope) } - // Legacy records written before the schema added `deviceID` won't have - // the field. Parse-tolerant: empty string can never equal a real - // localDeviceID, so the self-send filter stays safe. - let deviceID = (record["deviceID"] as? String) ?? "" - return Ping( - recordName: name, - gameID: gameID, - authorID: authorID, - deviceID: deviceID, - playerName: (record["playerName"] as? String) ?? "", - puzzleTitle: (record["puzzleTitle"] as? String) ?? "", - kind: kind, - scope: scope, - payload: payloadString, - addressee: record["addressee"] as? String - ) - } - - private nonisolated static func parseSessionRecord( - _ record: CKRecord, - puzzleTitle: String - ) -> Session? { - guard let (gameID, authorIDFromName) = RecordSerializer.parsePlayerRecordName(record.recordID.recordName) - else { return nil } - // A cleared selection is the player leaving the puzzle, not starting - // or actively navigating it. - guard RecordSerializer.parsePlayerSelection(from: record) != nil else { return nil } - let authorID = (record["authorID"] as? String) ?? authorIDFromName - let updatedAt = (record["updatedAt"] as? Date) - ?? record.modificationDate - ?? Date() - return Session( - recordName: record.recordID.recordName, - gameID: gameID, - authorID: authorID, - playerName: (record["name"] as? String) ?? "", - puzzleTitle: puzzleTitle, - updatedAt: updatedAt - ) - } - - private nonisolated func applyDeletion( - recordID: CKRecord.ID, - recordType: CKRecord.RecordType, - in ctx: NSManagedObjectContext - ) { - let name = recordID.recordName - let entityName: String - if name.hasPrefix("moves-") { - entityName = "MovesEntity" - } else if name.hasPrefix("player-") { - entityName = "PlayerEntity" - } else if name.hasPrefix("game-") { - entityName = "GameEntity" - } else { - switch recordType { - case "Moves": entityName = "MovesEntity" - case "Player": entityName = "PlayerEntity" - case "Game": entityName = "GameEntity" - default: return - } - } - let req = NSFetchRequest<NSManagedObject>(entityName: entityName) - req.predicate = NSPredicate(format: "ckRecordName == %@", name) - req.fetchLimit = 1 - if let obj = try? ctx.fetch(req).first { - ctx.delete(obj) - } - } private nonisolated static func recordTypeSummary(_ counts: [String: Int]) -> String { counts @@ -2796,7 +1103,7 @@ actor SyncEngine { } } - private nonisolated func isInvalidSharedZoneOwnerError(_ error: NSError) -> Bool { + nonisolated func isInvalidSharedZoneOwnerError(_ error: NSError) -> Bool { let values = [error.localizedDescription] + error.userInfo.map { "\($0.value)" } return values.contains { $0.localizedCaseInsensitiveContains("Cannot convert userId to dsId") || @@ -2953,21 +1260,6 @@ actor SyncEngine { print("SyncEngine: \(message)") } - private nonisolated func describe(_ error: Error) -> String { - let nsError = error as NSError - return "ERROR domain=\(nsError.domain) code=\(nsError.code) \(nsError.localizedDescription)" - } - - private nonisolated func describeStatus(_ status: CKAccountStatus) -> String { - switch status { - case .available: return "available" - case .noAccount: return "noAccount" - case .restricted: return "restricted" - case .couldNotDetermine: return "couldNotDetermine" - case .temporarilyUnavailable: return "temporarilyUnavailable" - @unknown default: return "unknown(\(status.rawValue))" - } - } } // MARK: - CKSyncEngineDelegate