crossmate

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

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:
MCrossmate/Persistence/GameStore.swift | 7++++++-
MCrossmate/Services/AppServices.swift | 97+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------
MCrossmate/Sync/RecordSerializer.swift | 28++++++++++++++++++++++------
MCrossmate/Sync/SyncEngine.swift | 77++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
MTests/Unit/PuzzleNotificationTextTests.swift | 2++
MTests/Unit/RecordSerializerTests.swift | 41+++++++++++++++++++++++++++++++++++++++++
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.