commit bc1bab42ca39b4ea47e7316a0856d6ec76cda61a
parent 0dd80f165744bc242b14c0a5f0217bcc104ee481
Author: Michael Camilleri <[email protected]>
Date: Fri, 15 May 2026 03:16:12 +0900
Freshen the puzzle grid as its own sync surface
The open puzzle sync now has an explicit freshenPuzzleGrid path, matching the
Game List freshness surface. Appeared, foreground, poll and remote refreshes
all route through the same helper, which flushes local moves, fetches the live
game directly when possible and refreshes the local snapshot afterward.
The Puzzle Grid freshen path coalesces duplicate work per game and database
scope, so polling, foregrounding and silent pushes do not stack overlapping
CloudKit reads for the same open puzzle.
Additionally, freshen reasons are now shared between Game List and Puzzle Grid
refreshes.
Co-Authored-By: Codex GPT 5.5 <[email protected]>
Diffstat:
4 files changed, 90 insertions(+), 39 deletions(-)
diff --git a/Crossmate/CrossmateApp.swift b/Crossmate/CrossmateApp.swift
@@ -514,7 +514,7 @@ private struct PuzzleDisplayView: View {
private func pollOpenSyncedPuzzle() async {
guard let scope = syncedScope else { return }
- await services.syncOpenPuzzle(gameID: gameID, scope: scope)
+ await services.freshenPuzzleGrid(gameID: gameID, scope: scope, reason: .appeared)
while !Task.isCancelled {
let interval = services.hadRecentRemoteNotification(within: Self.activityWindow)
? Self.activePollingInterval
@@ -526,7 +526,7 @@ private struct PuzzleDisplayView: View {
}
guard !Task.isCancelled else { break }
guard let scope = syncedScope else { break }
- await services.syncOpenPuzzle(gameID: gameID, scope: scope)
+ await services.freshenPuzzleGrid(gameID: gameID, scope: scope, reason: .poll)
}
}
}
diff --git a/Crossmate/Services/AppServices.swift b/Crossmate/Services/AppServices.swift
@@ -5,10 +5,11 @@ import UserNotifications
@MainActor
final class AppServices {
- enum GameListFreshenReason {
+ enum FreshenReason {
case appeared
case foreground
case manual
+ case poll
case remote
var diagnosticLabel: String {
@@ -16,6 +17,7 @@ final class AppServices {
case .appeared: return "appeared"
case .foreground: return "foreground"
case .manual: return "manual"
+ case .poll: return "poll"
case .remote: return "remote"
}
}
@@ -61,6 +63,7 @@ final class AppServices {
private var isHandlingSharedRemoteNotification = false
private var isFresheningPrivateGameList = false
private var isFresheningSharedGameList = false
+ private var fresheningPuzzleGridKeys: Set<String> = []
private var isGameListVisible = false
init() {
@@ -292,6 +295,10 @@ final class AppServices {
await freshenGameList(reason: .foreground)
return
}
+ if let (gameID, scope) = activePuzzleGridTarget() {
+ await freshenPuzzleGrid(gameID: gameID, scope: scope, reason: .foreground)
+ return
+ }
await refreshSnapshot()
}
@@ -329,7 +336,7 @@ final class AppServices {
await refreshSnapshot()
}
- func freshenGameList(reason: GameListFreshenReason) async {
+ func freshenGameList(reason: FreshenReason) async {
guard await ensureICloudSyncStarted() else { return }
// Private and shared hit different CloudKit databases, so their
// direct-fetch phases run as an independent pair. Within each
@@ -351,7 +358,7 @@ final class AppServices {
private func freshenGameList(
scope: CKDatabase.Scope,
- reason: GameListFreshenReason
+ reason: FreshenReason
) async {
let label: String
switch scope {
@@ -372,7 +379,7 @@ final class AppServices {
private func freshenGameListScope(
_ scope: CKDatabase.Scope,
label: String,
- reason: GameListFreshenReason
+ reason: FreshenReason
) async {
let reasonLabel = reason.diagnosticLabel
guard beginGameListFreshen(scope: scope, label: label, reason: reasonLabel) else {
@@ -437,21 +444,68 @@ final class AppServices {
return Date().timeIntervalSince(last) <= window
}
- func syncOpenPuzzle(gameID: UUID, scope: CKDatabase.Scope) async {
+ func freshenPuzzleGrid(
+ gameID: UUID,
+ scope: CKDatabase.Scope,
+ reason: FreshenReason
+ ) async {
await movesUpdater.flush()
guard await ensureICloudSyncStarted() else { return }
- await syncMonitor.run("open-puzzle live fetch") {
+ let label = reason.diagnosticLabel
+ guard beginPuzzleGridFreshen(gameID: gameID, scope: scope, reason: label) else {
+ return
+ }
+ defer { endPuzzleGridFreshen(gameID: gameID, scope: scope) }
+
+ await syncMonitor.run("freshen puzzle grid \(label)") {
let handled = try await syncEngine.fetchLiveGameDirect(
scope: scope,
gameID: gameID
)
if !handled {
- try await syncEngine.fetchChanges(source: "open-puzzle poll")
+ try await syncEngine.fetchChanges(source: "puzzle grid \(label)")
}
}
await refreshSnapshot()
}
+ private func beginPuzzleGridFreshen(
+ gameID: UUID,
+ scope: CKDatabase.Scope,
+ reason: String
+ ) -> Bool {
+ let key = puzzleGridFreshenKey(gameID: gameID, scope: scope)
+ guard !fresheningPuzzleGridKeys.contains(key) else {
+ syncMonitor.note(
+ "freshen puzzle grid \(reason): \(scopeLabel(scope)) \(gameID.uuidString.prefix(8)) coalesced into in-flight freshen"
+ )
+ return false
+ }
+ fresheningPuzzleGridKeys.insert(key)
+ return true
+ }
+
+ private func endPuzzleGridFreshen(gameID: UUID, scope: CKDatabase.Scope) {
+ fresheningPuzzleGridKeys.remove(puzzleGridFreshenKey(gameID: gameID, scope: scope))
+ }
+
+ private func puzzleGridFreshenKey(gameID: UUID, scope: CKDatabase.Scope) -> String {
+ "\(scopeLabel(scope)):\(gameID.uuidString)"
+ }
+
+ private func scopeLabel(_ scope: CKDatabase.Scope) -> String {
+ switch scope {
+ case .private:
+ return "private"
+ case .shared:
+ return "shared"
+ case .public:
+ return "public"
+ @unknown default:
+ return "unknown"
+ }
+ }
+
func makePlayerRoster(for gameID: UUID, preferences: PlayerPreferences) -> PlayerRoster {
PlayerRoster(
gameID: gameID,
@@ -524,21 +578,15 @@ final class AppServices {
}
if let activeGameID = activeGameID(in: scope) {
- // Hot path: collaborator activity on the open puzzle. The active
- // game's zone is already known, so we skip the zone-discovery
- // round-trip; new shared games arriving during this window are
- // picked up by the next push or by foregrounding. The direct
- // 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.fetchLiveGameDirect(
- scope: scope,
- gameID: activeGameID
- )
- if !handled {
- try await self.syncEngine.fetchChanges(source: "push")
- }
- }
+ // Hot path: collaborator activity on the open puzzle. The Puzzle
+ // Grid surface owns the direct Game/Moves/Player fetch so push
+ // handling and open-puzzle polling coalesce instead of duplicating
+ // the same active-zone query.
+ async let activeFetch: Void = freshenPuzzleGrid(
+ gameID: activeGameID,
+ scope: scope,
+ reason: .remote
+ )
async let pingFastPath: Void = syncMonitor.run("remote-notification ping fast-path") {
_ = try await self.syncEngine.fetchPushPingsDirect(scope: scope)
}
@@ -567,6 +615,20 @@ final class AppServices {
await refreshSnapshot()
}
+ private func activePuzzleGridTarget() -> (UUID, CKDatabase.Scope)? {
+ guard let entity = store.currentEntity,
+ let gameID = entity.id
+ else { return nil }
+ switch entity.databaseScope {
+ case 0:
+ return (gameID, .private)
+ case 1:
+ return (gameID, .shared)
+ default:
+ return nil
+ }
+ }
+
private func beginRemoteNotificationHandling(scope: CKDatabase.Scope) -> Bool {
switch scope {
case .private:
@@ -691,19 +753,8 @@ final class AppServices {
}
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
- }
+ guard let target = activePuzzleGridTarget() else { return nil }
+ return target.1 == scope ? target.0 : nil
}
private func ensureICloudSyncStarted() async -> Bool {
diff --git a/Crossmate/Services/DebuggingMonitors.swift b/Crossmate/Services/DebuggingMonitors.swift
@@ -195,7 +195,7 @@ final class SyncMonitor {
private let maxEntries = 300
func recordStart(_ phase: String) {
- append(level: "info", "Starting \(phase)")
+ append(level: "info", "starting \(phase)")
}
func recordSuccess(_ phase: String) {
diff --git a/Crossmate/Views/DiagnosticsView.swift b/Crossmate/Views/DiagnosticsView.swift
@@ -133,7 +133,7 @@ struct DiagnosticsView: View {
private func probeContainer() async {
guard let syncEngine else { return }
- syncMonitor.note("Starting container probe")
+ syncMonitor.note("starting container probe")
let results = await syncEngine.probeContainer()
for (name, result) in results {
syncMonitor.note("probe[\(name)]: \(result)")