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:
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 {