crossmate

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

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:
MCrossmate/Services/AppServices.swift | 28++++++++++++++++++++++++++--
MCrossmate/Sync/CloudQuery.swift | 12++++++------
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",