commit 763050477d91745f8695c0d4ec92e131d6cd2aaf
parent 2d868d03075a3e36ba4a2c873adb2c8bcc8a92d7
Author: Michael Camilleri <[email protected]>
Date: Tue, 12 May 2026 17:40:00 +0900
Discover unseen game zones without waiting on CKSyncEngine
CKSyncEngine is responsible for delivering database-scope change events that
announce new zones (a game created on another device of the same iCloud user,
or a freshly-accepted share), but those events can lag well behind reality — on
a silent-push wake they are sometimes withheld until the next foreground, and
on an idle engine they may not arrive for a long time at all. The symptom is a
second device whose library stays empty while the first device clearly has
games in the same private database.
This commit adds SyncEngine.discoverNewZonesDirect(scope:), which enumerates
zones via CKDatabase.allRecordZones(), diffs against the locally-known set from
knownZones(forScope:in:), and pulls Game / Moves / Player records for each new
zone directly. The records flow through the existing
applyDirectRecordZoneChanges pipeline, so upserts, cell-cache replay, and the
playerRosterShouldRefresh notification all behave the same as on an
engine-driven fetch. The Game query keeps the path independent of the
"game-<UUID>" zone-naming convention.
Two entry points wire it in. The remote-notification handler runs zone
discovery before the existing Ping fast-path, so a ping that arrives for a
brand-new game can resolve against a freshly-created GameEntity. A new
AppServices.refreshLibrary() runs discovery on both scopes followed by a normal
engine fetch, and is attached as the .refreshable action on GameListView so the
user has a manual fallback when the engine is silent. The empty-state
ContentUnavailableView moves into an overlay above an always-present List so
pull-to-refresh works even with zero games, which is the case this gesture most
needs to cover.
Co-Authored-By: Claude Opus 4.7 <[email protected]>
Diffstat:
4 files changed, 164 insertions(+), 22 deletions(-)
diff --git a/Crossmate/CrossmateApp.swift b/Crossmate/CrossmateApp.swift
@@ -241,6 +241,7 @@ struct RootView: View {
GameListView(
store: services.store,
shareController: services.shareController,
+ onRefresh: { await services.refreshLibrary() },
navigationPath: $navigationPath
)
.navigationDestination(for: UUID.self) { gameID in
diff --git a/Crossmate/Services/AppServices.swift b/Crossmate/Services/AppServices.swift
@@ -259,6 +259,25 @@ final class AppServices {
}
}
+ /// Pull-to-refresh action for the library. Discovers any zones the
+ /// device hasn't seen yet on both database scopes, then runs the normal
+ /// engine fetch so any in-flight changes also catch up. Bypasses
+ /// CKSyncEngine's database-scope change delivery, which can lag behind
+ /// reality when the engine has been idle.
+ func refreshLibrary() async {
+ guard await ensureICloudSyncStarted() else { return }
+ await syncMonitor.run("library refresh: private discovery") {
+ _ = try await syncEngine.discoverNewZonesDirect(scope: .private)
+ }
+ await syncMonitor.run("library refresh: shared discovery") {
+ _ = try await syncEngine.discoverNewZonesDirect(scope: .shared)
+ }
+ await syncMonitor.run("library refresh: engine fetch") {
+ try await syncEngine.fetchChanges(source: "library refresh")
+ }
+ await refreshSnapshot()
+ }
+
func syncOpenSharedPuzzle() async {
await movesUpdater.flush()
guard await ensureICloudSyncStarted() else { return }
@@ -288,6 +307,18 @@ final class AppServices {
guard await ensureICloudSyncStarted() else { return }
syncMonitor.note("remote notification: \(summary)")
+ // Fast path: discover any zones this device hasn't seen yet so a
+ // game started on another device of the same iCloud user (or a
+ // freshly-accepted share) lands locally without waiting on
+ // CKSyncEngine's database-scope change delivery, which can lag on a
+ // silent-push wake. Runs before the ping fast-path so any pings
+ // tied to a newly-discovered game can resolve their game locally.
+ if let scope, scope != .public {
+ await syncMonitor.run("remote-notification zone discovery") {
+ _ = try await syncEngine.discoverNewZonesDirect(scope: scope)
+ }
+ }
+
// Fast path: surface Ping-driven notifications immediately by
// querying Ping records directly, bypassing CKSyncEngine. Works
// whether or not a game is open, and works during the background-
diff --git a/Crossmate/Sync/SyncEngine.swift b/Crossmate/Sync/SyncEngine.swift
@@ -610,6 +610,111 @@ actor SyncEngine {
return pings.count
}
+ /// Discovers games whose zones the device has never seen and pulls their
+ /// Game / Moves / Player records directly, bypassing CKSyncEngine.
+ ///
+ /// 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
+ /// 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.
+ ///
+ /// Enumerates zones via `CKDatabase.allRecordZones()`, diffs against
+ /// `knownZones`, and pulls every record type we care about for each new
+ /// zone. The pull is unbounded in time because, by definition, the
+ /// device has no checkpoint for a zone it hasn't seen.
+ ///
+ /// Returns the number of newly-discovered zones.
+ @discardableResult
+ func discoverNewZonesDirect(scope: CKDatabase.Scope) async throws -> Int {
+ let database: CKDatabase
+ let scopeValue: Int16
+ let label: String
+ switch scope {
+ case .private:
+ database = container.privateCloudDatabase
+ scopeValue = 0
+ label = "private"
+ case .shared:
+ database = container.sharedCloudDatabase
+ scopeValue = 1
+ label = "shared"
+ case .public:
+ return 0
+ @unknown default:
+ return 0
+ }
+
+ let serverZones = try await database.allRecordZones()
+ let ctx = persistence.container.newBackgroundContext()
+ let known = knownZones(forScope: scopeValue, in: ctx)
+ let knownKeys = Set(known.map { "\($0.zoneID.ownerName)|\($0.zoneID.zoneName)" })
+
+ let candidates: [CKRecordZone.ID] = serverZones
+ .map(\.zoneID)
+ .filter { id in
+ id != CKRecordZone.ID.default &&
+ !knownKeys.contains("\(id.ownerName)|\(id.zoneName)")
+ }
+
+ guard !candidates.isEmpty else {
+ await trace(
+ "\(label) zone discovery: nothing new " +
+ "(server=\(serverZones.count), known=\(known.count))"
+ )
+ return 0
+ }
+
+ var collected: [CKRecord] = []
+ var zonesWithGame = 0
+ for zoneID in candidates {
+ // Query rather than guess the gameID from the zone name so this
+ // doesn't depend on the "game-<UUID>" zone-naming convention.
+ // One Game per zone, but a query keeps the contract symmetric
+ // with Moves/Player below.
+ let games = try await queryLiveRecords(
+ type: "Game",
+ database: database,
+ zoneID: zoneID,
+ since: nil,
+ desiredKeys: ["title", "completedAt", "shareRecordName", "puzzleSource"]
+ )
+ guard !games.isEmpty else { continue }
+ zonesWithGame += 1
+ let moves = try await queryLiveRecords(
+ type: "Moves",
+ database: database,
+ zoneID: zoneID,
+ since: nil,
+ desiredKeys: ["authorID", "deviceID", "cells", "updatedAt"]
+ )
+ let players = try await queryLiveRecords(
+ type: "Player",
+ database: database,
+ zoneID: zoneID,
+ since: nil,
+ desiredKeys: ["authorID", "name", "updatedAt", "selRow", "selCol", "selDir"]
+ )
+ collected.append(contentsOf: games)
+ collected.append(contentsOf: moves)
+ collected.append(contentsOf: players)
+ }
+
+ await applyDirectRecordZoneChanges(
+ records: collected,
+ deletions: [],
+ scopeValue: scopeValue
+ )
+
+ await trace(
+ "\(label) zone discovery: candidates=\(candidates.count), " +
+ "withGame=\(zonesWithGame), records=\(collected.count)"
+ )
+ return zonesWithGame
+ }
+
private func queryLiveRecords(
type: CKRecord.RecordType,
database: CKDatabase,
diff --git a/Crossmate/Views/GameListView.swift b/Crossmate/Views/GameListView.swift
@@ -4,6 +4,7 @@ import SwiftUI
struct GameListView: View {
let store: GameStore
let shareController: ShareController
+ let onRefresh: () async -> Void
@Binding var navigationPath: NavigationPath
@Environment(\.managedObjectContext) private var viewContext
@@ -112,35 +113,39 @@ struct GameListView: View {
.sorted { ($0.completedAt ?? .distantPast) > ($1.completedAt ?? .distantPast) }
Group {
- if games.isEmpty {
- ContentUnavailableView {
- Label("No Puzzles", systemImage: "square.grid.3x3")
- } description: {
- Text("Tap the + button to start a new puzzle.")
- }
- } else {
- List {
- if !inProgress.isEmpty {
- Section {
- ForEach(inProgress) { game in
- rowView(for: game, usesRoomierType: usesRoomierType)
- }
- } header: {
- Text("In Progress")
+ List {
+ if !inProgress.isEmpty {
+ Section {
+ ForEach(inProgress) { game in
+ rowView(for: game, usesRoomierType: usesRoomierType)
}
+ } header: {
+ Text("In Progress")
}
+ }
- if !completed.isEmpty {
- Section {
- ForEach(completed) { game in
- rowView(for: game, usesRoomierType: usesRoomierType)
- }
- } header: {
- Text("Completed")
+ if !completed.isEmpty {
+ Section {
+ ForEach(completed) { game in
+ rowView(for: game, usesRoomierType: usesRoomierType)
}
+ } header: {
+ Text("Completed")
}
}
}
+ .overlay {
+ if games.isEmpty {
+ ContentUnavailableView {
+ Label("No Puzzles", systemImage: "square.grid.3x3")
+ } description: {
+ Text("Tap the + button to start a new puzzle, or pull down to refresh.")
+ }
+ }
+ }
+ .refreshable {
+ await onRefresh()
+ }
}
}