commit e13c9a77f1ce2d04002a00f6e533cf709cabc3a6
parent ef23fd766891eecb1132631c99c37f45c2e1e02d
Author: Michael Camilleri <[email protected]>
Date: Fri, 15 May 2026 16:48:53 +0900
Dismiss notifications across user's devices
The active-puzzle guard and dismissDeliveredNotifications keep Notification
Center tidy on the device the user opened the puzzle on, but a sibling
iPhone/iPad on the same iCloud account will still have the notification until
the user dismisses it.
This commit introduces a new PingKind (.opened) that fires when a puzzle is
entered and fans out to the authoring user's other devices. The new ping lives
in a new 'account' zone in the private database — every other ping flows
through a per-game zone shared with collaborators, but .opened is a private
coordination signal between a single user's own devices, so it doesn't belong
on a shared zone. RecordSerializer.accountZoneID names it; SyncEngine
.enqueueOpenedPing adds the zone to the engine's pending changes (idempotent —
CKSyncEngine dedupes saveZone) and queues the record write. The account zone is
appended to incompleteKnownZones for the private scope so live-query fetches
pick up pings written by sibling devices, with a .distantPast lookback floor
since any unobserved .opened ping is in scope.
In addition, Ping records generally gain a deviceID field. authorID alone can't
distinguish an iPhone from an iPad — they share an iCloud author — so the
.opened receive side filters self-sends by (authorID, deviceID).
recordName(forPingInGame:...) folds deviceID into the name so writes from two
devices for the same gameID at the same millisecond don't collide. Legacy ping
records written before this schema lack the field; the parser defaults deviceID
to the empty string, which can never equal a real localDeviceID, so the
self-send filter remains correct against old data.
AppServices.applyOpenedPing handles the receive side. It dedups by ping record
name, drops self-sends, removes any delivered notifications for the ping's
gameID, calls GameStore.markOtherMovesSeenWithoutLoading to advance
lastSeenOtherMoveAt without disturbing currentEntity, and refreshes the icon
badge. presentPings partitions .opened out from the player-facing kinds and
routes it through applyOpenedPing. The .opened type is system-only and runs
without notification authorization because it never displays anything.
dismissDeliveredNotifications now also broadcasts an .opened ping after
clearing the local Notification Center, so navigating into a puzzle propagates
the dismissal to siblings.
Co-Authored-By: Claude Opus 4.7 <[email protected]>
Diffstat:
6 files changed, 233 insertions(+), 19 deletions(-)
diff --git a/Crossmate/Persistence/GameStore.swift b/Crossmate/Persistence/GameStore.swift
@@ -328,7 +328,12 @@ final class GameStore {
if (entity.latestOtherMoveAt ?? .distantPast) < latest {
entity.latestOtherMoveAt = latest
}
- if currentEntity?.id == gameID {
+ // Use the foreground-visible signal rather than `currentEntity` —
+ // the latter stays set after a normal back-out from the puzzle,
+ // and would otherwise advance lastSeenOtherMoveAt in lockstep
+ // even when the user is sitting on the library list, suppressing
+ // the badge that should appear there.
+ if NotificationState.isActive(gameID: gameID) {
entity.lastSeenOtherMoveAt = entity.latestOtherMoveAt
}
}
diff --git a/Crossmate/Services/AppServices.swift b/Crossmate/Services/AppServices.swift
@@ -874,13 +874,20 @@ final class AppServices {
private func presentPings(_ pings: [Ping]) async {
guard !pings.isEmpty else { return }
+ // `.opened` is system-only (notification dismissal, badge agreement).
+ // It runs without authorization since it doesn't show alerts.
+ let (openedPings, playerFacingPings) = pings.partitioned { $0.kind == .opened }
+ for ping in openedPings {
+ await applyOpenedPing(ping)
+ }
+ guard !playerFacingPings.isEmpty else { return }
guard await canPresentNotifications() else {
syncMonitor.note("ping: local notification skipped — authorization not granted")
return
}
let center = UNUserNotificationCenter.current()
- for ping in pings {
+ for ping in playerFacingPings {
if ping.authorID == identity.currentID {
syncMonitor.note("ping(\(ping.kind.rawValue)): skipped self-authored record \(ping.recordName)")
continue
@@ -1014,6 +1021,11 @@ final class AppServices {
case .word: return "\(player) revealed a word in \(puzzleSuffix)"
case .puzzle, .none: return "\(player) revealed \(puzzleSuffix)"
}
+ case .opened:
+ // `.opened` pings are system-only — handled by `applyOpenedPing`,
+ // never presented as a notification. If this text ever surfaces
+ // in a log or alert, the dispatch in `presentPings` has broken.
+ return "opened ping should not be in shared zone"
}
}
@@ -1092,11 +1104,15 @@ final class AppServices {
}
/// Removes any already-delivered local notifications for `gameID` from
- /// Notification Center. The active-puzzle guard in `presentPings` /
- /// `presentSessions` prevents *new* notifications while a game is open,
- /// but anything delivered before the user navigated in still needs to
- /// be cleared explicitly so Notification Center isn't littered with
- /// stale entries for a puzzle they're now actively viewing.
+ /// Notification Center and broadcasts an `.opened` ping so the user's
+ /// other devices can clear their copies too. The active-puzzle guard in
+ /// `presentPings` / `presentSessions` prevents *new* notifications while
+ /// a game is open, but anything delivered before the user navigated in
+ /// still needs to be cleared explicitly so Notification Center isn't
+ /// littered with stale entries for a puzzle they're now actively viewing.
+ ///
+ /// The ping fires unconditionally — a sibling device may have a
+ /// notification we don't, and we can't see its Notification Center.
func dismissDeliveredNotifications(for gameID: UUID) async {
let center = UNUserNotificationCenter.current()
let delivered = await center.deliveredNotifications()
@@ -1107,9 +1123,56 @@ final class AppServices {
else { return nil }
return notification.request.identifier
}
- guard !identifiers.isEmpty else { return }
- center.removeDeliveredNotifications(withIdentifiers: identifiers)
- syncMonitor.note("notif: dismissed \(identifiers.count) delivered for \(gameID.uuidString)")
+ if !identifiers.isEmpty {
+ center.removeDeliveredNotifications(withIdentifiers: identifiers)
+ syncMonitor.note("notif: dismissed \(identifiers.count) delivered for \(gameID.uuidString)")
+ }
+ await broadcastOpenedPing(for: gameID)
+ }
+
+ /// Applies an `.opened` ping observed from another device of the same
+ /// iCloud user: clears matching delivered notifications on this device
+ /// and marks any unseen other-author moves as seen so the badge agrees.
+ /// Self-sends — same (authorID, deviceID) — are dropped.
+ private func applyOpenedPing(_ ping: Ping) async {
+ if ping.authorID == identity.currentID,
+ ping.deviceID == RecordSerializer.localDeviceID {
+ return
+ }
+ if NotificationState.wasShown(pingRecordName: ping.recordName) {
+ return
+ }
+ NotificationState.recordShown(pingRecordName: ping.recordName)
+
+ let center = UNUserNotificationCenter.current()
+ let delivered = await center.deliveredNotifications()
+ let identifiers = delivered.compactMap { notification -> String? in
+ let userInfo = notification.request.content.userInfo
+ guard let raw = userInfo["crossmateGameID"] as? String,
+ raw == ping.gameID.uuidString
+ else { return nil }
+ return notification.request.identifier
+ }
+ if !identifiers.isEmpty {
+ center.removeDeliveredNotifications(withIdentifiers: identifiers)
+ syncMonitor.note("opened ping: dismissed \(identifiers.count) delivered for \(ping.gameID.uuidString)")
+ }
+ store.markOtherMovesSeenWithoutLoading(gameID: ping.gameID)
+ await refreshAppBadge()
+ }
+
+ private func broadcastOpenedPing(for gameID: UUID) async {
+ guard let authorID = identity.currentID, !authorID.isEmpty else {
+ syncMonitor.note("opened ping: skipped — no authorID for \(gameID.uuidString)")
+ return
+ }
+ let playerName = preferences.name
+ await syncEngine.enqueueOpenedPing(
+ gameID: gameID,
+ authorID: authorID,
+ playerName: playerName
+ )
+ syncMonitor.note("opened ping: enqueued for \(gameID.uuidString)")
}
/// Sets the app icon badge to the number of shared games with unseen
@@ -1139,3 +1202,19 @@ final class AppServices {
}
}
+
+private extension Array {
+ /// Splits the collection into `(matched, rejected)` in one pass.
+ func partitioned(by predicate: (Element) -> Bool) -> ([Element], [Element]) {
+ var matched: [Element] = []
+ var rejected: [Element] = []
+ for element in self {
+ if predicate(element) {
+ matched.append(element)
+ } else {
+ rejected.append(element)
+ }
+ }
+ return (matched, rejected)
+ }
+}
diff --git a/Crossmate/Sync/RecordSerializer.swift b/Crossmate/Sync/RecordSerializer.swift
@@ -50,15 +50,17 @@ enum RecordSerializer {
"player-\(gameID.uuidString)-\(authorID)"
}
- /// One Ping record per event. The event timestamp (ms since epoch) makes
- /// the name unique across events and devices, so repeated pings from the
- /// same author for the same game don't collide.
+ /// One Ping record per event. `deviceID` keeps cross-device writes from
+ /// the same iCloud user unique (authorID is identical across that user's
+ /// devices), and the event timestamp covers repeated pings from the same
+ /// device.
static func recordName(
forPingInGame gameID: UUID,
authorID: String,
+ deviceID: String,
eventTimestampMs: Int64
) -> String {
- "ping-\(gameID.uuidString)-\(authorID)-\(eventTimestampMs)"
+ "ping-\(gameID.uuidString)-\(authorID)-\(deviceID)-\(eventTimestampMs)"
}
// MARK: - Zone
@@ -73,6 +75,15 @@ enum RecordSerializer {
CKRecordZone.ID(zoneName: "game-\(gameID.uuidString)", ownerName: ownerName)
}
+ /// Zone ID for the user's account-wide zone in the private database. Holds
+ /// records that coordinate state between a single iCloud user's own
+ /// devices — never shared with collaborators, since the private database
+ /// itself isn't reachable to anyone else.
+ static let accountZoneID = CKRecordZone.ID(
+ zoneName: "account",
+ ownerName: CKCurrentUserDefaultName
+ )
+
// MARK: - Moves record building
static func movesRecord(
@@ -137,13 +148,16 @@ enum RecordSerializer {
/// Builds a freshly-minted Ping record. Pings are write-once — they have
/// no Core Data equivalent and no system-fields archive.
- /// - `authorID` lets receivers filter out self-sends.
+ /// - `authorID` + `deviceID` together let receivers filter out self-sends.
+ /// authorID alone is insufficient for kinds (e.g. `.opened`) that fire
+ /// between a single user's own devices, where authorID is identical.
/// - `playerName` and `puzzleTitle` let receivers render the alert body.
- /// - `kind` distinguishes join/win/check/reveal events.
+ /// - `kind` distinguishes join/win/check/reveal/opened events.
/// - `scope` is set only for check/reveal kinds.
static func pingRecord(
gameID: UUID,
authorID: String,
+ deviceID: String,
playerName: String,
puzzleTitle: String,
eventTimestampMs: Int64,
@@ -154,11 +168,13 @@ enum RecordSerializer {
let name = recordName(
forPingInGame: gameID,
authorID: authorID,
+ deviceID: deviceID,
eventTimestampMs: eventTimestampMs
)
let recordID = CKRecord.ID(recordName: name, zoneID: zone)
let record = CKRecord(recordType: "Ping", recordID: recordID)
record["authorID"] = authorID as CKRecordValue
+ record["deviceID"] = deviceID as CKRecordValue
record["playerName"] = playerName as CKRecordValue
record["puzzleTitle"] = puzzleTitle as CKRecordValue
record["kind"] = kind.rawValue as CKRecordValue
diff --git a/Crossmate/Sync/SyncEngine.swift b/Crossmate/Sync/SyncEngine.swift
@@ -17,12 +17,15 @@ extension Notification.Name {
}
/// What a Ping record represents. Stored as a string in the CKRecord's
-/// `kind` field.
+/// `kind` field. `.opened` is sent between a single iCloud user's own devices
+/// in the account zone to coordinate notification dismissal; all other kinds
+/// flow between players in a per-game zone.
enum PingKind: String, Sendable {
case join
case win
case check
case reveal
+ case opened
}
/// Granularity of a check/reveal action. Stored as a string in the CKRecord's
@@ -37,6 +40,7 @@ struct Ping: Sendable {
let recordName: String
let gameID: UUID
let authorID: String
+ let deviceID: String
let playerName: String
let puzzleTitle: String
let kind: PingKind
@@ -79,6 +83,7 @@ actor SyncEngine {
private struct PingPayload {
let gameID: UUID
let authorID: String
+ let deviceID: String
let playerName: String
let puzzleTitle: String
let eventTimestampMs: Int64
@@ -340,15 +345,18 @@ actor SyncEngine {
guard let zoneAndTitle else { return }
let engine = zoneAndTitle.info.scope == 1 ? sharedEngine : privateEngine
guard let engine else { return }
+ let deviceID = RecordSerializer.localDeviceID
let eventTimestampMs = Int64(Date().timeIntervalSince1970 * 1000)
let recordName = RecordSerializer.recordName(
forPingInGame: gameID,
authorID: authorID,
+ deviceID: deviceID,
eventTimestampMs: eventTimestampMs
)
pendingPings[recordName] = PingPayload(
gameID: gameID,
authorID: authorID,
+ deviceID: deviceID,
playerName: playerName,
puzzleTitle: zoneAndTitle.title,
eventTimestampMs: eventTimestampMs,
@@ -360,6 +368,51 @@ actor SyncEngine {
Task { try? await engine.sendChanges() }
}
+ /// Registers an `.opened` Ping for cross-device notification dismissal.
+ /// Written to the account zone in the private database, so only the
+ /// authoring user's own devices receive it. The receive side filters
+ /// self-sends by (authorID, deviceID).
+ func enqueueOpenedPing(
+ gameID: UUID,
+ authorID: String,
+ playerName: String
+ ) {
+ guard let engine = privateEngine else { return }
+ let ctx = persistence.container.newBackgroundContext()
+ let title: String = ctx.performAndWait {
+ let req = NSFetchRequest<GameEntity>(entityName: "GameEntity")
+ req.predicate = NSPredicate(format: "id == %@", gameID as CVarArg)
+ req.fetchLimit = 1
+ let entity = try? ctx.fetch(req).first
+ return Self.notificationTitle(for: entity)
+ }
+ let deviceID = RecordSerializer.localDeviceID
+ let eventTimestampMs = Int64(Date().timeIntervalSince1970 * 1000)
+ let recordName = RecordSerializer.recordName(
+ forPingInGame: gameID,
+ authorID: authorID,
+ deviceID: deviceID,
+ eventTimestampMs: eventTimestampMs
+ )
+ pendingPings[recordName] = PingPayload(
+ gameID: gameID,
+ authorID: authorID,
+ deviceID: deviceID,
+ playerName: playerName,
+ puzzleTitle: title,
+ eventTimestampMs: eventTimestampMs,
+ kind: .opened,
+ scope: nil
+ )
+ let zoneID = RecordSerializer.accountZoneID
+ // Make sure the zone exists before the record write. CKSyncEngine
+ // dedupes redundant saveZone requests, so it's safe to repeat.
+ engine.state.add(pendingDatabaseChanges: [.saveZone(CKRecordZone(zoneID: zoneID))])
+ let recordID = CKRecord.ID(recordName: recordName, zoneID: zoneID)
+ engine.state.add(pendingRecordZoneChanges: [.saveRecord(recordID)])
+ Task { try? await engine.sendChanges() }
+ }
+
/// Deletes transient Ping records for a completed owned game while keeping
/// every `.win` ping. Participants see the owner's zone through the share,
/// so the owner-side deletion removes the records from the cooperative
@@ -625,7 +678,7 @@ actor SyncEngine {
database: database,
zoneID: zoneID,
since: since,
- desiredKeys: ["authorID", "playerName", "puzzleTitle", "kind", "scope"]
+ desiredKeys: ["authorID", "deviceID", "playerName", "puzzleTitle", "kind", "scope"]
)
return PerZonePings(records: records, orphanedZone: nil)
} catch {
@@ -719,7 +772,7 @@ actor SyncEngine {
database: database,
zoneID: zone.zoneID,
since: since,
- desiredKeys: ["authorID", "playerName", "puzzleTitle", "kind", "scope"]
+ desiredKeys: ["authorID", "deviceID", "playerName", "puzzleTitle", "kind", "scope"]
)
async let playerRecords = self.queryLiveRecords(
type: "Player",
@@ -1566,6 +1619,18 @@ actor SyncEngine {
let createdAt = entity.createdAt ?? Date(timeIntervalSince1970: 0)
result.append((CKRecordZone.ID(zoneName: zoneName, ownerName: ownerName), createdAt))
}
+ // The account zone lives in the private database alongside the
+ // per-game zones and currently carries `.opened` pings between a
+ // single user's own devices. No GameEntity backs it, so it's
+ // appended explicitly. The lookback floor is `.distantPast`: any
+ // `.opened` ping we haven't observed yet should be processed.
+ if scope == 0 {
+ let zoneID = RecordSerializer.accountZoneID
+ let key = "\(zoneID.ownerName)|\(zoneID.zoneName)"
+ if seen.insert(key).inserted {
+ result.append((zoneID, Date(timeIntervalSince1970: 0)))
+ }
+ }
return result
}
}
@@ -1651,6 +1716,7 @@ actor SyncEngine {
return RecordSerializer.pingRecord(
gameID: payload.gameID,
authorID: payload.authorID,
+ deviceID: payload.deviceID,
playerName: payload.playerName,
puzzleTitle: payload.puzzleTitle,
eventTimestampMs: payload.eventTimestampMs,
@@ -2076,10 +2142,15 @@ actor SyncEngine {
let kind = PingKind(rawValue: kindRaw)
else { return nil }
let scope: PingScope? = (record["scope"] as? String).flatMap(PingScope.init(rawValue:))
+ // Legacy records written before the schema added `deviceID` won't have
+ // the field. Parse-tolerant: empty string can never equal a real
+ // localDeviceID, so the self-send filter stays safe.
+ let deviceID = (record["deviceID"] as? String) ?? ""
return Ping(
recordName: name,
gameID: gameID,
authorID: authorID,
+ deviceID: deviceID,
playerName: (record["playerName"] as? String) ?? "",
puzzleTitle: (record["puzzleTitle"] as? String) ?? "",
kind: kind,
diff --git a/Tests/Unit/PuzzleNotificationTextTests.swift b/Tests/Unit/PuzzleNotificationTextTests.swift
@@ -21,6 +21,7 @@ struct PuzzleNotificationTextTests {
recordName: "ping-test-1",
gameID: UUID(),
authorID: "alice",
+ deviceID: "device-a",
playerName: "Alice",
puzzleTitle: "Saturday Puzzle – 1 January 2001",
kind: .join,
@@ -36,6 +37,7 @@ struct PuzzleNotificationTextTests {
recordName: "ping-test-2",
gameID: UUID(),
authorID: "alice",
+ deviceID: "device-a",
playerName: "Alice",
puzzleTitle: "Saturday Puzzle – 1 January 2001",
kind: .check,
diff --git a/Tests/Unit/RecordSerializerTests.swift b/Tests/Unit/RecordSerializerTests.swift
@@ -68,6 +68,47 @@ struct RecordSerializerTests {
#expect(RecordSerializer.parsePlayerRecordName("player-12345678-1234-1234-1234-123456789ABC") == nil)
}
+ // MARK: - Ping
+
+ @Test("recordName(forPingInGame:authorID:deviceID:eventTimestampMs:) includes deviceID")
+ func pingRecordNameIncludesDeviceID() {
+ let id = UUID(uuidString: "12345678-1234-1234-1234-123456789ABC")!
+ let name = RecordSerializer.recordName(
+ forPingInGame: id,
+ authorID: "alice",
+ deviceID: "deviceA",
+ eventTimestampMs: 1700000000000
+ )
+ #expect(name == "ping-12345678-1234-1234-1234-123456789ABC-alice-deviceA-1700000000000")
+ }
+
+ @Test("pingRecord writes authorID and deviceID fields")
+ func pingRecordWritesDeviceID() {
+ let id = UUID()
+ let zone = CKRecordZone.ID(zoneName: "test-zone", ownerName: CKCurrentUserDefaultName)
+ let record = RecordSerializer.pingRecord(
+ gameID: id,
+ authorID: "alice",
+ deviceID: "deviceA",
+ playerName: "Alice",
+ puzzleTitle: "Puzzle",
+ eventTimestampMs: 1700000000000,
+ kind: .opened,
+ scope: nil,
+ zone: zone
+ )
+ #expect(record["authorID"] as? String == "alice")
+ #expect(record["deviceID"] as? String == "deviceA")
+ #expect(record["kind"] as? String == "opened")
+ }
+
+ @Test("accountZoneID is named 'account' in the current user's private DB")
+ func accountZoneIDShape() {
+ let zone = RecordSerializer.accountZoneID
+ #expect(zone.zoneName == "account")
+ #expect(zone.ownerName == CKCurrentUserDefaultName)
+ }
+
// MARK: - applyGameRecord
/// Writes `source` to a temp file and returns a `CKAsset` pointing to it.