crossmate

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

commit 469535a3b0a6e79cb027f32b9307fc85a9e0d3b1
parent 163395f4d16132d9939c57b9396d62ddc69ff2db
Author: Michael Camilleri <[email protected]>
Date:   Sun, 12 Jul 2026 15:03:58 +0900

Delay completion pushes until CloudKit state is durable

A completion notification could arrive while the winning move was still
unsaved, so a recipient opening the puzzle could see an incomplete grid
despite being told that it was solved. This commit holds win and resign
pushes until CloudKit confirms both the completed Game and final Moves
records, then holds the replay hint until the Journal is durable.

Pending delivery state survives relaunch and ignores acknowledgements
from older writes that were already in flight before completion. Worker
failures remain retryable, while devices that merely observe another
player's solve continue to upload their journals without originating a
duplicate win.

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

Diffstat:
MCrossmate/CrossmateApp.swift | 8--------
MCrossmate/Persistence/GameStore.swift | 20++++++++++++--------
MCrossmate/Services/AppActions.swift | 3---
MCrossmate/Services/AppServices.swift | 10+++++++++-
MCrossmate/Services/PushClient.swift | 9++++++---
MCrossmate/Services/SessionCoordinator.swift | 130+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------
MCrossmate/Sync/SyncEngine.swift | 32++++++++++++++++++++++++++++++++
MCrossmate/Views/GameList/GameListView.swift | 2--
MTests/Unit/GameStoreCompletionLockTests.swift | 50++++++++++++++++++++++++++++++++++++++++++++++++++
9 files changed, 220 insertions(+), 44 deletions(-)

diff --git a/Crossmate/CrossmateApp.swift b/Crossmate/CrossmateApp.swift @@ -688,11 +688,6 @@ private struct PuzzleDisplayView: View { // sibling devices get the final time at once, not // only when this device next leaves the puzzle. services.sessions.noteClockCompleted(gameID: gameID) - if notifyPeers { - Task { - await services.sessions.sendCompletionPings(gameID: gameID, resigned: false) - } - } } // The game is done — drop the other player's cursor // and tear the live room down. Idempotent, so the @@ -712,9 +707,6 @@ private struct PuzzleDisplayView: View { onResign: { try store.resignGame(id: gameID) services.sessions.noteClockCompleted(gameID: gameID) - Task { - await services.sessions.sendCompletionPings(gameID: gameID, resigned: true) - } }, onDelete: { try store.deleteGame(id: gameID) }, onNudge: { await services.sessions.nudge(gameID: gameID) }, diff --git a/Crossmate/Persistence/GameStore.swift b/Crossmate/Persistence/GameStore.swift @@ -551,7 +551,7 @@ final class GameStore { /// pushes the per-device Journal asset. Assigned post-init (like the other /// UI-facing callbacks below) so the handler can reference `AppServices`. @ObservationIgnored - var onJournalComplete: (@MainActor (UUID, String) -> Void)? + var onJournalComplete: (@MainActor (UUID, String, Bool, Bool) -> Void)? /// Fires when the count of shared games with unseen other-author moves /// may have changed (inbound moves merged, a game opened, a game @@ -1366,7 +1366,7 @@ final class GameStore { onGameUpdated(ckName) } Task { await movesUpdater.flush() } - triggerJournalUpload(id: id) + triggerJournalUpload(id: id, resigned: true, notifyPeers: true) // Clean up current references currentGame = nil @@ -1439,7 +1439,7 @@ final class GameStore { /// rather than waiting for the next keystroke or app-background event. @discardableResult func markCompleted(id: UUID) throws -> Bool { - try persistCompletion(id: id, completedBy: authorIDProvider()) + try persistCompletion(id: id, completedBy: authorIDProvider(), notifyPeers: true) } /// Marks a game completed after the visible grid became solved through @@ -1448,11 +1448,15 @@ final class GameStore { @discardableResult func markCompletedFromObservedSolvedState(id: UUID) throws -> Bool { let solver = inferredObservedCompletionAuthorID(for: id) ?? authorIDProvider() - return try persistCompletion(id: id, completedBy: solver) + return try persistCompletion(id: id, completedBy: solver, notifyPeers: false) } @discardableResult - private func persistCompletion(id: UUID, completedBy authorID: String?) throws -> Bool { + private func persistCompletion( + id: UUID, + completedBy authorID: String?, + notifyPeers: Bool + ) throws -> Bool { let request = NSFetchRequest<GameEntity>(entityName: "GameEntity") request.predicate = NSPredicate(format: "id == %@", id as CVarArg) request.fetchLimit = 1 @@ -1476,7 +1480,7 @@ final class GameStore { onGameUpdated(ckName) } Task { await movesUpdater.flush() } - triggerJournalUpload(id: id) + triggerJournalUpload(id: id, resigned: false, notifyPeers: notifyPeers) return true } @@ -1486,9 +1490,9 @@ final class GameStore { /// suspension point — the flush and CKSyncEngine enqueue then run under it /// via `flushJournal()`. Attributed to the local user (not the solver) — a /// resigner still has a log to upload. - private func triggerJournalUpload(id: UUID) { + private func triggerJournalUpload(id: UUID, resigned: Bool, notifyPeers: Bool) { guard let authorID = authorIDProvider(), !authorID.isEmpty else { return } - onJournalComplete?(id, authorID) + onJournalComplete?(id, authorID, resigned, notifyPeers) } /// Drains the journal's async persistence queue so the upload's record diff --git a/Crossmate/Services/AppActions.swift b/Crossmate/Services/AppActions.swift @@ -65,9 +65,6 @@ final class AppActions { } } - func sendResignPings(gameID: UUID) async { - await services.sessions.sendCompletionPings(gameID: gameID, resigned: true) - } } extension EnvironmentValues { diff --git a/Crossmate/Services/AppServices.swift b/Crossmate/Services/AppServices.swift @@ -722,7 +722,10 @@ final class AppServices { self.store.onLocalCellEditBatch = { [weak self] edits in self?.engagement.sendLocalCellEdits(edits) } - self.store.onJournalComplete = { [weak self] gameID, authorID in + self.store.onJournalComplete = { [weak self] gameID, authorID, resigned, notifyPeers in + if notifyPeers { + self?.sessions.stageCompletionDelivery(gameID: gameID, resigned: resigned) + } self?.beginCompletionJournalUpload(gameID: gameID, authorID: authorID) } } @@ -1168,6 +1171,11 @@ final class AppServices { self?.store.enqueuePeerChangeLedgerUpdate(for: [gameID]) } + await syncEngine.setOnCompletionRecordsSaved { [weak self] records in + await self?.sessions.noteCompletionRecordsSaved(records) + } + await sessions.resumePendingCompletionDeliveries() + await syncEngine.setOnGameJoined { [weak self] gameID in guard let self else { return } // A shared zone just synced in for this game — joined here or on diff --git a/Crossmate/Services/PushClient.swift b/Crossmate/Services/PushClient.swift @@ -300,6 +300,7 @@ final class PushClient { "game-\(gameID.uuidString)" } + @discardableResult func publish( kind: String, gameID: UUID, @@ -315,8 +316,8 @@ final class PushClient { collapseID: String? = nil, extra: [String: Any] = [:], body: String - ) async { - guard broadcast || !addressees.isEmpty else { return } + ) async -> Bool { + guard broadcast || !addressees.isEmpty else { return true } // A game push must prove participation: resolve (minting if needed) the // game's shared credential, register it with the worker, and sign the // request below. Account-scoped publishes (sibling-device hints) carry @@ -325,7 +326,7 @@ final class PushClient { if gameCredentialed { guard let creds = gameCredentialResolver?(gameID) else { log("push(\(kind)): skipped (no game credential)") - return + return false } await registerGameCredential(creds) credential = creds @@ -414,8 +415,10 @@ final class PushClient { throw URLError(.badServerResponse) } log("push(\(kind)): worker accepted\(Self.deliverySummary(from: data))") + return true } catch { log("push(\(kind)) failed: \(error.localizedDescription)") + return false } } diff --git a/Crossmate/Services/SessionCoordinator.swift b/Crossmate/Services/SessionCoordinator.swift @@ -3,6 +3,36 @@ import CloudKit import Foundation import UIKit +struct CompletionDeliveryProgress: Codable, Equatable { + let resigned: Bool + var durableRecords: Set<CompletionDurableRecordKind> = [] + var completionDelivered = false + var replayDelivered = false + + var canDeliverCompletion: Bool { + !completionDelivered && durableRecords.isSuperset(of: [.game, .moves]) + } + + var canDeliverReplay: Bool { + completionDelivered && !replayDelivered && durableRecords.contains(.journal) + } + + mutating func acknowledge(_ kinds: Set<CompletionDurableRecordKind>) { + let gameWasDurable = durableRecords.contains(.game) + if kinds.contains(.game) { + durableRecords.insert(.game) + } + // Ignore data acknowledgements that can belong to requests already in + // flight before completion was staged. Once the completed Game is + // durable (or accompanies them in this event), subsequent saves come + // from completion's flush/enqueue path. + if gameWasDurable || kinds.contains(.game) { + if kinds.contains(.moves) { durableRecords.insert(.moves) } + if kinds.contains(.journal) { durableRecords.insert(.journal) } + } + } +} + /// Owns the play-session lifecycle: the sender-side session pushes /// (pause / win / resign / replay), the manual `nudge` push, and the /// receiver-side catch-up banner, driven by three lifecycle events — @@ -43,6 +73,8 @@ final class SessionCoordinator { private let identity: AuthorIdentity private let preferences: PlayerPreferences private let pushClient: PushClient? + private let completionDefaults: UserDefaults + private static let pendingCompletionsKey = "pendingCompletionDeliveries.v1" /// Per-open-game session state machines — each owns its game's grace /// timers, background assertion, banner timer, and announced state. @@ -71,7 +103,8 @@ final class SessionCoordinator { announcements: AnnouncementCenter, identity: AuthorIdentity, preferences: PlayerPreferences, - pushClient: PushClient? + pushClient: PushClient?, + completionDefaults: UserDefaults = .standard ) { self.persistence = persistence self.store = store @@ -83,6 +116,7 @@ final class SessionCoordinator { self.identity = identity self.preferences = preferences self.pushClient = pushClient + self.completionDefaults = completionDefaults } // MARK: Per-game sessions @@ -164,13 +198,71 @@ final class SessionCoordinator { pruneIfIdle(gameID) } - /// Completion fan-out, delivered through the push worker. Win sets - /// `completedAt`/`completedBy` on the local Game record; resign leaves - /// `completedBy` nil and reveals the remaining cells through the Moves - /// stream (peers' grids fill in once the Moves push lands). - func sendCompletionPings(gameID: UUID, resigned: Bool) async { - await publishCompletionPush(gameID: gameID, resigned: resigned) - await publishReplayPush(gameID: gameID) + /// Persists the intent to notify peers before completion's asynchronous + /// CloudKit work begins. The visible completion alert waits until both the + /// Game marker and this device's final Moves snapshot are durable; replay + /// waits for the journal too. + func stageCompletionDelivery(gameID: UUID, resigned: Bool) { + var pending = pendingCompletions() + pending[gameID] = CompletionDeliveryProgress(resigned: resigned) + savePendingCompletions(pending) + syncMonitor.note("push(completion): staged pending durable Game+Moves for \(gameID.uuidString.prefix(8))…") + } + + func noteCompletionRecordsSaved( + _ records: [UUID: Set<CompletionDurableRecordKind>] + ) async { + var pending = pendingCompletions() + for (gameID, kinds) in records where pending[gameID] != nil { + pending[gameID]?.acknowledge(kinds) + } + savePendingCompletions(pending) + for gameID in records.keys where pending[gameID] != nil { + await deliverPendingCompletion(gameID: gameID) + } + } + + /// Retries a worker publish that was interrupted after CloudKit durability + /// had already been established. Called during service wiring on launch. + func resumePendingCompletionDeliveries() async { + for gameID in pendingCompletions().keys { + await deliverPendingCompletion(gameID: gameID) + } + } + + private func deliverPendingCompletion(gameID: UUID) async { + var all = pendingCompletions() + guard var pending = all[gameID] else { return } + if pending.canDeliverCompletion { + guard await publishCompletionPush(gameID: gameID, resigned: pending.resigned) else { return } + pending.completionDelivered = true + all[gameID] = pending + savePendingCompletions(all) + } + if pending.canDeliverReplay { + guard await publishReplayPush(gameID: gameID) else { return } + pending.replayDelivered = true + } + if pending.completionDelivered && pending.replayDelivered { + all.removeValue(forKey: gameID) + } else { + all[gameID] = pending + } + savePendingCompletions(all) + } + + private func pendingCompletions() -> [UUID: CompletionDeliveryProgress] { + guard let data = completionDefaults.data(forKey: Self.pendingCompletionsKey), + let stored = try? JSONDecoder().decode([String: CompletionDeliveryProgress].self, from: data) + else { return [:] } + return Dictionary(uniqueKeysWithValues: stored.compactMap { key, value in + UUID(uuidString: key).map { ($0, value) } + }) + } + + private func savePendingCompletions(_ pending: [UUID: CompletionDeliveryProgress]) { + let stored = Dictionary(uniqueKeysWithValues: pending.map { ($0.key.uuidString, $0.value) }) + completionDefaults.set(try? JSONEncoder().encode(stored), forKey: Self.pendingCompletionsKey) } // MARK: Nudge @@ -426,20 +518,20 @@ final class SessionCoordinator { pruneIfIdle(gameID) } - private func publishCompletionPush(gameID: UUID, resigned: Bool) async { + private func publishCompletionPush(gameID: UUID, resigned: Bool) async -> Bool { let kindLabel = resigned ? "resign" : "win" guard let pushClient else { syncMonitor.note("push(\(kindLabel)): skipped (no pushClient)") - return + return false } guard let localAuthorID = identity.currentID, !localAuthorID.isEmpty else { syncMonitor.note("push(\(kindLabel)): skipped (no authorID)") - return + return false } let plan = await pushPlan(for: gameID, excluding: localAuthorID) guard !plan.recipients.isEmpty else { syncMonitor.note("push(\(kindLabel)): skipped (no recipients)") - return + return true } // Send to every participant: presence is no longer guessed here. A // present recipient suppresses the banner where they're playing and @@ -452,7 +544,7 @@ final class SessionCoordinator { } guard !addressees.isEmpty else { syncMonitor.note("push(\(kindLabel)): skipped (no addressable recipients)") - return + return true } let kind = resigned ? "resign" : "win" let body = PuzzleNotificationText.completionBody( @@ -460,7 +552,7 @@ final class SessionCoordinator { puzzleTitle: plan.title, resigned: resigned ) - await pushClient.publish( + return await pushClient.publish( kind: kind, gameID: gameID, addressees: addressees, @@ -471,15 +563,15 @@ final class SessionCoordinator { ) } - private func publishReplayPush(gameID: UUID) async { + private func publishReplayPush(gameID: UUID) async -> Bool { guard let pushClient else { syncMonitor.note("push(replay): skipped (no pushClient)") - return + return false } let plan = await pushPlan(for: gameID) guard !plan.recipients.isEmpty else { syncMonitor.note("push(replay): skipped (no recipients)") - return + return true } let addressees = plan.recipients.compactMap { recipient in recipient.pushAddress.map { @@ -488,9 +580,9 @@ final class SessionCoordinator { } guard !addressees.isEmpty else { syncMonitor.note("push(replay): skipped (no addressable recipients)") - return + return true } - await pushClient.publish( + return await pushClient.publish( kind: "replay", gameID: gameID, addressees: addressees, diff --git a/Crossmate/Sync/SyncEngine.swift b/Crossmate/Sync/SyncEngine.swift @@ -3,6 +3,12 @@ import CoreData import Foundation import SwiftUI +enum CompletionDurableRecordKind: String, Codable, Hashable, Sendable { + case game + case moves + case journal +} + extension EnvironmentValues { @Entry var syncEngine: SyncEngine? = nil } @@ -168,6 +174,7 @@ actor SyncEngine { /// App-level side effects that are not sync-engine state (for example /// closing public share tickets) hang off this edge. var onGameCompleted: (@MainActor @Sendable (UUID) async -> Void)? + private var onCompletionRecordsSaved: (@MainActor @Sendable ([UUID: Set<CompletionDurableRecordKind>]) async -> Void)? /// Fires with the game ID of a shared zone that just appeared locally — /// the user joined the game here or on a sibling device. Drives cleanup /// of the now-redundant pending invite row. @@ -267,6 +274,12 @@ actor SyncEngine { onGameCompleted = cb } + func setOnCompletionRecordsSaved( + _ cb: @MainActor @Sendable @escaping ([UUID: Set<CompletionDurableRecordKind>]) async -> Void + ) { + onCompletionRecordsSaved = cb + } + func setOnGameJoined(_ cb: @MainActor @Sendable @escaping (UUID) async -> Void) { onGameJoined = cb } @@ -1832,6 +1845,22 @@ actor SyncEngine { _ event: CKSyncEngine.Event.SentRecordZoneChanges, isPrivate: Bool ) async { + var completionRecords: [UUID: Set<CompletionDurableRecordKind>] = [:] + for record in event.savedRecords { + let name = record.recordID.recordName + if record.recordType == "Game", + name.hasPrefix("game-"), + record["completedAt"] as? Date != nil, + let gameID = UUID(uuidString: String(name.dropFirst("game-".count))) { + completionRecords[gameID, default: []].insert(.game) + } else if record.recordType == "Moves", + let gameID = RecordSerializer.parseMovesRecordName(name)?.0 { + completionRecords[gameID, default: []].insert(.moves) + } else if record.recordType == "Journal", + let gameID = RecordSerializer.parseJournalRecordName(name)?.0 { + completionRecords[gameID, default: []].insert(.journal) + } + } let savedTypes = Dictionary( grouping: event.savedRecords, by: \.recordType @@ -2088,6 +2117,9 @@ actor SyncEngine { for message in failureMessages { await trace(message) } + if !completionRecords.isEmpty, let onCompletionRecordsSaved { + await onCompletionRecordsSaved(completionRecords) + } } nonisolated func isInvalidSharedZoneOwnerError(_ error: NSError) -> Bool { diff --git a/Crossmate/Views/GameList/GameListView.swift b/Crossmate/Views/GameList/GameListView.swift @@ -195,8 +195,6 @@ struct GameListView: View { if let target = resignTarget { do { try store.resignGame(id: target.id) - let id = target.id - Task { await appActions?.sendResignPings(gameID: id) } } catch { announcements.post(Announcement( id: Self.destructiveActionErrorID, diff --git a/Tests/Unit/GameStoreCompletionLockTests.swift b/Tests/Unit/GameStoreCompletionLockTests.swift @@ -143,4 +143,54 @@ struct GameStoreCompletionLockTests { #expect(mutator.isCompleted == false) #expect(game.completionState != .solved) } + + @Test("Completion delivery waits for both Game and Moves, then journal") + func completionDeliveryGates() { + var progress = CompletionDeliveryProgress(resigned: false) + #expect(!progress.canDeliverCompletion) + + progress.durableRecords.insert(.game) + #expect(!progress.canDeliverCompletion) + progress.durableRecords.insert(.journal) + #expect(!progress.canDeliverCompletion) + + progress.durableRecords.insert(.moves) + #expect(progress.canDeliverCompletion) + #expect(!progress.canDeliverReplay) + + progress.completionDelivered = true + #expect(progress.canDeliverReplay) + progress.replayDelivered = true + #expect(!progress.canDeliverReplay) + } + + @Test("A pre-completion Moves acknowledgement cannot open the gate") + func completionDeliveryOrdering() { + var progress = CompletionDeliveryProgress(resigned: false) + progress.acknowledge([.moves, .journal]) + #expect(progress.durableRecords.isEmpty) + progress.acknowledge([.game]) + #expect(!progress.canDeliverCompletion) + progress.acknowledge([.moves, .journal]) + #expect(progress.canDeliverCompletion) + #expect(progress.durableRecords.contains(.journal)) + } + + @Test("Only the device that initiates completion stages a peer notification") + func completionCallbackDistinguishesObservedSolve() throws { + let persistence = makeTestPersistence() + let store = makeTestStore( + persistence: persistence, + authorIDProvider: { Self.authorID } + ) + let (_, gameID) = try makeGame(completed: false, in: persistence.viewContext) + var callback: (resigned: Bool, notifyPeers: Bool)? + store.onJournalComplete = { _, _, resigned, notifyPeers in + callback = (resigned, notifyPeers) + } + + #expect(try store.markCompletedFromObservedSolvedState(id: gameID)) + #expect(callback?.resigned == false) + #expect(callback?.notifyPeers == false) + } }