crossmate

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

commit ede281d8b794776b545c43b07ab3d58e4d900561
parent 30f36a0b511561736809b18f25eb025ccd815c53
Author: Michael Camilleri <[email protected]>
Date:   Fri,  8 May 2026 22:38:03 +0900

Add alternative CloudKit path for notified changes

Diffstat:
MCrossmate/CrossmateApp.swift | 5+++--
MCrossmate/Services/AppServices.swift | 46+++++++++++++++++++++++++---------------------
MCrossmate/Sync/SyncEngine.swift | 209+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------
3 files changed, 216 insertions(+), 44 deletions(-)

diff --git a/Crossmate/CrossmateApp.swift b/Crossmate/CrossmateApp.swift @@ -33,7 +33,7 @@ struct CrossmateApp: App { // MARK: - App Delegate final class AppDelegate: NSObject, UIApplicationDelegate, @preconcurrency UNUserNotificationCenterDelegate, @unchecked Sendable { - var onRemoteNotification: ((String) async -> Void)? + var onRemoteNotification: ((String, CKDatabase.Scope?) async -> Void)? /// Reports the outcome of `registerForRemoteNotifications`. Surfaced in /// the diagnostics log so a missing APNs token (e.g. an aps-environment /// mismatch between the entitlements and the TestFlight distribution @@ -139,7 +139,8 @@ final class AppDelegate: NSObject, UIApplicationDelegate, @preconcurrency UNUser didReceiveRemoteNotification userInfo: [AnyHashable: Any] ) async -> UIBackgroundFetchResult { let summary = AppServices.describePush(userInfo: userInfo) - await onRemoteNotification?(summary) + let scope = AppServices.databaseScope(fromPush: userInfo) + await onRemoteNotification?(summary, scope) return .newData } diff --git a/Crossmate/Services/AppServices.swift b/Crossmate/Services/AppServices.swift @@ -131,8 +131,8 @@ final class AppServices { nytAuth.loadStoredSession() driveMonitor.start() - appDelegate.onRemoteNotification = { summary in - await self.handleRemoteNotification(summary: summary) + appDelegate.onRemoteNotification = { summary, scope in + await self.handleRemoteNotification(summary: summary, scope: scope) } appDelegate.onAPNsRegistrationResult = { [syncMonitor] message in syncMonitor.note(message) @@ -269,38 +269,26 @@ final class AppServices { ) } - private func handleRemoteNotification(summary: String) async { + private func handleRemoteNotification(summary: String, scope: CKDatabase.Scope?) async { guard preferences.isICloudSyncEnabled else { syncMonitor.note("remote notification ignored while iCloud sync is disabled") return } guard await ensureICloudSyncStarted() else { return } syncMonitor.note("remote notification: \(summary)") - if let target = currentPuzzleFetchTarget() { - await syncMonitor.run("remote-notification fetch") { - try await syncEngine.fetchChanges( - source: "push", - targetDatabase: target.database, - prioritizedZoneIDs: [target.zoneID] - ) - } - } else { + guard let scope, scope != .public else { await syncMonitor.run("remote-notification fetch") { try await syncEngine.fetchChanges(source: "push") } + await refreshSnapshot() + return + } + await syncMonitor.run("remote-notification direct fetch") { + try await syncEngine.fetchPushChangesDirect(scope: scope) } await refreshSnapshot() } - private func currentPuzzleFetchTarget() -> (database: CKDatabase.Scope, zoneID: CKRecordZone.ID)? { - guard let entity = store.currentEntity, - let zoneName = entity.ckZoneName - else { return nil } - let database: CKDatabase.Scope = entity.databaseScope == 1 ? .shared : .private - let ownerName = entity.ckZoneOwnerName ?? CKCurrentUserDefaultName - return (database, CKRecordZone.ID(zoneName: zoneName, ownerName: ownerName)) - } - private func ensureICloudSyncStarted() async -> Bool { guard preferences.isICloudSyncEnabled else { return false } guard !syncStarted else { return true } @@ -437,6 +425,22 @@ final class AppServices { return "scope=\(scopeLabel) kind=\(kind) sub=\(sub) pruned=\(note.isPruned)" } + static func databaseScope(fromPush userInfo: [AnyHashable: Any]) -> CKDatabase.Scope? { + guard let note = CKNotification(fromRemoteNotificationDictionary: userInfo) else { + return nil + } + switch note { + case let n as CKDatabaseNotification: + return n.databaseScope + case let n as CKRecordZoneNotification: + return n.databaseScope + case let n as CKQueryNotification: + return n.databaseScope + default: + return nil + } + } + private func processPendingShareAcceptances() async { guard isReadyForShareAcceptance, !isProcessingShareAcceptanceQueue else { return } isProcessingShareAcceptanceQueue = true diff --git a/Crossmate/Sync/SyncEngine.swift b/Crossmate/Sync/SyncEngine.swift @@ -368,38 +368,205 @@ actor SyncEngine { // MARK: - Explicit sync triggers (called by AppServices / diagnostics view) - func fetchChanges( - source: String = "manual", - targetDatabase: CKDatabase.Scope? = nil, - prioritizedZoneIDs: [CKRecordZone.ID] = [] - ) async throws { + func fetchChanges(source: String = "manual") async throws { currentFetchSource = source defer { currentFetchSource = nil } + async let p: Void = privateEngine?.fetchChanges() ?? () + async let s: Void = sharedEngine?.fetchChanges() ?? () + _ = try await (p, s) + } - switch targetDatabase { + /// Push-only fallback path. On device, CKSyncEngine.fetchChanges() can + /// return successfully from a silent-push wake without delivering database + /// or record-zone events until a later foreground fetch. This direct pull + /// trades token efficiency for latency: it asks CloudKit for modified + /// zones in the notified database scope from a nil token and applies the + /// returned records through the same idempotent merge path used by + /// CKSyncEngine events. CKSyncEngine remains the owner of durable tokens + /// and will catch up during normal fetches. + func fetchPushChangesDirect(scope: CKDatabase.Scope) async throws { + let database: CKDatabase + let scopeValue: Int16 + let label: String + switch scope { case .private: - try await privateEngine?.fetchChanges(fetchOptions(prioritizedZoneIDs: prioritizedZoneIDs)) + database = container.privateCloudDatabase + scopeValue = 0 + label = "private" case .shared: - try await sharedEngine?.fetchChanges(fetchOptions(prioritizedZoneIDs: prioritizedZoneIDs)) + database = container.sharedCloudDatabase + scopeValue = 1 + label = "shared" case .public: return - case .none: - let privateOptions = fetchOptions(prioritizedZoneIDs: prioritizedZoneIDs) - let sharedOptions = fetchOptions(prioritizedZoneIDs: prioritizedZoneIDs) - async let p: Void = privateEngine?.fetchChanges(privateOptions) ?? () - async let s: Void = sharedEngine?.fetchChanges(sharedOptions) ?? () - _ = try await (p, s) - case .some: + @unknown default: return } + + var changedZoneIDs: [CKRecordZone.ID] = [] + var databaseToken: CKServerChangeToken? + repeat { + let result = try await database.databaseChanges(since: databaseToken) + changedZoneIDs.append(contentsOf: result.modifications.map(\.zoneID)) + databaseToken = result.moreComing ? result.changeToken : nil + if !result.deletions.isEmpty { + await applyDirectZoneDeletions( + result.deletions.map(\.zoneID), + scopeValue: scopeValue + ) + } + } while databaseToken != nil + + let gameZoneIDs = Array(Set(changedZoneIDs)) + .filter { $0.zoneName.hasPrefix("game-") } + await trace( + "\(label) direct push db changes: \(gameZoneIDs.count) game zone(s)" + ) + + var totalModifications = 0 + var totalDeletions = 0 + for zoneID in gameZoneIDs { + var zoneToken: CKServerChangeToken? + repeat { + let result = try await database.recordZoneChanges( + inZoneWith: zoneID, + since: zoneToken + ) + let records = result.modificationResultsByID.compactMap { _, recordResult in + try? recordResult.get().record + } + let deletions = result.deletions.map { ($0.recordID, $0.recordType) } + totalModifications += records.count + totalDeletions += deletions.count + await applyDirectRecordZoneChanges( + records: records, + deletions: deletions, + scopeValue: scopeValue + ) + zoneToken = result.moreComing ? result.changeToken : nil + } while zoneToken != nil + } + await trace( + "\(label) direct push fetch: \(totalModifications) modifications, \(totalDeletions) deletions" + ) + } + + private func applyDirectZoneDeletions( + _ zoneIDs: [CKRecordZone.ID], + scopeValue: Int16 + ) async { + guard !zoneIDs.isEmpty else { return } + let isPrivate = scopeValue == 0 + let ctx = persistence.container.newBackgroundContext() + ctx.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump + let (removedIDs, revokedIDs): ([UUID], [UUID]) = ctx.performAndWait { + var removed: [UUID] = [] + var revoked: [UUID] = [] + for zoneID in zoneIDs { + let zoneName = zoneID.zoneName + guard zoneName.hasPrefix("game-") else { continue } + let req = NSFetchRequest<GameEntity>(entityName: "GameEntity") + req.predicate = NSPredicate(format: "ckZoneName == %@", zoneName) + req.fetchLimit = 1 + guard let entity = try? ctx.fetch(req).first else { continue } + if isPrivate { + if let id = entity.id { removed.append(id) } + ctx.delete(entity) + } else { + entity.isAccessRevoked = true + if let id = entity.id { revoked.append(id) } + } + } + if ctx.hasChanges { + try? ctx.save() + } + return (removed, revoked) + } + for id in removedIDs { + if let cb = onGameRemoved { await cb(id) } + } + for id in revokedIDs { + if let cb = onGameAccessRevoked { await cb(id) } + } } - private func fetchOptions( - prioritizedZoneIDs: [CKRecordZone.ID] - ) -> CKSyncEngine.FetchChangesOptions { - var options = CKSyncEngine.FetchChangesOptions() - options.prioritizedZoneIDs = prioritizedZoneIDs - return options + 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 (movesUpdatedGameIDs, affectedGameIDs, pings): (Set<UUID>, Set<UUID>, [Ping]) = ctx.performAndWait { + var movesUpdated = Set<UUID>() + var affected = Set<UUID>() + var pings: [Ping] = [] + 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) { + RecordSerializer.applyMovesRecord(record, value: value, to: ctx) + movesUpdated.insert(value.gameID) + affected.insert(value.gameID) + } + case "Player": + if let (gameID, _) = RecordSerializer.parsePlayerRecordName(record.recordID.recordName) { + self.applyPlayerRecord(record, in: ctx) + affected.insert(gameID) + } + case "Ping": + if let ping = Self.parsePingRecord(record) { + pings.append(ping) + } + 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, pings) + } + + if let onRemoteMovesUpdated, !movesUpdatedGameIDs.isEmpty { + await onRemoteMovesUpdated(movesUpdatedGameIDs) + } + if let onPings, !pings.isEmpty { + await onPings(pings) + } + if !affectedGameIDs.isEmpty { + NotificationCenter.default.post( + name: .playerRosterShouldRefresh, + object: nil, + userInfo: ["gameIDs": affectedGameIDs] + ) + } } func pushChanges() async throws {