crossmate

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

commit d2243dc5ff197aeba21f4ac28161038a4d117307
parent 6982ae31db03498897daa4d519bdf9fb95031ea7
Author: Michael Camilleri <[email protected]>
Date:   Sat,  9 May 2026 10:28:52 +0900

Fix notification replay bug

Notifications are created by Ping records. These were being refetched
during active sessions causing a flurry of notifications to be received.
These records should not be refetched at all; only Moves and Game are
required.

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

Diffstat:
MCrossmate/Models/Game.swift | 4++--
MCrossmate/Sync/SyncEngine.swift | 25+++++++------------------
MCrossmate/Views/PuzzleView.swift | 3+++
3 files changed, 12 insertions(+), 20 deletions(-)

diff --git a/Crossmate/Models/Game.swift b/Crossmate/Models/Game.swift @@ -12,8 +12,8 @@ final class Game { let puzzle: Puzzle var squares: [[Square]] @ObservationIgnored private let fillableCellCount: Int - @ObservationIgnored private var filledCellCount: Int = 0 - @ObservationIgnored private var wrongCellCount: Int = 0 + private var filledCellCount: Int = 0 + private var wrongCellCount: Int = 0 init(puzzle: Puzzle) { self.puzzle = puzzle diff --git a/Crossmate/Sync/SyncEngine.swift b/Crossmate/Sync/SyncEngine.swift @@ -386,9 +386,11 @@ actor SyncEngine { /// or record-zone events until a later foreground fetch. This direct pull /// trades token efficiency for latency: it asks CloudKit for modified /// zones in the notified database scope from a nil token and applies the - /// returned records through the same idempotent merge path used by - /// CKSyncEngine events. CKSyncEngine remains the owner of durable tokens - /// and will catch up during normal fetches. + /// returned Game/Moves records through the same idempotent merge path + /// used by CKSyncEngine events. Event-like records such as Ping are + /// intentionally ignored here because a nil-token fetch can redeliver old + /// records. CKSyncEngine remains the owner of durable tokens and will + /// catch up during normal fetches. func fetchPushChangesDirect(scope: CKDatabase.Scope) async throws { let database: CKDatabase let scopeValue: Int16 @@ -504,10 +506,9 @@ actor SyncEngine { let ctx = persistence.container.newBackgroundContext() ctx.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump let localAuthorID = await currentLocalAuthorID() - let (movesUpdatedGameIDs, affectedGameIDs, pings): (Set<UUID>, Set<UUID>, [Ping]) = ctx.performAndWait { + let (movesUpdatedGameIDs, affectedGameIDs): (Set<UUID>, Set<UUID>) = ctx.performAndWait { var movesUpdated = Set<UUID>() var affected = Set<UUID>() - var pings: [Ping] = [] for record in records { switch record.recordType { case "Game": @@ -524,15 +525,6 @@ actor SyncEngine { movesUpdated.insert(value.gameID) affected.insert(value.gameID) } - case "Player": - if let (gameID, _) = RecordSerializer.parsePlayerRecordName(record.recordID.recordName) { - self.applyPlayerRecord(record, in: ctx) - affected.insert(gameID) - } - case "Ping": - if let ping = Self.parsePingRecord(record) { - pings.append(ping) - } default: break } @@ -562,15 +554,12 @@ actor SyncEngine { ) } } - return (movesUpdated, affected, pings) + return (movesUpdated, affected) } if let onRemoteMovesUpdated, !movesUpdatedGameIDs.isEmpty { await onRemoteMovesUpdated(movesUpdatedGameIDs) } - if let onPings, !pings.isEmpty { - await onPings(pings) - } if !affectedGameIDs.isEmpty { NotificationCenter.default.post( name: .playerRosterShouldRefresh, diff --git a/Crossmate/Views/PuzzleView.swift b/Crossmate/Views/PuzzleView.swift @@ -831,6 +831,9 @@ private struct PuzzleLifecycleModifier: ViewModifier { onCompletionStateChanged(.solved) } } + .onChange(of: session.game.completionState) { _, newValue in + onCompletionStateChanged(newValue) + } .onAppear { session.onCompletionStateChanged = { newValue in onCompletionStateChanged(newValue)