commit 8ebbef3cf89ae36f754e5705de62f51eb16bd0ab
parent 6fe44e2331fe8c25fb69d74c3c4f2029b663a4c7
Author: Michael Camilleri <[email protected]>
Date: Sat, 30 May 2026 08:54:07 +0900
Fix direct fetches dropping collaboration fields
Direct CloudKit catch-up paths were fetching partial Game and Player
records without every field their appliers consume. Player fetches
omitted pushAddress, so applying a partial record could clear the local
address even though CloudKit still had it, leaving sender-side APNs with
“no addressable recipients”. Game fetches similarly omitted engagement,
which could erase local room credentials and cause unnecessary
engagement room rotation.
This commit included the push/session fields in direct Player reads and
the engagement /completion fields in direct Game reads. It also
reconcile push registration when shares are saved or newly joined so
local address slots are published as soon as collaboration begins.
Co-Authored-By: Codex GPT 5.5 <[email protected]>
Diffstat:
2 files changed, 32 insertions(+), 8 deletions(-)
diff --git a/Crossmate/Services/AppServices.swift b/Crossmate/Services/AppServices.swift
@@ -253,8 +253,27 @@ final class AppServices {
syncEngine: syncEngine,
syncMonitor: self.syncMonitor
)
- self.shareController.onShareSaved = { [weak store] gameID in
- store?.markShared(gameID: gameID)
+ let pushClient = self.pushClient
+ self.shareController.onShareSaved = { [store] gameID in
+ store.markShared(gameID: gameID)
+ // The CKSyncEngine drain kicked by `enqueuePlayer` is detached
+ // inside SyncEngine. Keep this hop MainActor-bound because these
+ // collaborators are main-actor app state, not Sendable payloads.
+ Task { @MainActor [preferences, identity, store, syncEngine, pushClient] in
+ guard preferences.isICloudSyncEnabled,
+ let authorID = identity.currentID,
+ !authorID.isEmpty
+ else { return }
+ let result = store.reconcileLocalPushAddresses(authorID: authorID)
+ for gameID in result.mintedGameIDs {
+ await syncEngine.enqueuePlayer(
+ gameID: gameID,
+ authorID: authorID,
+ reason: "pushAddress"
+ )
+ }
+ pushClient?.setAddresses(result.addresses)
+ }
// Register the app for notifications now that the user has chosen
// to collaborate. Surfaces the app in Settings > Notifications and
// makes the icon-badge permission available before any inbound
@@ -478,6 +497,11 @@ final class AppServices {
dismissal: .manual
))
}
+ // Defer the sync enqueue out of the `onGameJoined` callback; the
+ // actual CKSyncEngine send drain remains detached in SyncEngine.
+ Task { @MainActor [weak self] in
+ await self?.reconcilePushRegistration()
+ }
}
// A sibling device consumed (deleted) a directed ping; withdraw any
diff --git a/Crossmate/Sync/CloudQuery.swift b/Crossmate/Sync/CloudQuery.swift
@@ -282,7 +282,7 @@ extension SyncEngine {
database: database,
zoneID: zone.zoneID,
since: since,
- desiredKeys: ["authorID", "name", "updatedAt", "selRow", "selCol", "selDir", "readAt"]
+ desiredKeys: ["authorID", "name", "updatedAt", "selRow", "selCol", "selDir", "readAt", "sessionSnapshot", "pushAddress"]
)
let activities = playerRecords.compactMap { record in
Session.parseRecord(record, puzzleTitle: zone.title)
@@ -425,7 +425,7 @@ extension SyncEngine {
database: database,
zoneID: zoneID,
since: nil,
- desiredKeys: ["title", "completedAt", "shareRecordName", "puzzleSource"]
+ desiredKeys: ["title", "completedAt", "completedBy", "shareRecordName", "engagement", "puzzleSource"]
)
guard !games.isEmpty else {
return PerZoneResult(records: [], hasGame: false)
@@ -442,7 +442,7 @@ extension SyncEngine {
database: database,
zoneID: zoneID,
since: nil,
- desiredKeys: ["authorID", "name", "updatedAt", "selRow", "selCol", "selDir", "readAt"]
+ desiredKeys: ["authorID", "name", "updatedAt", "selRow", "selCol", "selDir", "readAt", "sessionSnapshot", "pushAddress"]
)
let (m, p) = try await (moves, players)
return PerZoneResult(records: games + m + p, hasGame: true)
@@ -531,7 +531,7 @@ extension SyncEngine {
async let gameResultsTask = database.records(
for: [gameRecordID],
- desiredKeys: ["title", "completedAt", "shareRecordName", "puzzleSource"]
+ desiredKeys: ["title", "completedAt", "completedBy", "shareRecordName", "engagement", "puzzleSource"]
)
async let movesTask = queryLiveRecords(
type: "Moves",
@@ -545,7 +545,7 @@ extension SyncEngine {
database: database,
zoneID: info.zoneID,
since: since,
- desiredKeys: ["authorID", "name", "updatedAt", "selRow", "selCol", "selDir", "readAt"]
+ desiredKeys: ["authorID", "name", "updatedAt", "selRow", "selCol", "selDir", "readAt", "sessionSnapshot", "pushAddress"]
)
let (gameResults, moves, players) = try await (gameResultsTask, movesTask, playersTask)
@@ -642,7 +642,7 @@ extension SyncEngine {
)
async let gameResultsTask = database.records(
for: [gameRecordID],
- desiredKeys: ["title", "completedAt", "shareRecordName"]
+ desiredKeys: ["title", "completedAt", "completedBy", "shareRecordName", "engagement", "puzzleSource"]
)
async let movesTask = self.queryLiveRecords(
type: "Moves",