commit 6756becfdf92f1eca0a34803922693415c36e12d
parent 0812dac2de826334bc3c6fb64104ba42412f3b84
Author: Michael Camilleri <[email protected]>
Date: Wed, 6 May 2026 07:16:58 +0900
Expand scope of notifications of collaborator actions
Prior to this commit, the only collaborator notification was a generic
session-start ping ('X has updated the puzzle Y') fired by MoveBuffer after a
30-minute idle gap, with the receiver further suppressing repeats inside a
two-hour window.
This commit broadens the system to cover four additional events (collaborator
joining a shared game, solving the puzzle, checking an answer, and revealing an
answer) and tailors the body copy of each. The session ping is retained but its
idle threshold is shortened to twenty minutes, and the receiver-side dedup
window is shortened to match so the sender's gating dominates.
The CloudKit record type is generalised: SessionPing is renamed to Ping, gains
kind and scope fields, and the record-name prefix moves from sessionping- to
ping-. The new check/reveal events are emitted from PlayerSession with a scope
of square, word, or puzzle; win is emitted on the .solved completion-state
transition (seeded at construction so opening an already-solved puzzle does not
falsely fire); and join is emitted by the joiner once share acceptance has
fetched the shared zone.
Suppression rules are split: active-puzzle suppression applies to all ping
kinds (no notifications for the puzzle the user is already in), while the dedup
window applies only to session pings. The foreground willPresent handler reads
the ping kind from userInfo so a non-session ping cannot extend the session
dedup window.
Co-Authored-By: Claude Opus 4.7 <[email protected]>
Diffstat:
9 files changed, 247 insertions(+), 97 deletions(-)
diff --git a/Crossmate/CrossmateApp.swift b/Crossmate/CrossmateApp.swift
@@ -57,8 +57,11 @@ final class AppDelegate: NSObject, UIApplicationDelegate, @preconcurrency UNUser
completionHandler([.banner, .sound])
return
}
- NotificationState.recordShown(gameID: gameID)
- if NotificationState.activePuzzleID() == gameID {
+ let isSession = (userInfo["crossmatePingKind"] as? String) == "session"
+ if isSession {
+ NotificationState.recordShown(gameID: gameID)
+ }
+ if NotificationState.isActive(gameID: gameID) {
completionHandler([])
} else {
completionHandler([.banner, .sound])
@@ -382,6 +385,25 @@ private struct PuzzleDisplayView: View {
session.onSelectionChanged = { selection in
Task { await presence.publish(selection) }
}
+ let syncEngine = services.syncEngine
+ let identity = services.identity
+ let preferences = self.preferences
+ let eventGameID = gameID
+ session.onPlayerEvent = { kind, scope in
+ guard preferences.isICloudSyncEnabled,
+ let authorID = identity.currentID
+ else { return }
+ let playerName = preferences.name
+ Task {
+ await syncEngine.enqueuePing(
+ kind: kind,
+ scope: scope,
+ gameID: eventGameID,
+ authorID: authorID,
+ playerName: playerName
+ )
+ }
+ }
let initial = PlayerSelection(
row: session.selectedRow,
col: session.selectedCol,
diff --git a/Crossmate/Models/PlayerSession.swift b/Crossmate/Models/PlayerSession.swift
@@ -34,6 +34,18 @@ final class PlayerSession {
@ObservationIgnored
var onCompletionStateChanged: ((Game.CompletionState) -> Void)?
+ /// Optional sink fired for collaborator-visible events (check, reveal, win)
+ /// so the puzzle screen can enqueue a CloudKit ping for other participants.
+ /// Set only for shared games with iCloud sync enabled; nil for solo games.
+ @ObservationIgnored
+ var onPlayerEvent: ((PingKind, PingScope?) -> Void)?
+
+ /// Last completion state we fired a `.win` ping for. Tracked so the win
+ /// ping fires once on the `.solved` transition and not on every keystroke
+ /// after the puzzle is already solved.
+ @ObservationIgnored
+ private var lastWinCompletionState: Game.CompletionState?
+
/// Rebus mode lets the player type a multi-character value into a single
/// cell (e.g. "STAR" or "♥"). While active, keyboard input accumulates in
/// `rebusBuffer` rather than going straight to `Game.squares`; on commit
@@ -46,6 +58,7 @@ final class PlayerSession {
init(game: Game, mutator: GameMutator) {
self.game = game
self.mutator = mutator
+ self.lastWinCompletionState = game.completionState
let puzzle = game.puzzle
// Start at the first across clue. Fall back to the first down clue if
@@ -155,30 +168,36 @@ final class PlayerSession {
let cell = puzzle.cells[selectedRow][selectedCol]
guard !cell.isBlock else { return }
mutator.checkCells([cell])
+ onPlayerEvent?(.check, .square)
}
func checkCurrentWord() {
mutator.checkCells(currentWordCells())
+ onPlayerEvent?(.check, .word)
}
func checkPuzzle() {
mutator.checkCells(puzzle.cells.flatMap { $0 })
+ onPlayerEvent?(.check, .puzzle)
}
func revealSquare() {
let cell = puzzle.cells[selectedRow][selectedCol]
guard !cell.isBlock else { return }
mutator.revealCells([cell])
+ onPlayerEvent?(.reveal, .square)
publishTerminalCompletionState()
}
func revealCurrentWord() {
mutator.revealCells(currentWordCells())
+ onPlayerEvent?(.reveal, .word)
publishTerminalCompletionState()
}
func revealPuzzle() {
mutator.revealCells(puzzle.cells.flatMap { $0 })
+ onPlayerEvent?(.reveal, .puzzle)
publishTerminalCompletionState()
}
@@ -320,6 +339,10 @@ final class PlayerSession {
private func publishTerminalCompletionState(_ state: Game.CompletionState? = nil) {
let state = state ?? game.completionState
guard state != .incomplete else { return }
+ if state == .solved, lastWinCompletionState != .solved {
+ onPlayerEvent?(.win, nil)
+ }
+ lastWinCompletionState = state
onCompletionStateChanged?(state)
}
diff --git a/Crossmate/Services/AppServices.swift b/Crossmate/Services/AppServices.swift
@@ -70,7 +70,9 @@ final class AppServices {
sessionPingSink: { [preferences] gameID, authorID in
guard await MainActor.run(body: { preferences.isICloudSyncEnabled }) else { return }
let name = await MainActor.run { preferences.name }
- await syncEngine.enqueueSessionPing(
+ await syncEngine.enqueuePing(
+ kind: .session,
+ scope: nil,
gameID: gameID,
authorID: authorID,
playerName: name
@@ -161,9 +163,9 @@ final class AppServices {
}
}
- await syncEngine.setOnSessionPings { [weak self] pings in
+ await syncEngine.setOnPings { [weak self] pings in
guard let self else { return }
- await self.presentSessionPings(pings)
+ await self.presentPings(pings)
}
await syncEngine.setOnAccountChange { [weak self] in
@@ -182,6 +184,20 @@ final class AppServices {
await syncEngine.enqueueDeleteRecords(prunedMoveNames)
}
+ cloudService.onShareJoined = { [weak self] gameID in
+ guard let self else { return }
+ guard self.preferences.isICloudSyncEnabled,
+ let authorID = self.identity.currentID
+ else { return }
+ await self.syncEngine.enqueuePing(
+ kind: .join,
+ scope: nil,
+ gameID: gameID,
+ authorID: authorID,
+ playerName: self.preferences.name
+ )
+ }
+
// NameBroadcaster fans out name changes to all shared/joined games.
// PuzzleDisplayView also calls `broadcastName()` when a shared puzzle
// is opened, which covers first-sync-after-share-create / accept.
@@ -292,7 +308,7 @@ final class AppServices {
return true
}
- private func presentSessionPings(_ pings: [SessionPing]) async {
+ private func presentPings(_ pings: [Ping]) async {
let center = UNUserNotificationCenter.current()
let settings = await center.notificationSettings()
let canNotify: Bool
@@ -309,37 +325,67 @@ final class AppServices {
for ping in pings {
if ping.authorID == identity.currentID { continue }
- if NotificationState.shouldSuppress(gameID: ping.gameID) {
+ if NotificationState.isActive(gameID: ping.gameID) {
+ if ping.kind == .session {
+ NotificationState.recordShown(gameID: ping.gameID)
+ }
+ syncMonitor.note("ping(\(ping.kind.rawValue)): suppressed — puzzle is active for \(ping.gameID.uuidString)")
+ continue
+ }
+ if ping.kind == .session,
+ NotificationState.wasRecentlyShown(gameID: ping.gameID) {
NotificationState.recordShown(gameID: ping.gameID)
- syncMonitor.note("session-ping: local notification suppressed for \(ping.gameID.uuidString)")
+ syncMonitor.note("ping(session): dedup-suppressed for \(ping.gameID.uuidString)")
continue
}
let content = UNMutableNotificationContent()
content.title = "Crossmate"
- if !ping.playerName.isEmpty, !ping.puzzleTitle.isEmpty {
- content.body = "\(ping.playerName) has updated the puzzle \(ping.puzzleTitle)"
- } else if !ping.playerName.isEmpty {
- content.body = "\(ping.playerName) has updated a puzzle"
- } else if !ping.puzzleTitle.isEmpty {
- content.body = "A player has updated the puzzle \(ping.puzzleTitle)"
- } else {
- content.body = "A player has updated a puzzle"
- }
+ content.body = Self.bodyText(for: ping)
content.sound = .default
- content.userInfo = ["crossmateGameID": ping.gameID.uuidString]
+ content.userInfo = [
+ "crossmateGameID": ping.gameID.uuidString,
+ "crossmatePingKind": ping.kind.rawValue
+ ]
let request = UNNotificationRequest(
- identifier: "sessionping-\(ping.gameID.uuidString)-\(UUID().uuidString)",
+ identifier: "ping-\(ping.gameID.uuidString)-\(UUID().uuidString)",
content: content,
trigger: nil
)
do {
try await center.add(request)
- NotificationState.recordShown(gameID: ping.gameID)
- syncMonitor.note("session-ping: queued local notification for \(ping.gameID.uuidString)")
+ if ping.kind == .session {
+ NotificationState.recordShown(gameID: ping.gameID)
+ }
+ syncMonitor.note("ping(\(ping.kind.rawValue)): queued local notification for \(ping.gameID.uuidString)")
} catch {
- syncMonitor.note("session-ping: local notification failed — \(error.localizedDescription)")
+ syncMonitor.note("ping(\(ping.kind.rawValue)): local notification failed — \(error.localizedDescription)")
+ }
+ }
+ }
+
+ static func bodyText(for ping: Ping) -> String {
+ let player = ping.playerName.isEmpty ? "A player" : ping.playerName
+ let puzzleSuffix = ping.puzzleTitle.isEmpty ? "the puzzle" : "the puzzle \(ping.puzzleTitle)"
+ switch ping.kind {
+ case .session:
+ return "\(player) is solving \(puzzleSuffix)"
+ case .join:
+ return "\(player) joined \(puzzleSuffix)"
+ case .win:
+ return "\(player) solved \(puzzleSuffix)"
+ case .check:
+ switch ping.scope {
+ case .square: return "\(player) checked a square in \(puzzleSuffix)"
+ case .word: return "\(player) checked a word in \(puzzleSuffix)"
+ case .puzzle, .none: return "\(player) checked \(puzzleSuffix)"
+ }
+ case .reveal:
+ switch ping.scope {
+ case .square: return "\(player) revealed a square in \(puzzleSuffix)"
+ case .word: return "\(player) revealed a word in \(puzzleSuffix)"
+ case .puzzle, .none: return "\(player) revealed \(puzzleSuffix)"
}
}
}
diff --git a/Crossmate/Services/CloudService.swift b/Crossmate/Services/CloudService.swift
@@ -12,6 +12,11 @@ final class CloudService {
private let syncMonitor: SyncMonitor
private let store: GameStore
+ /// Fired after a successful share acceptance once the shared zone has been
+ /// fetched. Used to enqueue a `.join` ping so other collaborators are
+ /// notified that someone has joined the puzzle.
+ var onShareJoined: ((UUID) async -> Void)?
+
init(
container: CKContainer,
syncEngine: SyncEngine,
@@ -53,6 +58,9 @@ final class CloudService {
object: nil,
userInfo: joinedGameID.map { ["gameID": $0] }
)
+ if let joinedGameID, let onShareJoined {
+ await onShareJoined(joinedGameID)
+ }
} catch {
syncMonitor.recordError("acceptShare", error)
}
diff --git a/Crossmate/Sync/MoveBuffer.swift b/Crossmate/Sync/MoveBuffer.swift
@@ -45,14 +45,14 @@ actor MoveBuffer {
/// replace the pending value without flushing.
private var lastCell: Key?
private var debounceTask: Task<Void, Never>?
- /// Per-game timestamp of the last SessionPing fired. The first
+ /// Per-game timestamp of the last session ping fired. The first
/// `enqueue` for a game with no entry — or one stale beyond
/// `sessionPingStaleInterval` — counts as a new session and fires a ping.
private var lastSessionPingAt: [UUID: Date] = [:]
init(
debounceInterval: Duration = .milliseconds(1500),
- sessionPingStaleInterval: TimeInterval = 30 * 60,
+ sessionPingStaleInterval: TimeInterval = 20 * 60,
persistence: PersistenceController,
sink: @escaping @Sendable ([Move]) async -> Void,
afterFlush: (@Sendable (Set<UUID>) async -> Void)? = nil,
diff --git a/Crossmate/Sync/RecordSerializer.swift b/Crossmate/Sync/RecordSerializer.swift
@@ -51,15 +51,15 @@ enum RecordSerializer {
"player-\(gameID.uuidString)-\(authorID)"
}
- /// One SessionPing record per session-start. The session-start timestamp
- /// (ms since epoch) makes the name unique across sessions and devices, so
- /// repeated pings from the same author for the same game don't collide.
+ /// 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.
static func recordName(
- forSessionPingInGame gameID: UUID,
+ forPingInGame gameID: UUID,
authorID: String,
- sessionStartMs: Int64
+ eventTimestampMs: Int64
) -> String {
- "sessionping-\(gameID.uuidString)-\(authorID)-\(sessionStartMs)"
+ "ping-\(gameID.uuidString)-\(authorID)-\(eventTimestampMs)"
}
// MARK: - Zone
@@ -156,30 +156,36 @@ enum RecordSerializer {
record["puzzleSource"] = CKAsset(fileURL: url)
}
- /// Builds a freshly-minted SessionPing record. SessionPings are
- /// write-once — they have no Core Data equivalent and no system-fields
- /// archive.
+ /// 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.
- /// - `playerName` and `puzzleTitle` let receivers render the alert body
- /// (e.g. "Alice has updated the puzzle Sunday Crossword").
- static func sessionPingRecord(
+ /// - `playerName` and `puzzleTitle` let receivers render the alert body.
+ /// - `kind` distinguishes session/join/win/check/reveal events.
+ /// - `scope` is set only for check/reveal kinds.
+ static func pingRecord(
gameID: UUID,
authorID: String,
playerName: String,
puzzleTitle: String,
- sessionStartMs: Int64,
+ eventTimestampMs: Int64,
+ kind: PingKind,
+ scope: PingScope?,
zone: CKRecordZone.ID
) -> CKRecord {
let name = recordName(
- forSessionPingInGame: gameID,
+ forPingInGame: gameID,
authorID: authorID,
- sessionStartMs: sessionStartMs
+ eventTimestampMs: eventTimestampMs
)
let recordID = CKRecord.ID(recordName: name, zoneID: zone)
- let record = CKRecord(recordType: "SessionPing", recordID: recordID)
+ let record = CKRecord(recordType: "Ping", recordID: recordID)
record["authorID"] = authorID as CKRecordValue
record["playerName"] = playerName as CKRecordValue
record["puzzleTitle"] = puzzleTitle as CKRecordValue
+ record["kind"] = kind.rawValue as CKRecordValue
+ if let scope {
+ record["scope"] = scope.rawValue as CKRecordValue
+ }
return record
}
diff --git a/Crossmate/Sync/SyncEngine.swift b/Crossmate/Sync/SyncEngine.swift
@@ -16,11 +16,31 @@ extension Notification.Name {
static let playerRosterShouldRefresh = Notification.Name("playerRosterShouldRefresh")
}
-struct SessionPing: Sendable {
+/// What a Ping record represents. Stored as a string in the CKRecord's
+/// `kind` field.
+enum PingKind: String, Sendable {
+ case session
+ case join
+ case win
+ case check
+ case reveal
+}
+
+/// Granularity of a check/reveal action. Stored as a string in the CKRecord's
+/// `scope` field; nil for kinds where it doesn't apply.
+enum PingScope: String, Sendable {
+ case square
+ case word
+ case puzzle
+}
+
+struct Ping: Sendable {
let gameID: UUID
let authorID: String
let playerName: String
let puzzleTitle: String
+ let kind: PingKind
+ let scope: PingScope?
}
/// Owns the CloudKit sync lifecycle via two `CKSyncEngine` instances — one for
@@ -42,17 +62,19 @@ actor SyncEngine {
private var privateEngine: CKSyncEngine?
private var sharedEngine: CKSyncEngine?
- /// In-memory map for SessionPing records pending send. SessionPings have
- /// no Core Data backing — they're write-once-and-forget — so we stash the
- /// minimal data here keyed by record name and look it up in `buildRecord`.
- private var pendingSessionPings: [String: SessionPingPayload] = [:]
+ /// In-memory map for Ping records pending send. Pings have no Core Data
+ /// backing — they're write-once-and-forget — so we stash the minimal data
+ /// here keyed by record name and look it up in `buildRecord`.
+ private var pendingPings: [String: PingPayload] = [:]
- private struct SessionPingPayload {
+ private struct PingPayload {
let gameID: UUID
let authorID: String
let playerName: String
let puzzleTitle: String
- let sessionStartMs: Int64
+ let eventTimestampMs: Int64
+ let kind: PingKind
+ let scope: PingScope?
}
/// Label for the in-flight fetch — surfaced in traces so the diagnostics
@@ -65,7 +87,7 @@ actor SyncEngine {
private var loggedFirstSharedPushPayload = false
private var onRemoteMoves: (@MainActor @Sendable ([Move]) async -> Void)?
- private var onSessionPings: (@MainActor @Sendable ([SessionPing]) async -> Void)?
+ private var onPings: (@MainActor @Sendable ([Ping]) async -> Void)?
private var onAccountChange: (@MainActor @Sendable () async -> Void)?
private var onGameAccessRevoked: (@MainActor @Sendable (UUID) async -> Void)?
private var onSnapshotsSaved: (@MainActor @Sendable ([String]) async -> Void)?
@@ -79,8 +101,8 @@ actor SyncEngine {
onRemoteMoves = cb
}
- func setOnSessionPings(_ cb: @MainActor @Sendable @escaping ([SessionPing]) async -> Void) {
- onSessionPings = cb
+ func setOnPings(_ cb: @MainActor @Sendable @escaping ([Ping]) async -> Void) {
+ onPings = cb
}
func setOnAccountChange(_ cb: @MainActor @Sendable @escaping () async -> Void) {
@@ -235,11 +257,17 @@ actor SyncEngine {
engine.state.add(pendingRecordZoneChanges: [.saveRecord(recordID)])
}
- /// Registers a SessionPing record as a pending send. Called by
- /// `MoveBuffer` on the first move of a session. Sender-only state — the
- /// payload is stashed in `pendingSessionPings` and only used to build the
- /// outgoing `CKRecord`; nothing is persisted.
- func enqueueSessionPing(gameID: UUID, authorID: String, playerName: String) {
+ /// Registers a Ping record as a pending send. Pings cover session-start,
+ /// join, win, check, and reveal events; sender-only state — the payload is
+ /// stashed in `pendingPings` and only used to build the outgoing
+ /// `CKRecord`; nothing is persisted.
+ func enqueuePing(
+ kind: PingKind,
+ scope: PingScope?,
+ gameID: UUID,
+ authorID: String,
+ playerName: String
+ ) {
let ctx = persistence.container.newBackgroundContext()
let zoneAndTitle: (info: ZoneInfo, title: String)? = ctx.performAndWait {
guard let info = self.zoneInfo(forGameID: gameID, in: ctx) else { return nil }
@@ -252,18 +280,20 @@ actor SyncEngine {
guard let zoneAndTitle else { return }
let engine = zoneAndTitle.info.scope == 1 ? sharedEngine : privateEngine
guard let engine else { return }
- let sessionStartMs = Int64(Date().timeIntervalSince1970 * 1000)
+ let eventTimestampMs = Int64(Date().timeIntervalSince1970 * 1000)
let recordName = RecordSerializer.recordName(
- forSessionPingInGame: gameID,
+ forPingInGame: gameID,
authorID: authorID,
- sessionStartMs: sessionStartMs
+ eventTimestampMs: eventTimestampMs
)
- pendingSessionPings[recordName] = SessionPingPayload(
+ pendingPings[recordName] = PingPayload(
gameID: gameID,
authorID: authorID,
playerName: playerName,
puzzleTitle: zoneAndTitle.title,
- sessionStartMs: sessionStartMs
+ eventTimestampMs: eventTimestampMs,
+ kind: kind,
+ scope: scope
)
let recordID = CKRecord.ID(recordName: recordName, zoneID: zoneAndTitle.info.zoneID)
engine.state.add(pendingRecordZoneChanges: [.saveRecord(recordID)])
@@ -437,7 +467,7 @@ actor SyncEngine {
/// Extracts the game UUID from any of our record name formats:
/// `game-<UUID>`, `move-<UUID>-…`, `snapshot-<UUID>-…`, `player-<UUID>-…`,
- /// `sessionping-<UUID>-…`.
+ /// `ping-<UUID>-…`.
private nonisolated func gameID(fromRecordName name: String) -> UUID? {
if name.hasPrefix("game-") {
return UUID(uuidString: String(name.dropFirst("game-".count)))
@@ -446,7 +476,7 @@ actor SyncEngine {
if name.hasPrefix("move-") { prefix = "move-" }
else if name.hasPrefix("snapshot-") { prefix = "snapshot-" }
else if name.hasPrefix("player-") { prefix = "player-" }
- else if name.hasPrefix("sessionping-") { prefix = "sessionping-" }
+ else if name.hasPrefix("ping-") { prefix = "ping-" }
else { return nil }
let rest = name.dropFirst(prefix.count)
return UUID(uuidString: String(rest.prefix(36)))
@@ -476,22 +506,24 @@ actor SyncEngine {
/// Builds the `CKRecord` for a pending change. Uses the zone ID already
/// embedded in the `recordID` — set correctly at enqueue time.
- /// `sessionPings` is a snapshot taken from the actor before this is
- /// invoked, since the framework calls back synchronously off-actor.
+ /// `pings` is a snapshot taken from the actor before this is invoked,
+ /// since the framework calls back synchronously off-actor.
private nonisolated func buildRecord(
for recordID: CKRecord.ID,
- sessionPings: [String: SessionPingPayload]
+ pings: [String: PingPayload]
) -> CKRecord? {
let name = recordID.recordName
let zoneID = recordID.zoneID
- if name.hasPrefix("sessionping-") {
- guard let payload = sessionPings[name] else { return nil }
- return RecordSerializer.sessionPingRecord(
+ if name.hasPrefix("ping-") {
+ guard let payload = pings[name] else { return nil }
+ return RecordSerializer.pingRecord(
gameID: payload.gameID,
authorID: payload.authorID,
playerName: payload.playerName,
puzzleTitle: payload.puzzleTitle,
- sessionStartMs: payload.sessionStartMs,
+ eventTimestampMs: payload.eventTimestampMs,
+ kind: payload.kind,
+ scope: payload.scope,
zone: zoneID
)
}
@@ -899,10 +931,10 @@ actor SyncEngine {
}
let ctx = persistence.container.newBackgroundContext()
- let (newMoves, affectedGameIDs, sessionPings): ([Move], Set<UUID>, [SessionPing]) = ctx.performAndWait {
+ let (newMoves, affectedGameIDs, pings): ([Move], Set<UUID>, [Ping]) = ctx.performAndWait {
var moves: [Move] = []
var affected = Set<UUID>()
- var pings: [SessionPing] = []
+ var pings: [Ping] = []
for mod in event.modifications {
let record = mod.record
switch record.recordType {
@@ -925,8 +957,8 @@ actor SyncEngine {
self.applyPlayerRecord(record, in: ctx)
affected.insert(gameID)
}
- case "SessionPing":
- if let ping = Self.parseSessionPingRecord(record) {
+ case "Ping":
+ if let ping = Self.parsePingRecord(record) {
pings.append(ping)
}
default:
@@ -953,8 +985,8 @@ actor SyncEngine {
if let onRemoteMoves, !newMoves.isEmpty {
await onRemoteMoves(newMoves)
}
- if let onSessionPings, !sessionPings.isEmpty {
- await onSessionPings(sessionPings)
+ if let onPings, !pings.isEmpty {
+ await onPings(pings)
}
if !affectedGameIDs.isEmpty {
NotificationCenter.default.post(
@@ -965,11 +997,11 @@ actor SyncEngine {
}
}
- private nonisolated static func parseSessionPingRecord(_ record: CKRecord) -> SessionPing? {
+ private nonisolated static func parsePingRecord(_ record: CKRecord) -> Ping? {
let name = record.recordID.recordName
let gameID: UUID?
- if name.hasPrefix("sessionping-") {
- let rest = name.dropFirst("sessionping-".count)
+ if name.hasPrefix("ping-") {
+ let rest = name.dropFirst("ping-".count)
gameID = UUID(uuidString: String(rest.prefix(36)))
} else if record.recordID.zoneID.zoneName.hasPrefix("game-") {
gameID = UUID(uuidString: String(record.recordID.zoneID.zoneName.dropFirst("game-".count)))
@@ -977,13 +1009,18 @@ actor SyncEngine {
gameID = nil
}
guard let gameID,
- let authorID = record["authorID"] as? String
+ let authorID = record["authorID"] as? String,
+ let kindRaw = record["kind"] as? String,
+ let kind = PingKind(rawValue: kindRaw)
else { return nil }
- return SessionPing(
+ let scope: PingScope? = (record["scope"] as? String).flatMap(PingScope.init(rawValue:))
+ return Ping(
gameID: gameID,
authorID: authorID,
playerName: (record["playerName"] as? String) ?? "",
- puzzleTitle: (record["puzzleTitle"] as? String) ?? ""
+ puzzleTitle: (record["puzzleTitle"] as? String) ?? "",
+ kind: kind,
+ scope: scope
)
}
@@ -1031,8 +1068,8 @@ actor SyncEngine {
)
for record in event.savedRecords {
let name = record.recordID.recordName
- if name.hasPrefix("sessionping-") {
- pendingSessionPings.removeValue(forKey: name)
+ if name.hasPrefix("ping-") {
+ pendingPings.removeValue(forKey: name)
}
}
let ctx = persistence.container.newBackgroundContext()
@@ -1153,10 +1190,10 @@ extension SyncEngine: CKSyncEngineDelegate {
) async -> CKSyncEngine.RecordZoneChangeBatch? {
let pending = syncEngine.state.pendingRecordZoneChanges
guard !pending.isEmpty else { return nil }
- let pingSnapshot = pendingSessionPings
+ let pingSnapshot = pendingPings
return await CKSyncEngine.RecordZoneChangeBatch(pendingChanges: pending) { [weak self] recordID in
guard let self else { return nil }
- return self.buildRecord(for: recordID, sessionPings: pingSnapshot)
+ return self.buildRecord(for: recordID, pings: pingSnapshot)
}
}
}
diff --git a/Shared/NotificationState.swift b/Shared/NotificationState.swift
@@ -6,15 +6,17 @@ import Foundation
/// - `activePuzzleID` — set by the app while the user is viewing a puzzle in
/// the foreground so local notifications for that same puzzle can be skipped.
/// - `shownByGame` — a `[gameID: Date]` map used to debounce repeat
-/// notifications. Once a SessionPing for game X has been shown, further
-/// pings for X within `dedupWindow` are suppressed.
+/// notifications. Once a session ping for game X has been shown, further
+/// session pings for X within `dedupWindow` are suppressed.
enum NotificationState {
static let appGroup = "group.net.inqk.crossmate"
- /// How long after a shown notification subsequent pings for the same game
- /// are demoted. Two hours matches the user's "few hours" intent without
- /// being so long that returning to a long-idle puzzle is silent.
- static let dedupWindow: TimeInterval = 2 * 60 * 60
+ /// How long after a shown session ping subsequent session pings for the
+ /// same game are suppressed. Matches the sender-side quiet threshold so
+ /// the sender's gating dominates and the receiver only acts as a guard
+ /// against multi-device, app-restart, and initial-sync-flood duplicates.
+ /// Only applies to session pings; join/win/check/reveal bypass dedup.
+ static let dedupWindow: TimeInterval = 20 * 60
private static let activeKey = "notif.activePuzzleID"
private static let shownKey = "notif.shownByGame"
@@ -42,10 +44,16 @@ enum NotificationState {
setActivePuzzleID(nil)
}
- /// Returns true if a notification for `gameID` was shown within
- /// `dedupWindow`, or if the user is currently viewing it.
- static func shouldSuppress(gameID: UUID, now: Date = Date()) -> Bool {
- if activePuzzleID() == gameID { return true }
+ /// True if the user is currently viewing the puzzle for `gameID`. Active-
+ /// puzzle suppression applies to all ping kinds — no notifications fire
+ /// while you're already in the puzzle they describe.
+ static func isActive(gameID: UUID) -> Bool {
+ activePuzzleID() == gameID
+ }
+
+ /// True if a session ping for `gameID` was shown within `dedupWindow`.
+ /// Only consulted for `.session` kinds; join/win/check/reveal bypass.
+ static func wasRecentlyShown(gameID: UUID, now: Date = Date()) -> Bool {
let map = shownMap()
guard let last = map[gameID.uuidString] else { return false }
return now.timeIntervalSince1970 - last < dedupWindow
diff --git a/Tests/Unit/NotificationStateTests.swift b/Tests/Unit/NotificationStateTests.swift
@@ -11,10 +11,10 @@ struct NotificationStateTests {
NotificationState.setActivePuzzleID(nil)
NotificationState.setActivePuzzleID(gameID)
- #expect(NotificationState.shouldSuppress(gameID: gameID))
+ #expect(NotificationState.isActive(gameID: gameID))
NotificationState.clearActivePuzzleID(if: gameID)
- #expect(!NotificationState.shouldSuppress(gameID: gameID))
+ #expect(!NotificationState.isActive(gameID: gameID))
}
@Test("Clearing one puzzle does not clear another active puzzle")