crossmate

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

commit 7bb3505c2b5947202e333bd64d06aeeeb26387b3
parent 2c0f11b1dff0a9feeafecbffcd27c66a0a89ea58
Author: Michael Camilleri <[email protected]>
Date:   Thu, 14 May 2026 15:06:16 +0900

Freshen the game list as its own sync surface

The library view was still relying on a mix of foreground CKSyncEngine fetches,
remote-notification branches and delayed catch-ups. That made the behavior
depend on whether iOS delivered a push as foreground/background, and it also
let a stale currentEntity route list-visible pushes through the active-puzzle
path after returning from a puzzle.

This commit adds an explicit freshenGameList path with typed reasons for
appeared, foreground, manual and remote refreshes. The path discovers new
private/shared zones and then fetches Game/Moves for known open games, so the
library has a single app-level freshness rule whenever it is visible.

GameListView now reports appear/disappear to AppServices. Appearing freshens
the list, foregrounding while the list is visible freshens it after the normal
fetch/push, and list-visible silent pushes run ping handling plus a scoped
freshenGameList pass before the usual push fetch. Those pushes also schedule
the delayed 5s/20s Game/Moves catch-ups so a Player-triggered notification can
still catch a later Moves save.

Pull-to-refresh now reuses freshenGameList before the broader engine fetch,
keeping the manual path aligned with the visible-list behavior.

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

Diffstat:
MCrossmate/CrossmateApp.swift | 2++
MCrossmate/Services/AppServices.swift | 111+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------
MCrossmate/Views/GameListView.swift | 8++++++++
3 files changed, 111 insertions(+), 10 deletions(-)

diff --git a/Crossmate/CrossmateApp.swift b/Crossmate/CrossmateApp.swift @@ -243,6 +243,8 @@ struct RootView: View { store: services.store, shareController: services.shareController, onRefresh: { await services.refreshLibrary() }, + onAppear: { await services.gameListAppeared() }, + onDisappear: { services.gameListDisappeared() }, navigationPath: $navigationPath ) .navigationDestination(for: UUID.self) { gameID in diff --git a/Crossmate/Services/AppServices.swift b/Crossmate/Services/AppServices.swift @@ -5,6 +5,22 @@ import UserNotifications @MainActor final class AppServices { + enum GameListFreshenReason { + case appeared + case foreground + case manual + case remote + + var diagnosticLabel: String { + switch self { + case .appeared: return "appeared" + case .foreground: return "foreground" + case .manual: return "manual" + case .remote: return "remote" + } + } + } + let persistence: PersistenceController let store: GameStore let syncEngine: SyncEngine @@ -43,6 +59,7 @@ final class AppServices { private var sharedPushCatchUpTask: Task<Void, Never>? private var isHandlingPrivateRemoteNotification = false private var isHandlingSharedRemoteNotification = false + private var isGameListVisible = false init() { let preferences = PlayerPreferences() @@ -269,9 +286,22 @@ final class AppServices { await syncMonitor.run("foreground push") { try await syncEngine.pushChanges() } + if isGameListVisible { + await freshenGameList(reason: .foreground) + return + } await refreshSnapshot() } + func gameListAppeared() async { + isGameListVisible = true + await freshenGameList(reason: .appeared) + } + + func gameListDisappeared() { + isGameListVisible = false + } + func syncOnBackground() async { await movesUpdater.flush() } @@ -289,26 +319,65 @@ final class AppServices { /// CKSyncEngine's database-scope change delivery, which can lag behind /// reality when the engine has been idle. func refreshLibrary() async { + await freshenGameList(reason: .manual) + guard await ensureICloudSyncStarted() else { return } + await syncMonitor.run("library refresh: engine fetch") { + try await syncEngine.fetchChanges(source: "library refresh") + } + await refreshSnapshot() + } + + func freshenGameList(reason: GameListFreshenReason) 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 // scope, discovery still completes before known-zone updates so // any zone discovery just added is included in the same refresh. - async let privatePhase: Void = refreshLibraryScope(.private, label: "private") - async let sharedPhase: Void = refreshLibraryScope(.shared, label: "shared") + async let privatePhase: Void = freshenGameListScope( + .private, + label: "private", + reason: reason + ) + async let sharedPhase: Void = freshenGameListScope( + .shared, + label: "shared", + reason: reason + ) _ = await (privatePhase, sharedPhase) - await syncMonitor.run("library refresh: engine fetch") { - try await syncEngine.fetchChanges(source: "library refresh") + await refreshSnapshot() + } + + private func freshenGameList( + scope: CKDatabase.Scope, + reason: GameListFreshenReason + ) async { + let label: String + switch scope { + case .private: + label = "private" + case .shared: + label = "shared" + case .public: + return + @unknown default: + return } + guard await ensureICloudSyncStarted() else { return } + await freshenGameListScope(scope, label: label, reason: reason) await refreshSnapshot() } - private func refreshLibraryScope(_ scope: CKDatabase.Scope, label: String) async { - await syncMonitor.run("library refresh: \(label) discovery") { + private func freshenGameListScope( + _ scope: CKDatabase.Scope, + label: String, + reason: GameListFreshenReason + ) async { + let reasonLabel = reason.diagnosticLabel + await syncMonitor.run("freshen game list \(reasonLabel): \(label) discovery") { _ = try await self.syncEngine.discoverNewZonesDirect(scope: scope) } - await syncMonitor.run("library refresh: \(label) known-zone updates") { - _ = try await self.syncEngine.fetchKnownZoneUpdatesDirect(scope: scope) + await syncMonitor.run("freshen game list \(reasonLabel): \(label) game/moves") { + _ = try await self.syncEngine.fetchKnownGameMovesDirect(scope: scope) } } @@ -390,6 +459,23 @@ final class AppServices { return } + if isGameListVisible { + async let pingFastPath: Void = syncMonitor.run("remote-notification ping fast-path") { + _ = try await self.syncEngine.fetchPushPingsDirect(scope: scope) + } + async let gameListFreshen: Void = freshenGameList( + scope: scope, + reason: .remote + ) + _ = await (pingFastPath, gameListFreshen) + await syncMonitor.run("remote-notification fetch") { + try await self.syncEngine.fetchChanges(source: "push") + } + scheduleBackgroundPushCatchUp(scope: scope) + await refreshSnapshot() + return + } + 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 @@ -418,12 +504,17 @@ final class AppServices { await syncMonitor.run("remote-notification zone discovery") { _ = try await self.syncEngine.discoverNewZonesDirect(scope: scope) } - await syncMonitor.run("remote-notification ping fast-path") { + async let pingFastPath: Void = syncMonitor.run("remote-notification ping fast-path") { _ = try await self.syncEngine.fetchPushPingsDirect(scope: scope) } + async let gameMovesCatchUp: Void = syncMonitor.run("remote-notification game/moves catch-up") { + _ = try await self.syncEngine.fetchKnownGameMovesDirect(scope: scope) + } + _ = await (pingFastPath, gameMovesCatchUp) await syncMonitor.run("remote-notification fetch") { try await self.syncEngine.fetchChanges(source: "push") } + scheduleBackgroundPushCatchUp(scope: scope) } await refreshSnapshot() @@ -504,7 +595,7 @@ final class AppServices { await runBackgroundPushCatchUp( scope: scope, label: label, - delayNanoseconds: 3_000_000_000, + delayNanoseconds: 5_000_000_000, phaseSuffix: "short" ) await runBackgroundPushCatchUp( diff --git a/Crossmate/Views/GameListView.swift b/Crossmate/Views/GameListView.swift @@ -5,6 +5,8 @@ struct GameListView: View { let store: GameStore let shareController: ShareController let onRefresh: () async -> Void + let onAppear: () async -> Void + let onDisappear: () -> Void @Binding var navigationPath: NavigationPath @Environment(\.managedObjectContext) private var viewContext @@ -51,6 +53,12 @@ struct GameListView: View { .sheet(isPresented: $showingNewGame) { NewGameSheet(store: store) } + .task { + await onAppear() + } + .onDisappear { + onDisappear() + } .alert("Resign Puzzle?", isPresented: .init( get: { resignTarget != nil }, set: { if !$0 { resignTarget = nil } }