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