crossmate

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

commit b6a48d0044e5f5f4171fa02d30176029a6ccbbde
parent 0d73711b3faa610fc7df096438f05ee91fb7d086
Author: Michael Camilleri <[email protected]>
Date:   Thu, 14 May 2026 11:00:58 +0900

Use live fetches for open synced puzzles

Opening a synced puzzle should refresh the visible game directly rather than
waiting for CKSyncEngine's broad reconciliation path. This routes open-puzzle
polling through the live Game/Moves/Player fetch for both private cross-device
sync and shared games.

This commit also renames the direct active-game fetch to reflect that it is no
longer just a push fallback.

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

Diffstat:
MCrossmate/CrossmateApp.swift | 29+++++++++++++++++++----------
MCrossmate/Services/AppServices.swift | 14++++++++++----
MCrossmate/Sync/SyncEngine.swift | 32++++++++++++++++----------------
3 files changed, 45 insertions(+), 30 deletions(-)

diff --git a/Crossmate/CrossmateApp.swift b/Crossmate/CrossmateApp.swift @@ -305,9 +305,16 @@ private extension UIApplication { /// Loads a game when navigated to. private struct PuzzleDisplayView: View { - private static let sharedPuzzlePollingInterval: Duration = .seconds(5) - private var sharedID: UUID? { - session?.mutator.isShared == true ? gameID : nil + private static let syncedPuzzlePollingInterval: Duration = .seconds(5) + private var syncedID: UUID? { + preferences.isICloudSyncEnabled && session != nil ? gameID : nil + } + + private var syncedScope: CKDatabase.Scope? { + guard preferences.isICloudSyncEnabled, + let mutator = session?.mutator + else { return nil } + return mutator.isOwned ? .private : .shared } let gameID: UUID @@ -351,9 +358,9 @@ private struct PuzzleDisplayView: View { } .navigationTitle("") .navigationBarTitleDisplayMode(.inline) - .task(id: sharedID) { - guard sharedID != nil else { return } - await pollOpenSharedPuzzle() + .task(id: syncedID) { + guard syncedID != nil else { return } + await pollOpenSyncedPuzzle() } .task(id: gameID) { openPuzzleFollowUpTask?.cancel() @@ -492,16 +499,18 @@ private struct PuzzleDisplayView: View { await selectionPublisher.publish(initial) } - private func pollOpenSharedPuzzle() async { - await services.syncOpenSharedPuzzle() + private func pollOpenSyncedPuzzle() async { + guard let scope = syncedScope else { return } + await services.syncOpenPuzzle(gameID: gameID, scope: scope) while !Task.isCancelled { do { - try await Task.sleep(for: Self.sharedPuzzlePollingInterval) + try await Task.sleep(for: Self.syncedPuzzlePollingInterval) } catch { break } guard !Task.isCancelled else { break } - await services.syncOpenSharedPuzzle() + guard let scope = syncedScope else { break } + await services.syncOpenPuzzle(gameID: gameID, scope: scope) } } } diff --git a/Crossmate/Services/AppServices.swift b/Crossmate/Services/AppServices.swift @@ -281,11 +281,17 @@ final class AppServices { } } - func syncOpenSharedPuzzle() async { + func syncOpenPuzzle(gameID: UUID, scope: CKDatabase.Scope) async { await movesUpdater.flush() guard await ensureICloudSyncStarted() else { return } - await syncMonitor.run("open-puzzle fetch") { - try await syncEngine.fetchChanges(source: "open-puzzle poll") + await syncMonitor.run("open-puzzle live fetch") { + let handled = try await syncEngine.fetchLiveGameDirect( + scope: scope, + gameID: gameID + ) + if !handled { + try await syncEngine.fetchChanges(source: "open-puzzle poll") + } } await refreshSnapshot() } @@ -345,7 +351,7 @@ final class AppServices { // fetch and the ping fast-path read different record types and // are independent, so run them concurrently. async let activeFetch: Void = syncMonitor.run("remote-notification direct fetch") { - let handled = try await self.syncEngine.fetchPushChangesDirect( + let handled = try await self.syncEngine.fetchLiveGameDirect( scope: scope, gameID: activeGameID ) diff --git a/Crossmate/Sync/SyncEngine.swift b/Crossmate/Sync/SyncEngine.swift @@ -458,17 +458,17 @@ actor SyncEngine { _ = try await (p, s) } - /// 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. + /// 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 fetchPushChangesDirect(scope: CKDatabase.Scope, gameID: UUID) async throws -> Bool { + func fetchLiveGameDirect(scope: CKDatabase.Scope, gameID: UUID) async throws -> Bool { let database: CKDatabase let scopeValue: Int16 let label: String @@ -563,7 +563,7 @@ actor SyncEngine { /// 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 `fetchPushChangesDirect`). Moves / Player / Game records are + /// 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 { @@ -754,7 +754,7 @@ actor SyncEngine { /// 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 - /// `fetchPushChangesDirect` and `fetchPushPingsDirect`). Without zone + /// `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. @@ -887,7 +887,7 @@ actor SyncEngine { /// `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 - /// `fetchPushChangesDirect`, which uses the `liveQueryCheckpoints` + /// `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. @@ -918,7 +918,7 @@ actor SyncEngine { return 0 } - // Fan the per-game fetches out concurrently. Each fetchPushChangesDirect + // 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- @@ -929,7 +929,7 @@ actor SyncEngine { group.addTask { [weak self] in guard let self else { return false } do { - return try await self.fetchPushChangesDirect( + return try await self.fetchLiveGameDirect( scope: scope, gameID: gameID ) @@ -1318,7 +1318,7 @@ actor SyncEngine { /// 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 `fetchPushChangesDirect`. Games with a non-nil + /// 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.