crossmate

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

commit 9b285ad724c8f424dc46040ab063d4faf7e7aa21
parent 75b0b776f45cc97eef42c9154743feb771211edc
Author: Michael Camilleri <[email protected]>
Date:   Sun, 10 May 2026 15:18:48 +0900

Limit push fallback to active game

The silent-push fallback only needs live collaboration state for the currently
open puzzle. This commit replaces the broad nil-token replay with an
active-zone query that fetches the Game record and recently modified Moves and
Player records, while continuing to leave Ping records out of the live path.

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

Diffstat:
MCrossmate/Services/AppServices.swift | 31++++++++++++++++++++++++++++++-
MCrossmate/Sync/SyncEngine.swift | 197++++++++++++++++++++++++++++++++++++++++++++-----------------------------------
2 files changed, 139 insertions(+), 89 deletions(-)

diff --git a/Crossmate/Services/AppServices.swift b/Crossmate/Services/AppServices.swift @@ -287,12 +287,41 @@ final class AppServices { await refreshSnapshot() return } + guard let activeGameID = activeGameID(in: scope) 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) + let handled = try await syncEngine.fetchPushChangesDirect( + scope: scope, + gameID: activeGameID + ) + if !handled { + try await syncEngine.fetchChanges(source: "push") + } } await refreshSnapshot() } + private func activeGameID(in scope: CKDatabase.Scope) -> UUID? { + guard let entity = store.currentEntity, + let gameID = entity.id + else { return nil } + switch scope { + case .private: + return entity.databaseScope == 0 ? gameID : nil + case .shared: + return entity.databaseScope == 1 ? gameID : nil + case .public: + return nil + @unknown default: + return nil + } + } + private func ensureICloudSyncStarted() async -> Bool { guard preferences.isICloudSyncEnabled else { return false } guard !syncStarted else { return true } diff --git a/Crossmate/Sync/SyncEngine.swift b/Crossmate/Sync/SyncEngine.swift @@ -93,6 +93,8 @@ actor SyncEngine { private var onGameRemoved: (@MainActor @Sendable (UUID) 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 func setTracer(_ t: @MainActor @Sendable @escaping (String) -> Void) { tracer = t @@ -381,17 +383,17 @@ actor SyncEngine { _ = try await (p, s) } - /// 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 Game/Moves/Player records through the same idempotent merge path - /// used by CKSyncEngine events. Event-like records such as Ping are - /// intentionally ignored here because a nil-token fetch can redeliver old - /// records. CKSyncEngine remains the owner of durable tokens and will - /// catch up during normal fetches. - func fetchPushChangesDirect(scope: CKDatabase.Scope) async throws { + /// Push-only fallback path for the currently open game. 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 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 fetchPushChangesDirect(scope: CKDatabase.Scope, gameID: UUID) async throws -> Bool { let database: CKDatabase let scopeValue: Int16 let label: String @@ -405,96 +407,115 @@ actor SyncEngine { scopeValue = 1 label = "shared" case .public: - return + return false @unknown default: - return + return false } - 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 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 gameZoneIDs = Array(Set(changedZoneIDs)) - .filter { $0.zoneName.hasPrefix("game-") } - await trace( - "\(label) direct push db changes: \(gameZoneIDs.count) game zone(s)" + let checkpointKey = "\(scopeValue):\(gameID.uuidString)" + let since = liveQueryCheckpoints[checkpointKey]? + .addingTimeInterval(-liveQueryCheckpointOverlap) + + let gameRecordID = CKRecord.ID( + recordName: RecordSerializer.recordName(forGameID: gameID), + zoneID: info.zoneID ) + var records: [CKRecord] = [] + let gameResults = try await database.records( + for: [gameRecordID], + desiredKeys: ["title", "completedAt", "shareRecordName"] + ) + let fetchedGameRecord: Bool + if case .success(let record)? = gameResults[gameRecordID] { + records.append(record) + fetchedGameRecord = true + } else { + fetchedGameRecord = false + } - 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 + let moves = try await queryLiveRecords( + type: "Moves", + database: database, + zoneID: info.zoneID, + since: since, + desiredKeys: ["authorID", "deviceID", "cells", "updatedAt"] + ) + let players = try await queryLiveRecords( + type: "Player", + database: database, + zoneID: info.zoneID, + since: since, + desiredKeys: ["authorID", "name", "updatedAt", "selRow", "selCol", "selDir"] + ) + 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) direct push fetch: \(totalModifications) modifications, \(totalDeletions) deletions" + "\(label) live query fetch \(gameID.uuidString.prefix(8)): " + + "game=\(fetchedGameRecord ? 1 : 0), " + + "moves=\(moves.count), players=\(players.count)" ) + return true } - 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) } + private func queryLiveRecords( + type: CKRecord.RecordType, + database: CKDatabase, + zoneID: CKRecordZone.ID, + since: Date?, + desiredKeys: [CKRecord.FieldKey] + ) async throws -> [CKRecord] { + let predicate: NSPredicate + if let since { + predicate = NSPredicate(format: "modificationDate > %@", since as NSDate) + } else { + predicate = NSPredicate(value: true) } - for id in revokedIDs { - if let cb = onGameAccessRevoked { await cb(id) } + + let query = CKQuery(recordType: type, predicate: predicate) + query.sortDescriptors = [NSSortDescriptor(key: "modificationDate", ascending: true)] + + 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 applyDirectRecordZoneChanges(