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