crossmate

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

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:
MCrossmate/CrossmateApp.swift | 1+
MCrossmate/Services/AppServices.swift | 31+++++++++++++++++++++++++++++++
MCrossmate/Sync/SyncEngine.swift | 105+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
MCrossmate/Views/GameListView.swift | 49+++++++++++++++++++++++++++----------------------
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() + } } }