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