commit b1744e82ed6b3914dfcd369edf71d21b6fcb07af
parent f45ecf8db8d937ad9c0e7433253d0c53b9c4c123
Author: Michael Camilleri <[email protected]>
Date: Thu, 21 May 2026 08:29:14 +0900
Notify when a collaborator's play session ends
Crossmate now surfaces a local notification (e.g. 'Alice added 4 letters
and cleared 2 in the puzzle "Tuesday Mini"') when a collaborator stops
typing for a while. The detection is receiver-side and quiescence-based
rather than sender-pushed: each incoming batch of Moves records is
diffed against the merged grid as it stood before the batch, per-cell
transitions are attributed to the LWW-winning writer, and the resulting
(gameID, authorID) deltas feed a running tally. A write older than 180
seconds is treated as cold-launch backfill and ignored so a hard offline
catch-up does not queue a notification for a session that ended hours
ago. Multi-device falls out for free because the bucket is keyed by
author, not device — any of Alice's devices streaming Moves keeps the
timer reset until all of them are quiet.
SessionMonitor holds the per-author buckets and uses
UNTimeIntervalNotificationTrigger with a stable
'session-end-<gameID>-<authorID>' identifier; each new delta re-add()s the
request, collapsing a typing burst into one delivery and surviving a force-quit
because the OS retains pending time-trigger requests. The end-of-session is
cancelled by stronger signals — a .win or .resign ping, a game removal or
access revocation, or the user opening the puzzle — and firing the end also
withdraws any session-begin banner still on screen for that (gameID, authorID)
along with its dedup gate so a fresh session can notify normally. To support
the matrix of begin and end notifications coexisting per (game, author), the
session-begin path stabilises its identifier to
'session-begin-<gameID>-<authorID>', carries the authorID in userInfo, renames
presentSessions to presentSessionBegins, and the begin-side dedup in
NotificationState is rekeyed from gameID to (gameID, authorID) so two
collaborators starting sessions in the same window can both notify. The
receiver-side delta is computed by a new GridStateMerger.mergeWithProvenance
plus authorDeltas helpers in RecordApplier, surfaced from both apply paths
(direct push and CKSyncEngine fetched changes) via a new onRemoteAuthorDelta
callback on SyncEngine.
Tests cover the merged-grid provenance variant, the delta computation
(added/cleared transitions, letter-overwrite-neutral, idempotent re-apply,
cutoff gate, multi-device same-author and per-author aggregation), and
SessionMonitor's accumulation, suppression gate, session rollover after the
trigger elapses, the cancel paths, and end-withdraws-begin. SessionMonitor
gains a small SessionNotificationScheduling protocol so tests can substitute
an in-memory recorder for UNUserNotificationCenter.
Co-Authored-By: Claude Opus 4.7 <[email protected]>
Diffstat:
11 files changed, 1032 insertions(+), 45 deletions(-)
diff --git a/Crossmate.xcodeproj/project.pbxproj b/Crossmate.xcodeproj/project.pbxproj
@@ -40,6 +40,7 @@
4D90B39AD2F79959FB8089EE /* MovesUpdater.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7DD270E16E00145EF2807EA9 /* MovesUpdater.swift */; };
503229FF89FF7C29CEF4C16D /* Puzzle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64C8064F04FC6177D987ACA2 /* Puzzle.swift */; };
54464FDFB8C71B0D3B4B61A2 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74FEFF257CDDD3EF0E77CBF7 /* SettingsView.swift */; };
+ 5ECF5B80D08E5E999A540782 /* SessionMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0EB332831AB173ACF6BFEC59 /* SessionMonitor.swift */; };
6A1CA96FF48CBEEE78EA6D34 /* FriendModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B766E872B12DC79ECCD80941 /* FriendModelTests.swift */; };
6AE88D9E1918508DBF2A91E1 /* NotificationState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D2FD896D75863554E31654C /* NotificationState.swift */; };
6BE7E91158F4DF1F71247C6D /* CellMark.swift in Sources */ = {isa = PBXBuildFile; fileRef = B135C285570F91181595B405 /* CellMark.swift */; };
@@ -72,6 +73,7 @@
AA38A51862FC0AB8F7D34899 /* NYTToXDConverterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C54223FED97577A593B7964E /* NYTToXDConverterTests.swift */; };
AACC9F70AEEDCB3360FFDEFF /* GridStateMergerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C7F3A9BD7FAF81CB77032A6 /* GridStateMergerTests.swift */; };
AB05765D2C3F4841026344E5 /* AboutView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4AF633D73818BD59F759FAC4 /* AboutView.swift */; };
+ AE5D8C531F89F05B7201B3AC /* SessionMonitorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F64DAE64C9AA042B330C526F /* SessionMonitorTests.swift */; };
AF4F1AE2A1F94E92C785C524 /* Square.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB851649DE78AAAC5A928C52 /* Square.swift */; };
B6AB531F4E0C4031B627C539 /* PlayerSelectionPublisher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11BF168D5C1CD85DAE5CAF9E /* PlayerSelectionPublisher.swift */; };
B762200F54C52E8377A80D15 /* NYTToXDConverter.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF6F111BE8750697C4BC7A17 /* NYTToXDConverter.swift */; };
@@ -80,6 +82,7 @@
BE57957589423497338EBD37 /* ShareRoutingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 68072F4F3EB5D5A78E03D408 /* ShareRoutingTests.swift */; };
C1930083671621AC79CF95DD /* MovesUpdaterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9AF6157D97271205626E207C /* MovesUpdaterTests.swift */; };
C1D97A4CD02BC9C22C4208BB /* NYTAuthServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED8154F949E1D94252F70765 /* NYTAuthServiceTests.swift */; };
+ C2D8A9C79D75DBEF45720927 /* AuthorDeltaTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2570F892610F1834C92F7F6E /* AuthorDeltaTests.swift */; };
C30C0C4E54E4209A22843872 /* CrossmateModel.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = F93AC31640C40FCC039570A3 /* CrossmateModel.xcdatamodeld */; };
C7370BCAD585EEFD366204E3 /* GridThumbnailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D9BB7D9759D27F7BA6734FDE /* GridThumbnailView.swift */; };
C89A15D812E372FE1C56039B /* PUZToXDConverterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE193CAB325C991952D7CE5 /* PUZToXDConverterTests.swift */; };
@@ -128,6 +131,7 @@
07E5E4B165E374FEE732068B /* CloudDiagnostics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudDiagnostics.swift; sourceTree = "<group>"; };
0BF60C84D92A9024AC1A53FC /* Media.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Media.xcassets; sourceTree = "<group>"; };
0C0A7348E1283E7CD2486E2A /* RecordSerializer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecordSerializer.swift; sourceTree = "<group>"; };
+ 0EB332831AB173ACF6BFEC59 /* SessionMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionMonitor.swift; sourceTree = "<group>"; };
11BF168D5C1CD85DAE5CAF9E /* PlayerSelectionPublisher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerSelectionPublisher.swift; sourceTree = "<group>"; };
14B05C19BD4705876B3DF0EC /* GridStateMerger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GridStateMerger.swift; sourceTree = "<group>"; };
14F2AC5C3B50F4178859E9AC /* CrossmateApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CrossmateApp.swift; sourceTree = "<group>"; };
@@ -136,6 +140,7 @@
1813630FA05C194AFF43855C /* PlayerRosterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerRosterTests.swift; sourceTree = "<group>"; };
1F2BE43E18B1CC6AAD27DC6D /* NYTBrowseView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NYTBrowseView.swift; sourceTree = "<group>"; };
20B331CC55827FEF3420ABCE /* PlayerSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerSession.swift; sourceTree = "<group>"; };
+ 2570F892610F1834C92F7F6E /* AuthorDeltaTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthorDeltaTests.swift; sourceTree = "<group>"; };
26397B9DBC57DCF7B58899D4 /* BuildNumber.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = BuildNumber.xcconfig; sourceTree = "<group>"; };
283C5C97180C805B6C5BF622 /* PerGameZoneTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PerGameZoneTests.swift; sourceTree = "<group>"; };
2D2FD896D75863554E31654C /* NotificationState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationState.swift; sourceTree = "<group>"; };
@@ -227,6 +232,7 @@
EE3412F437AABD2988B6976D /* FriendPickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FriendPickerView.swift; sourceTree = "<group>"; };
EF1254FE7BE3672AEC1607B1 /* MovesInboundTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MovesInboundTests.swift; sourceTree = "<group>"; };
F2F7D62E5E9EE2AEFC8940F4 /* NewGameSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewGameSheet.swift; sourceTree = "<group>"; };
+ F64DAE64C9AA042B330C526F /* SessionMonitorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionMonitorTests.swift; sourceTree = "<group>"; };
F8E50E7BA98C88B4CAB39DC1 /* CellView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CellView.swift; sourceTree = "<group>"; };
F91707302CBA406BF7FB8F8A /* FriendAvatarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FriendAvatarView.swift; sourceTree = "<group>"; };
F97B399E89BBB37730F2F1E9 /* TestHelpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestHelpers.swift; sourceTree = "<group>"; };
@@ -262,6 +268,7 @@
BD63A9B20168F3B81AF4348F /* RecordApplier.swift */,
5267DDA1A330DCBD07303D44 /* RecordBuilder.swift */,
0C0A7348E1283E7CD2486E2A /* RecordSerializer.swift */,
+ 0EB332831AB173ACF6BFEC59 /* SessionMonitor.swift */,
5C74683332956B0D1CA37589 /* ShareController.swift */,
73DDDED719CFFDD6035C3B48 /* SyncEngine.swift */,
9A49C3C31F49A85764B84A15 /* SyncState+Helpers.swift */,
@@ -404,6 +411,7 @@
ABB371EF2574E95782CB05FD /* Sync */ = {
isa = PBXGroup;
children = (
+ 2570F892610F1834C92F7F6E /* AuthorDeltaTests.swift */,
457B06DBFDC358D213A7CE54 /* AuthorIdentityTests.swift */,
94CEBA27A8AC4FCC92ADE1B4 /* EnsureGameEntityTests.swift */,
B766E872B12DC79ECCD80941 /* FriendModelTests.swift */,
@@ -411,6 +419,7 @@
EF1254FE7BE3672AEC1607B1 /* MovesInboundTests.swift */,
BAEDA3C3765CD8D8897FE5D5 /* PendingChangeReapTests.swift */,
283C5C97180C805B6C5BF622 /* PerGameZoneTests.swift */,
+ F64DAE64C9AA042B330C526F /* SessionMonitorTests.swift */,
68072F4F3EB5D5A78E03D408 /* ShareRoutingTests.swift */,
A9A01534A21796A4EC7113A9 /* ZoneOrphaningTests.swift */,
);
@@ -544,6 +553,7 @@
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
+ C2D8A9C79D75DBEF45720927 /* AuthorDeltaTests.swift in Sources */,
A98382E7659991FAF0F4ED0A /* AuthorIdentityTests.swift in Sources */,
02943BA53D2130B910E6DC00 /* EnsureGameEntityTests.swift in Sources */,
6A1CA96FF48CBEEE78EA6D34 /* FriendModelTests.swift in Sources */,
@@ -570,6 +580,7 @@
7FCD3F582B5ADC235E1F88A0 /* PuzzleNotificationTextTests.swift in Sources */,
89CEDB8864F61E42AC04F9D6 /* RecordSerializerMovesTests.swift in Sources */,
ECC1A5C3623F50B67185CFFB /* RecordSerializerTests.swift in Sources */,
+ AE5D8C531F89F05B7201B3AC /* SessionMonitorTests.swift in Sources */,
BE57957589423497338EBD37 /* ShareRoutingTests.swift in Sources */,
4819D7FBB407C9D76510EA2A /* TestHelpers.swift in Sources */,
31F2B6A61ED352C7D800149F /* XDAcceptTests.swift in Sources */,
@@ -645,6 +656,7 @@
D13ECFAE05DB508577D2FF66 /* RecordBuilder.swift in Sources */,
D5150033DB80810F93BE0B5F /* RecordEditorView.swift in Sources */,
CF0CA17ABE211DAE4DD35AFD /* RecordSerializer.swift in Sources */,
+ 5ECF5B80D08E5E999A540782 /* SessionMonitor.swift in Sources */,
54464FDFB8C71B0D3B4B61A2 /* SettingsView.swift in Sources */,
BCB9A4D5E06EE5006186465D /* ShareController.swift in Sources */,
AF4F1AE2A1F94E92C785C524 /* Square.swift in Sources */,
diff --git a/Crossmate/CrossmateApp.swift b/Crossmate/CrossmateApp.swift
@@ -96,9 +96,9 @@ final class AppDelegate: NSObject, UIApplicationDelegate, @preconcurrency UNUser
completionHandler([.banner, .sound])
return
}
- let isSession = (userInfo["crossmateActivity"] as? String) == "session"
- if isSession {
- NotificationState.recordShown(gameID: gameID)
+ let isSessionBegin = (userInfo["crossmateActivity"] as? String) == "session-begin"
+ if isSessionBegin, let authorID = userInfo["crossmateAuthorID"] as? String {
+ NotificationState.recordShown(gameID: gameID, authorID: authorID)
}
if NotificationState.isSuppressed(gameID: gameID) {
completionHandler([])
@@ -544,6 +544,11 @@ private struct PuzzleDisplayView: View {
private func updateActiveNotificationPuzzleID(for phase: ScenePhase) {
if phase == .active {
NotificationState.setActivePuzzleID(gameID)
+ // Opening the puzzle supersedes any pending end-of-session
+ // summary — the user is about to see the moves directly.
+ let id = gameID
+ let sessionMonitor = services.sessionMonitor
+ Task { await sessionMonitor.cancel(gameID: id) }
} else {
NotificationState.clearActivePuzzleID(if: gameID)
}
diff --git a/Crossmate/Services/AppServices.swift b/Crossmate/Services/AppServices.swift
@@ -51,6 +51,7 @@ final class AppServices {
let nytFetcher: NYTPuzzleFetcher
let inputMonitor: InputMonitor
let movesUpdater: MovesUpdater
+ let sessionMonitor: SessionMonitor
let playerSelectionPublisher: PlayerSelectionPublisher
let identity: AuthorIdentity
let shareController: ShareController
@@ -146,6 +147,11 @@ final class AppServices {
)
self.movesUpdater = movesUpdater
+ self.sessionMonitor = SessionMonitor(
+ persistence: persistence,
+ localAuthorIDProvider: { await MainActor.run { identity.currentID } }
+ )
+
let cursorStore = GameCursorStore()
self.cursorStore = cursorStore
let onGameDeletedHandler = Self.makeOnGameDeleted(
@@ -248,6 +254,10 @@ final class AppServices {
identity.currentID
}
+ await syncEngine.setOnRemoteAuthorDelta { [sessionMonitor] deltas in
+ await sessionMonitor.ingest(deltas)
+ }
+
await syncEngine.setOnRemoteMovesUpdated { [weak self, store, identity] gameIDs in
store.noteIncomingMovesUpdate(
gameIDs: gameIDs,
@@ -288,12 +298,14 @@ final class AppServices {
await self.identity.refresh(using: self.ckContainer)
}
- await syncEngine.setOnGameAccessRevoked { [store] gameID in
+ await syncEngine.setOnGameAccessRevoked { [store, sessionMonitor] gameID in
store.markAccessRevoked(gameID: gameID)
+ await sessionMonitor.cancel(gameID: gameID)
}
- await syncEngine.setOnGameRemoved { [store] gameID in
+ await syncEngine.setOnGameRemoved { [store, sessionMonitor] gameID in
store.handleRemoteRemoval(gameID: gameID)
+ await sessionMonitor.cancel(gameID: gameID)
}
// A sibling device consumed (deleted) a directed ping; withdraw any
@@ -746,7 +758,7 @@ final class AppServices {
}
if let result {
await presentPings(result.0)
- await presentSessions(result.1)
+ await presentSessionBegins(result.1)
}
scheduleBackgroundPushCatchUp(scope: scope)
await refreshSnapshot()
@@ -1198,6 +1210,13 @@ final class AppServices {
private func presentPings(_ pings: [Ping]) async {
guard !pings.isEmpty else { return }
+ // Cancel any pending end-of-session summary that a stronger signal
+ // (the author solved or gave up) is about to supersede. Runs before
+ // notification authorization is checked so the cancel happens even
+ // when the system-only `.friend` ping path skips presentation.
+ for ping in pings where ping.kind == .win || ping.kind == .resign {
+ await sessionMonitor.cancel(gameID: ping.gameID, authorID: ping.authorID)
+ }
applyInvitePings(pings)
// `.friend` is the friendship-bootstrap handshake — system-only, no
// alert, runs without notification authorization. Everything else
@@ -1280,12 +1299,12 @@ final class AppServices {
}
}
- private func presentSessions(
+ private func presentSessionBegins(
_ sessions: [Session]
) async {
guard !sessions.isEmpty else { return }
guard await canPresentNotifications() else {
- syncMonitor.note("session: local notification skipped — authorization not granted")
+ syncMonitor.note("session-begin: local notification skipped — authorization not granted")
return
}
@@ -1312,21 +1331,21 @@ final class AppServices {
let center = UNUserNotificationCenter.current()
for session in sessions {
if session.authorID == identity.currentID {
- syncMonitor.note("session: skipped self-authored record \(session.recordName)")
+ syncMonitor.note("session-begin: skipped self-authored record \(session.recordName)")
continue
}
if completedByAuthor.contains("\(session.gameID.uuidString)|\(session.authorID)") {
- syncMonitor.note("session: suppressed — author completed \(session.gameID.uuidString)")
+ syncMonitor.note("session-begin: suppressed — author completed \(session.gameID.uuidString)")
continue
}
if NotificationState.isSuppressed(gameID: session.gameID) {
- NotificationState.recordShown(gameID: session.gameID)
- syncMonitor.note("session: suppressed — puzzle is active for \(session.gameID.uuidString)")
+ NotificationState.recordShown(gameID: session.gameID, authorID: session.authorID)
+ syncMonitor.note("session-begin: suppressed — puzzle is active for \(session.gameID.uuidString)")
continue
}
- if NotificationState.wasRecentlyShown(gameID: session.gameID) {
- NotificationState.recordShown(gameID: session.gameID)
- syncMonitor.note("session: dedup-suppressed for \(session.gameID.uuidString)")
+ if NotificationState.wasRecentlyShown(gameID: session.gameID, authorID: session.authorID) {
+ NotificationState.recordShown(gameID: session.gameID, authorID: session.authorID)
+ syncMonitor.note("session-begin: dedup-suppressed for \(session.gameID.uuidString) author=\(session.authorID)")
continue
}
@@ -1336,20 +1355,21 @@ final class AppServices {
content.sound = .default
content.userInfo = [
"crossmateGameID": session.gameID.uuidString,
- "crossmateActivity": "session"
+ "crossmateAuthorID": session.authorID,
+ "crossmateActivity": "session-begin"
]
let request = UNNotificationRequest(
- identifier: "session-\(session.gameID.uuidString)-\(UUID().uuidString)",
+ identifier: "session-begin-\(session.gameID.uuidString)-\(session.authorID)",
content: content,
trigger: nil
)
do {
try await center.add(request)
- NotificationState.recordShown(gameID: session.gameID)
- syncMonitor.note("session: queued local notification for \(session.gameID.uuidString)")
+ NotificationState.recordShown(gameID: session.gameID, authorID: session.authorID)
+ syncMonitor.note("session-begin: queued local notification for \(session.gameID.uuidString) author=\(session.authorID)")
} catch {
- syncMonitor.note("session: local notification failed — \(error.localizedDescription)")
+ syncMonitor.note("session-begin: local notification failed — \(error.localizedDescription)")
}
}
}
diff --git a/Crossmate/Sync/GridStateMerger.swift b/Crossmate/Sync/GridStateMerger.swift
@@ -10,6 +10,40 @@ import Foundation
enum GridStateMerger {
static func merge(_ moves: [MovesValue]) -> GridState {
+ var grid: GridState = [:]
+ for (position, winner) in winners(moves) {
+ grid[position] = GridCell(
+ letter: winner.cell.letter,
+ markKind: winner.cell.markKind,
+ checkedWrong: winner.cell.checkedWrong,
+ authorID: winner.cell.authorID
+ )
+ }
+ return grid
+ }
+
+ /// Merge variant that preserves the *writer* (the iCloud user whose
+ /// `MovesValue` won LWW for each cell) and the raw `TimestampedCell`,
+ /// including `updatedAt`. Cells whose only writes are empty letters are
+ /// retained — SessionMonitor needs them to detect clears against a
+ /// before-snapshot.
+ static func mergeWithProvenance(_ moves: [MovesValue]) -> [GridPosition: Provenance] {
+ var result: [GridPosition: Provenance] = [:]
+ for (position, winner) in winners(moves) {
+ result[position] = Provenance(
+ cell: winner.cell,
+ writerAuthorID: winner.writerAuthorID
+ )
+ }
+ return result
+ }
+
+ struct Provenance: Equatable {
+ var cell: TimestampedCell
+ var writerAuthorID: String
+ }
+
+ private static func winners(_ moves: [MovesValue]) -> [GridPosition: Winner] {
var winners: [GridPosition: Winner] = [:]
for view in moves {
for (position, cell) in view.cells {
@@ -27,17 +61,7 @@ enum GridStateMerger {
}
}
}
-
- var grid: GridState = [:]
- for (position, winner) in winners {
- grid[position] = GridCell(
- letter: winner.cell.letter,
- markKind: winner.cell.markKind,
- checkedWrong: winner.cell.checkedWrong,
- authorID: winner.cell.authorID
- )
- }
- return grid
+ return winners
}
private struct Winner {
diff --git a/Crossmate/Sync/RecordApplier.swift b/Crossmate/Sync/RecordApplier.swift
@@ -2,6 +2,20 @@ import CloudKit
import CoreData
import Foundation
+/// One game's worth of remote-driven cell churn attributed to a single
+/// writer. Surfaced from the receiver-side apply path so SessionMonitor can
+/// accumulate per-author tallies and (re)schedule end-of-session
+/// notifications. `latestUpdate` is the wall-clock timestamp of the most
+/// recent winning cell write in this batch — used to schedule the
+/// quiescence trigger relative to the actual typing, not to batch arrival.
+struct AuthorDelta: Sendable {
+ let gameID: UUID
+ let authorID: String
+ let added: Int
+ let cleared: Int
+ let latestUpdate: Date
+}
+
extension SyncEngine {
func applyDirectRecordZoneChanges(
records: [CKRecord],
@@ -12,12 +26,22 @@ extension SyncEngine {
let ctx = persistence.container.newBackgroundContext()
ctx.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump
let localAuthorID = await currentLocalAuthorID()
- let (movesUpdatedGameIDs, affectedGameIDs, playersUpdatedGameIDs, readCursors):
- (Set<UUID>, Set<UUID>, Set<UUID>, [(UUID, Date)]) = ctx.performAndWait {
+ let (movesUpdatedGameIDs, affectedGameIDs, playersUpdatedGameIDs, readCursors, authorDeltas):
+ (Set<UUID>, Set<UUID>, Set<UUID>, [(UUID, Date)], [AuthorDelta]) = ctx.performAndWait {
var movesUpdated = Set<UUID>()
var affected = Set<UUID>()
var playersUpdated = Set<UUID>()
var read: [(UUID, Date)] = []
+ // Pre-pass: snapshot the merged grid for every game that has a
+ // Moves record in this batch, so the post-apply diff can attribute
+ // each cell transition to the LWW-winning writer.
+ let movesBearingGameIDs: Set<UUID> = Set(records.compactMap { record -> UUID? in
+ guard record.recordType == "Moves",
+ let (gameID, _, _) = RecordSerializer.parseMovesRecordName(record.recordID.recordName)
+ else { return nil }
+ return gameID
+ })
+ let beforeGrids = Self.gridSnapshots(for: movesBearingGameIDs, in: ctx)
for record in records {
switch record.recordType {
case "Game":
@@ -62,6 +86,12 @@ extension SyncEngine {
for gameID in movesUpdated {
self.replayCellCache(for: gameID, in: ctx)
}
+ let afterGrids = Self.gridSnapshots(for: movesBearingGameIDs, in: ctx)
+ let deltas = Self.authorDeltas(
+ before: beforeGrids,
+ after: afterGrids,
+ cutoff: Date().addingTimeInterval(-SessionMonitor.quiescenceWindow)
+ )
if ctx.hasChanges {
do {
try ctx.save()
@@ -74,12 +104,15 @@ extension SyncEngine {
)
}
}
- return (movesUpdated, affected, playersUpdated, read)
+ return (movesUpdated, affected, playersUpdated, read, deltas)
}
if let onRemoteMovesUpdated, !movesUpdatedGameIDs.isEmpty {
await onRemoteMovesUpdated(movesUpdatedGameIDs)
}
+ if let onRemoteAuthorDelta, !authorDeltas.isEmpty {
+ await onRemoteAuthorDelta(authorDeltas)
+ }
if let onRemotePlayersUpdated, !playersUpdatedGameIDs.isEmpty {
await onRemotePlayersUpdated(playersUpdatedGameIDs)
}
@@ -252,6 +285,91 @@ extension SyncEngine {
)
}
+ /// Computes the merged-grid provenance snapshot for each game. Called
+ /// twice during a Moves batch apply — once before any apply, once after
+ /// `replayCellCache` runs — so the diff between the two yields per-cell
+ /// transitions attributed to the LWW-winning writer.
+ nonisolated static func gridSnapshots(
+ for gameIDs: Set<UUID>,
+ in ctx: NSManagedObjectContext
+ ) -> [UUID: [GridPosition: GridStateMerger.Provenance]] {
+ var result: [UUID: [GridPosition: GridStateMerger.Provenance]] = [:]
+ for gameID in gameIDs {
+ let gameReq = NSFetchRequest<GameEntity>(entityName: "GameEntity")
+ gameReq.predicate = NSPredicate(format: "id == %@", gameID as CVarArg)
+ gameReq.fetchLimit = 1
+ guard let game = try? ctx.fetch(gameReq).first else {
+ result[gameID] = [:]
+ continue
+ }
+ let movesReq = NSFetchRequest<MovesEntity>(entityName: "MovesEntity")
+ movesReq.predicate = NSPredicate(format: "game == %@", game)
+ let movesEntities = (try? ctx.fetch(movesReq)) ?? []
+ let values: [MovesValue] = movesEntities.compactMap { movesValue(from: $0) }
+ result[gameID] = GridStateMerger.mergeWithProvenance(values)
+ }
+ return result
+ }
+
+ /// Aggregates the merged-grid diff per `(gameID, writerAuthorID)`. Counts
+ /// empty→letter transitions as `added` and letter→empty as `cleared`;
+ /// letter→different-letter (one author rewriting over another's cell)
+ /// contributes zero. Cells whose winning entry was written before
+ /// `cutoff` are ignored — older entries can't represent a currently
+ /// active session and would mis-fire on cold-launch backfill of an old
+ /// record stream.
+ nonisolated static func authorDeltas(
+ before: [UUID: [GridPosition: GridStateMerger.Provenance]],
+ after: [UUID: [GridPosition: GridStateMerger.Provenance]],
+ cutoff: Date
+ ) -> [AuthorDelta] {
+ struct Key: Hashable {
+ let gameID: UUID
+ let authorID: String
+ }
+ var byKey: [Key: (added: Int, cleared: Int, latest: Date)] = [:]
+ let allGameIDs = Set(before.keys).union(after.keys)
+ for gameID in allGameIDs {
+ let b = before[gameID] ?? [:]
+ let a = after[gameID] ?? [:]
+ let positions = Set(b.keys).union(a.keys)
+ for pos in positions {
+ let beforeLetter = b[pos]?.cell.letter ?? ""
+ let afterEntry = a[pos]
+ let afterLetter = afterEntry?.cell.letter ?? ""
+ // Attribute to the LWW-winning writer in the post-apply
+ // snapshot; if the cell vanished (no MovesEntity for it
+ // anymore — only via record deletion), fall back to the
+ // pre-apply writer.
+ let writer = afterEntry?.writerAuthorID ?? b[pos]?.writerAuthorID ?? ""
+ guard !writer.isEmpty else { continue }
+ let stamp = afterEntry?.cell.updatedAt ?? b[pos]?.cell.updatedAt ?? .distantPast
+ guard stamp >= cutoff else { continue }
+ let key = Key(gameID: gameID, authorID: writer)
+ if beforeLetter.isEmpty, !afterLetter.isEmpty {
+ var bucket = byKey[key] ?? (0, 0, .distantPast)
+ bucket.added += 1
+ if stamp > bucket.latest { bucket.latest = stamp }
+ byKey[key] = bucket
+ } else if !beforeLetter.isEmpty, afterLetter.isEmpty {
+ var bucket = byKey[key] ?? (0, 0, .distantPast)
+ bucket.cleared += 1
+ if stamp > bucket.latest { bucket.latest = stamp }
+ byKey[key] = bucket
+ }
+ }
+ }
+ return byKey.map { key, value in
+ AuthorDelta(
+ gameID: key.gameID,
+ authorID: key.authorID,
+ added: value.added,
+ cleared: value.cleared,
+ latestUpdate: value.latest
+ )
+ }
+ }
+
nonisolated func applyDeletion(
recordID: CKRecord.ID,
recordType: CKRecord.RecordType,
diff --git a/Crossmate/Sync/SessionMonitor.swift b/Crossmate/Sync/SessionMonitor.swift
@@ -0,0 +1,249 @@
+import CoreData
+import Foundation
+import UserNotifications
+
+/// Indirection over the slice of `UNUserNotificationCenter` SessionMonitor
+/// uses, so tests can substitute an in-memory recorder. The production
+/// implementation is the `UNUserNotificationCenter` extension below.
+protocol SessionNotificationScheduling: Sendable {
+ func add(_ request: UNNotificationRequest) async throws
+ func removePendingNotificationRequests(withIdentifiers identifiers: [String])
+ func removeDeliveredNotifications(withIdentifiers identifiers: [String])
+}
+
+extension UNUserNotificationCenter: SessionNotificationScheduling {}
+
+/// Receiver-side observer that turns a stream of remote cell deltas
+/// (`AuthorDelta`) into one end-of-session local notification per
+/// `(gameID, authorID)`. Each delta bumps an in-memory running tally and
+/// (re)schedules a `UNNotificationRequest` with a stable identifier and a
+/// quiescence-window time trigger — so a quick burst of incoming Moves
+/// records collapses to one delivery, and a force-quit between deltas
+/// still fires the last-scheduled summary (the OS holds time-trigger
+/// requests even when the app is not running).
+///
+/// Multi-device collaborators fall out for free: the bucket is keyed by
+/// the writing iCloud author, not the device, so any of Alice's devices
+/// streaming Moves resets the timer until *all* of her devices are quiet.
+actor SessionMonitor {
+ /// Wall-clock window of inactivity before the end-of-session
+ /// notification fires. Also used by `RecordApplier.authorDeltas` to gate
+ /// stale cell writes out of cold-launch backfill so they don't queue a
+ /// notification for a session that ended hours ago.
+ static let quiescenceWindow: TimeInterval = 180
+
+ private struct Key: Hashable {
+ let gameID: UUID
+ let authorID: String
+ }
+
+ private struct Bucket {
+ var added: Int
+ var cleared: Int
+ /// Wall-clock time at which the currently-scheduled
+ /// `UNNotificationRequest` will fire. On the next note() this is
+ /// compared against `now` — if the trigger has already elapsed, the
+ /// tally is treated as consumed and the new delta starts a fresh
+ /// session.
+ var scheduledFor: Date
+ }
+
+ private var buckets: [Key: Bucket] = [:]
+ private let persistence: PersistenceController
+ private let notificationCenter: SessionNotificationScheduling
+ private let nameLookup: @Sendable (UUID, String) async -> (playerName: String, puzzleTitle: String)
+ private let suppressionGate: @Sendable (UUID) -> Bool
+ private let localAuthorIDProvider: @Sendable () async -> String?
+ private let clock: @Sendable () -> Date
+
+ init(
+ persistence: PersistenceController,
+ localAuthorIDProvider: @escaping @Sendable () async -> String?,
+ notificationCenter: SessionNotificationScheduling = UNUserNotificationCenter.current(),
+ nameLookup: (@Sendable (UUID, String) async -> (playerName: String, puzzleTitle: String))? = nil,
+ suppressionGate: @escaping @Sendable (UUID) -> Bool = { NotificationState.isSuppressed(gameID: $0) },
+ clock: @escaping @Sendable () -> Date = { Date() }
+ ) {
+ self.persistence = persistence
+ self.localAuthorIDProvider = localAuthorIDProvider
+ self.notificationCenter = notificationCenter
+ self.suppressionGate = suppressionGate
+ self.clock = clock
+ if let nameLookup {
+ self.nameLookup = nameLookup
+ } else {
+ self.nameLookup = { gameID, authorID in
+ await Self.coreDataNameLookup(
+ persistence: persistence,
+ gameID: gameID,
+ authorID: authorID
+ )
+ }
+ }
+ }
+
+ /// Folds one batch of receiver-side deltas into the per-author tallies
+ /// and reschedules each affected end-of-session notification. Drops
+ /// self-authored deltas (sibling device of the same iCloud account) and
+ /// deltas for puzzles the user is currently viewing.
+ func ingest(_ deltas: [AuthorDelta]) async {
+ guard !deltas.isEmpty else { return }
+ let localAuthorID = await localAuthorIDProvider()
+ for delta in deltas {
+ if delta.added == 0 && delta.cleared == 0 { continue }
+ if delta.authorID == localAuthorID { continue }
+ if suppressionGate(delta.gameID) { continue }
+ let key = Key(gameID: delta.gameID, authorID: delta.authorID)
+ let now = clock()
+ var bucket = buckets[key] ?? Bucket(added: 0, cleared: 0, scheduledFor: now)
+ // Prior trigger has already fired (or never existed) — start a
+ // fresh session rather than accumulating into the old summary.
+ if now >= bucket.scheduledFor {
+ bucket = Bucket(added: 0, cleared: 0, scheduledFor: now)
+ }
+ bucket.added += delta.added
+ bucket.cleared += delta.cleared
+ // Schedule the trigger relative to the latest typing time, not
+ // batch arrival, so a late-arriving batch doesn't push the
+ // notification further into the future than it should be.
+ let scheduledFor = max(
+ now.addingTimeInterval(1),
+ delta.latestUpdate.addingTimeInterval(Self.quiescenceWindow)
+ )
+ bucket.scheduledFor = scheduledFor
+ buckets[key] = bucket
+ await scheduleEnd(key: key, bucket: bucket, now: now)
+ }
+ }
+
+ /// Withdraws the pending end-of-session notification and tally for
+ /// `(gameID, authorID)`. Pass `authorID == nil` to drop every author's
+ /// bucket for the game — used when the user opens the puzzle, a `.win`
+ /// or `.resign` ping arrives, or the Game gains a `completedBy` (the
+ /// summary is superseded by a stronger signal).
+ func cancel(gameID: UUID, authorID: String? = nil) async {
+ let keysToCancel: [Key] = buckets.keys.filter { key in
+ key.gameID == gameID && (authorID == nil || key.authorID == authorID)
+ }
+ guard !keysToCancel.isEmpty else { return }
+ for key in keysToCancel {
+ buckets.removeValue(forKey: key)
+ }
+ let identifiers = keysToCancel.map(Self.endIdentifier(for:))
+ notificationCenter.removePendingNotificationRequests(withIdentifiers: identifiers)
+ notificationCenter.removeDeliveredNotifications(withIdentifiers: identifiers)
+ }
+
+ /// Number of distinct `(gameID, authorID)` buckets currently being
+ /// accumulated. Exposed for tests; not part of the runtime API.
+ var pendingBucketCount: Int { buckets.count }
+
+ /// Snapshot of the running tally for `(gameID, authorID)`, if any.
+ /// Exposed for tests; not part of the runtime API.
+ func tally(gameID: UUID, authorID: String) -> (added: Int, cleared: Int, scheduledFor: Date)? {
+ guard let bucket = buckets[Key(gameID: gameID, authorID: authorID)] else { return nil }
+ return (bucket.added, bucket.cleared, bucket.scheduledFor)
+ }
+
+ private func scheduleEnd(key: Key, bucket: Bucket, now: Date) async {
+ let (playerName, puzzleTitle) = await nameLookup(key.gameID, key.authorID)
+ let content = UNMutableNotificationContent()
+ content.title = "Crossmate"
+ content.body = Self.bodyText(
+ playerName: playerName,
+ puzzleTitle: puzzleTitle,
+ added: bucket.added,
+ cleared: bucket.cleared
+ )
+ content.sound = .default
+ content.userInfo = [
+ "crossmateGameID": key.gameID.uuidString,
+ "crossmateAuthorID": key.authorID,
+ "crossmateActivity": "session-end"
+ ]
+ let interval = max(bucket.scheduledFor.timeIntervalSince(now), 1)
+ let trigger = UNTimeIntervalNotificationTrigger(timeInterval: interval, repeats: false)
+ let request = UNNotificationRequest(
+ identifier: Self.endIdentifier(for: key),
+ content: content,
+ trigger: trigger
+ )
+ do {
+ try await notificationCenter.add(request)
+ // The session-begin notification (if still on screen) is
+ // superseded by this richer end-of-session summary — withdraw
+ // both the delivered banner and its dedup gate so a fresh
+ // begin can fire once the next session starts.
+ notificationCenter.removeDeliveredNotifications(
+ withIdentifiers: [Self.beginIdentifier(for: key)]
+ )
+ NotificationState.clearShown(gameID: key.gameID, authorID: key.authorID)
+ } catch {
+ // Best-effort: a scheduling failure leaves the running tally
+ // intact, so the next note() will retry the add() with the
+ // accumulated totals.
+ }
+ }
+
+ private static func coreDataNameLookup(
+ persistence: PersistenceController,
+ gameID: UUID,
+ authorID: String
+ ) async -> (playerName: String, puzzleTitle: String) {
+ let ctx = persistence.container.newBackgroundContext()
+ return ctx.performAndWait {
+ let gameReq = NSFetchRequest<GameEntity>(entityName: "GameEntity")
+ gameReq.predicate = NSPredicate(format: "id == %@", gameID as CVarArg)
+ gameReq.fetchLimit = 1
+ let game = try? ctx.fetch(gameReq).first
+ let title = game?.title ?? ""
+
+ var name = ""
+ if let game {
+ let playerReq = NSFetchRequest<PlayerEntity>(entityName: "PlayerEntity")
+ playerReq.predicate = NSPredicate(
+ format: "game == %@ AND authorID == %@",
+ game, authorID
+ )
+ playerReq.fetchLimit = 1
+ name = (try? ctx.fetch(playerReq).first?.name) ?? ""
+ }
+ return (name, title)
+ }
+ }
+
+ static func endIdentifier(for gameID: UUID, authorID: String) -> String {
+ "session-end-\(gameID.uuidString)-\(authorID)"
+ }
+
+ private static func endIdentifier(for key: Key) -> String {
+ endIdentifier(for: key.gameID, authorID: key.authorID)
+ }
+
+ static func beginIdentifier(for gameID: UUID, authorID: String) -> String {
+ "session-begin-\(gameID.uuidString)-\(authorID)"
+ }
+
+ private static func beginIdentifier(for key: Key) -> String {
+ beginIdentifier(for: key.gameID, authorID: key.authorID)
+ }
+
+ static func bodyText(
+ playerName: String,
+ puzzleTitle: String,
+ added: Int,
+ cleared: Int
+ ) -> String {
+ let name = playerName.isEmpty ? "A player" : playerName
+ let suffix = puzzleTitle.isEmpty ? "the puzzle" : "the puzzle '\(puzzleTitle)'"
+ var parts: [String] = []
+ if added > 0 {
+ parts.append("added \(added) \(added == 1 ? "letter" : "letters")")
+ }
+ if cleared > 0 {
+ parts.append("cleared \(cleared) \(cleared == 1 ? "letter" : "letters")")
+ }
+ let action = parts.isEmpty ? "made changes" : parts.joined(separator: " and ")
+ return "\(name) \(action) in \(suffix)"
+ }
+}
diff --git a/Crossmate/Sync/SyncEngine.swift b/Crossmate/Sync/SyncEngine.swift
@@ -74,6 +74,11 @@ actor SyncEngine {
private var loggedFirstSharedPushPayload = false
var onRemoteMovesUpdated: (@MainActor @Sendable (Set<UUID>) async -> Void)?
+ /// Fires with per-`(gameID, authorID)` deltas computed by diffing the
+ /// merged grid before and after a batch of incoming Moves records. Drives
+ /// SessionMonitor's end-of-session notifications: each delta bumps the
+ /// running tally and reschedules the quiescence trigger.
+ var onRemoteAuthorDelta: (@MainActor @Sendable ([AuthorDelta]) async -> Void)?
/// Fires with the game IDs for which a collaborator's `Player` record was
/// seen for the **first time** (a new `PlayerEntity` was created) — not on
/// their subsequent name / cursor updates. Independent of moves; the
@@ -121,6 +126,10 @@ actor SyncEngine {
onRemoteMovesUpdated = cb
}
+ func setOnRemoteAuthorDelta(_ cb: @MainActor @Sendable @escaping ([AuthorDelta]) async -> Void) {
+ onRemoteAuthorDelta = cb
+ }
+
func setOnRemotePlayersUpdated(_ cb: @MainActor @Sendable @escaping (Set<UUID>) async -> Void) {
onRemotePlayersUpdated = cb
}
@@ -853,14 +862,24 @@ actor SyncEngine {
let ctx = persistence.container.newBackgroundContext()
ctx.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump
let localAuthorID = await currentLocalAuthorID()
- let (movesUpdatedGameIDs, affectedGameIDs, pings, playersUpdatedGameIDs, removedGameIDs, readCursors):
- (Set<UUID>, Set<UUID>, [Ping], Set<UUID>, Set<UUID>, [(UUID, Date)]) = ctx.performAndWait {
+ let (movesUpdatedGameIDs, affectedGameIDs, pings, playersUpdatedGameIDs, removedGameIDs, readCursors, authorDeltas):
+ (Set<UUID>, Set<UUID>, [Ping], Set<UUID>, Set<UUID>, [(UUID, Date)], [AuthorDelta]) = ctx.performAndWait {
var movesUpdated = Set<UUID>()
var affected = Set<UUID>()
var pings: [Ping] = []
var playersUpdated = Set<UUID>()
var removed = Set<UUID>()
var read: [(UUID, Date)] = []
+ // Pre-pass: snapshot the merged grid for every game that has a
+ // Moves record in this batch, so the post-apply diff can attribute
+ // each cell transition to the LWW-winning writer.
+ let movesBearingGameIDs: Set<UUID> = Set(event.modifications.compactMap { mod -> UUID? in
+ guard mod.record.recordType == "Moves",
+ let (gameID, _, _) = RecordSerializer.parseMovesRecordName(mod.record.recordID.recordName)
+ else { return nil }
+ return gameID
+ })
+ let beforeGrids = Self.gridSnapshots(for: movesBearingGameIDs, in: ctx)
for mod in event.modifications {
let record = mod.record
switch record.recordType {
@@ -927,6 +946,12 @@ actor SyncEngine {
for gameID in movesUpdated {
self.replayCellCache(for: gameID, in: ctx)
}
+ let afterGrids = Self.gridSnapshots(for: movesBearingGameIDs, in: ctx)
+ let deltas = Self.authorDeltas(
+ before: beforeGrids,
+ after: afterGrids,
+ cutoff: Date().addingTimeInterval(-SessionMonitor.quiescenceWindow)
+ )
// CKSyncEngine advances its change token whenever the delegate
// returns from fetchedRecordZoneChanges, regardless of whether we
// persisted anything. A silent failure here means the records are
@@ -944,12 +969,15 @@ actor SyncEngine {
)
}
}
- return (movesUpdated, affected, pings, playersUpdated, removed, read)
+ return (movesUpdated, affected, pings, playersUpdated, removed, read, deltas)
}
if let onRemoteMovesUpdated, !movesUpdatedGameIDs.isEmpty {
await onRemoteMovesUpdated(movesUpdatedGameIDs)
}
+ if let onRemoteAuthorDelta, !authorDeltas.isEmpty {
+ await onRemoteAuthorDelta(authorDeltas)
+ }
if let onRemotePlayersUpdated, !playersUpdatedGameIDs.isEmpty {
await onRemotePlayersUpdated(playersUpdatedGameIDs)
}
diff --git a/Shared/NotificationState.swift b/Shared/NotificationState.swift
@@ -76,25 +76,42 @@ enum NotificationState {
return false
}
- /// True if session activity for `gameID` was shown within `dedupWindow`.
- static func wasRecentlyShown(gameID: UUID, now: Date = Date()) -> Bool {
+ /// True if a session-begin notification for `(gameID, authorID)` was
+ /// shown within `dedupWindow`. The key is per-author so a second
+ /// collaborator starting a session in the same window can still notify.
+ static func wasRecentlyShown(gameID: UUID, authorID: String, now: Date = Date()) -> Bool {
let map = shownMap()
- guard let last = map[gameID.uuidString] else { return false }
+ guard let last = map[Self.compositeKey(gameID: gameID, authorID: authorID)] else { return false }
return now.timeIntervalSince1970 - last < dedupWindow
}
- /// Records that a notification for `gameID` was surfaced (or would have
- /// been, before suppression decisions). Trims old entries so the map
- /// stays small.
- static func recordShown(gameID: UUID, now: Date = Date()) {
+ /// Records that a notification for `(gameID, authorID)` was surfaced (or
+ /// would have been, before suppression decisions). Trims old entries so
+ /// the map stays small.
+ static func recordShown(gameID: UUID, authorID: String, now: Date = Date()) {
guard let defaults else { return }
var map = shownMap()
- map[gameID.uuidString] = now.timeIntervalSince1970
+ map[Self.compositeKey(gameID: gameID, authorID: authorID)] = now.timeIntervalSince1970
let cutoff = now.timeIntervalSince1970 - 2 * dedupWindow
map = map.filter { $0.value >= cutoff }
defaults.set(map, forKey: shownKey)
}
+ /// Drops the session-begin dedup entry for `(gameID, authorID)`. Called
+ /// when SessionMonitor fires the matching end-of-session notification —
+ /// the completed session has been summarised, so a fresh session-begin
+ /// from this author shouldn't be gated by the prior begin's dedup.
+ static func clearShown(gameID: UUID, authorID: String) {
+ guard let defaults else { return }
+ var map = shownMap()
+ guard map.removeValue(forKey: Self.compositeKey(gameID: gameID, authorID: authorID)) != nil else { return }
+ defaults.set(map, forKey: shownKey)
+ }
+
+ private static func compositeKey(gameID: UUID, authorID: String) -> String {
+ "\(gameID.uuidString)|\(authorID)"
+ }
+
private static func shownMap() -> [String: TimeInterval] {
defaults?.dictionary(forKey: shownKey) as? [String: TimeInterval] ?? [:]
}
diff --git a/Tests/Unit/NotificationStateTests.swift b/Tests/Unit/NotificationStateTests.swift
@@ -43,6 +43,40 @@ struct NotificationStateTests {
NotificationState.setActivePuzzleID(nil)
}
+ @Test("Per-author dedup: two authors in the same game don't collide")
+ func dedupIsPerAuthor() {
+ let gameID = UUID()
+ let now = Date(timeIntervalSince1970: 8_000_000)
+
+ NotificationState.recordShown(gameID: gameID, authorID: "alice", now: now)
+ // Alice is gated within the dedup window; Bob is not.
+ #expect(NotificationState.wasRecentlyShown(gameID: gameID, authorID: "alice", now: now))
+ #expect(!NotificationState.wasRecentlyShown(gameID: gameID, authorID: "bob", now: now))
+
+ // After Bob also shows, both are gated; clearing Alice doesn't
+ // unblock Bob.
+ NotificationState.recordShown(gameID: gameID, authorID: "bob", now: now)
+ NotificationState.clearShown(gameID: gameID, authorID: "alice")
+ #expect(!NotificationState.wasRecentlyShown(gameID: gameID, authorID: "alice", now: now))
+ #expect(NotificationState.wasRecentlyShown(gameID: gameID, authorID: "bob", now: now))
+
+ NotificationState.clearShown(gameID: gameID, authorID: "bob")
+ }
+
+ @Test("Dedup window expires after dedupWindow seconds")
+ func dedupWindowExpires() {
+ let gameID = UUID()
+ let now = Date(timeIntervalSince1970: 9_000_000)
+ NotificationState.recordShown(gameID: gameID, authorID: "alice", now: now)
+ #expect(NotificationState.wasRecentlyShown(gameID: gameID, authorID: "alice", now: now))
+ #expect(!NotificationState.wasRecentlyShown(
+ gameID: gameID,
+ authorID: "alice",
+ now: now.addingTimeInterval(NotificationState.dedupWindow)
+ ))
+ NotificationState.clearShown(gameID: gameID, authorID: "alice")
+ }
+
@Test("isSuppressed tracks the local active puzzle (and its grace tail)")
func suppressedTracksLocalActive() {
let gameID = UUID()
diff --git a/Tests/Unit/Sync/AuthorDeltaTests.swift b/Tests/Unit/Sync/AuthorDeltaTests.swift
@@ -0,0 +1,232 @@
+import Foundation
+import Testing
+
+@testable import Crossmate
+
+@Suite("GridStateMerger.mergeWithProvenance")
+struct GridStateMergerProvenanceTests {
+
+ private let gameID = UUID(uuidString: "AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE")!
+
+ private func view(
+ author: String,
+ device: String = "d1",
+ cells: [(row: Int, col: Int, letter: String, updatedAt: Date)]
+ ) -> MovesValue {
+ var dict: [GridPosition: TimestampedCell] = [:]
+ for entry in cells {
+ dict[GridPosition(row: entry.row, col: entry.col)] = TimestampedCell(
+ letter: entry.letter,
+ markKind: 0,
+ checkedWrong: false,
+ updatedAt: entry.updatedAt,
+ authorID: author
+ )
+ }
+ return MovesValue(
+ gameID: gameID,
+ authorID: author,
+ deviceID: device,
+ cells: dict,
+ updatedAt: cells.map(\.updatedAt).max() ?? .distantPast
+ )
+ }
+
+ @Test("Provenance carries the writer authorID, not just the cell author")
+ func writerAttribution() {
+ let alice = view(
+ author: "alice",
+ cells: [(0, 0, "A", Date(timeIntervalSince1970: 5))]
+ )
+ let result = GridStateMerger.mergeWithProvenance([alice])
+ let entry = result[GridPosition(row: 0, col: 0)]
+ #expect(entry?.writerAuthorID == "alice")
+ #expect(entry?.cell.letter == "A")
+ #expect(entry?.cell.updatedAt == Date(timeIntervalSince1970: 5))
+ }
+
+ @Test("Cleared cells (empty letter) are retained with their writer")
+ func emptyCellRetained() {
+ let cleared = view(
+ author: "alice",
+ cells: [(0, 0, "", Date(timeIntervalSince1970: 9))]
+ )
+ let result = GridStateMerger.mergeWithProvenance([cleared])
+ let entry = result[GridPosition(row: 0, col: 0)]
+ #expect(entry?.cell.letter == "")
+ #expect(entry?.writerAuthorID == "alice")
+ }
+
+ @Test("LWW winner's writer survives across multiple devices")
+ func lwwWinnerWriter() {
+ let alice = view(
+ author: "alice",
+ device: "ipad",
+ cells: [(0, 0, "A", Date(timeIntervalSince1970: 1))]
+ )
+ let bob = view(
+ author: "bob",
+ device: "phone",
+ cells: [(0, 0, "B", Date(timeIntervalSince1970: 2))]
+ )
+ let result = GridStateMerger.mergeWithProvenance([alice, bob])
+ #expect(result[GridPosition(row: 0, col: 0)]?.writerAuthorID == "bob")
+ }
+}
+
+@Suite("RecordApplier.authorDeltas")
+struct AuthorDeltaTests {
+
+ private let gameA = UUID(uuidString: "11111111-1111-1111-1111-111111111111")!
+ private let gameB = UUID(uuidString: "22222222-2222-2222-2222-222222222222")!
+
+ /// Convenience to build a per-game provenance map without going through
+ /// GridStateMerger — keeps tests focused on the diff logic.
+ private func snapshot(
+ _ entries: [(row: Int, col: Int, letter: String, writer: String, updatedAt: Date)]
+ ) -> [GridPosition: GridStateMerger.Provenance] {
+ var out: [GridPosition: GridStateMerger.Provenance] = [:]
+ for entry in entries {
+ out[GridPosition(row: entry.row, col: entry.col)] = GridStateMerger.Provenance(
+ cell: TimestampedCell(
+ letter: entry.letter,
+ markKind: 0,
+ checkedWrong: false,
+ updatedAt: entry.updatedAt,
+ authorID: entry.writer
+ ),
+ writerAuthorID: entry.writer
+ )
+ }
+ return out
+ }
+
+ private let recentEnough = Date(timeIntervalSince1970: 10_000)
+ private let cutoff = Date(timeIntervalSince1970: 9_000)
+
+ @Test("Empty-to-letter transition counts as added, attributed to the writer")
+ func addedCount() {
+ let before: [UUID: [GridPosition: GridStateMerger.Provenance]] = [gameA: [:]]
+ let after = [gameA: snapshot([(0, 0, "A", "alice", recentEnough)])]
+ let deltas = SyncEngine.authorDeltas(before: before, after: after, cutoff: cutoff)
+ #expect(deltas.count == 1)
+ #expect(deltas[0].gameID == gameA)
+ #expect(deltas[0].authorID == "alice")
+ #expect(deltas[0].added == 1)
+ #expect(deltas[0].cleared == 0)
+ }
+
+ @Test("Letter-to-empty transition counts as cleared, attributed to the clearing writer")
+ func clearedCount() {
+ // Bob wrote "B" originally, then Alice's record overwrites that cell
+ // with an empty letter — the clear is attributed to Alice, who is
+ // the LWW-winning writer of the post-apply state.
+ let before = [gameA: snapshot([(0, 0, "B", "bob", recentEnough)])]
+ let after = [gameA: snapshot([(0, 0, "", "alice", recentEnough.addingTimeInterval(1))])]
+ let deltas = SyncEngine.authorDeltas(before: before, after: after, cutoff: cutoff)
+ #expect(deltas.count == 1)
+ #expect(deltas[0].authorID == "alice")
+ #expect(deltas[0].added == 0)
+ #expect(deltas[0].cleared == 1)
+ }
+
+ @Test("Letter-to-different-letter contributes zero (no add, no clear)")
+ func overwriteIsNeutral() {
+ let before = [gameA: snapshot([(0, 0, "A", "alice", recentEnough)])]
+ let after = [gameA: snapshot([(0, 0, "B", "bob", recentEnough.addingTimeInterval(1))])]
+ let deltas = SyncEngine.authorDeltas(before: before, after: after, cutoff: cutoff)
+ #expect(deltas.isEmpty)
+ }
+
+ @Test("Cells unchanged between before and after contribute zero")
+ func unchangedCells() {
+ // Re-applying the same merged-grid state (idempotent CKSyncEngine
+ // re-delivery) yields no deltas.
+ let stamp = recentEnough
+ let same = snapshot([(0, 0, "A", "alice", stamp)])
+ let deltas = SyncEngine.authorDeltas(before: [gameA: same], after: [gameA: same], cutoff: cutoff)
+ #expect(deltas.isEmpty)
+ }
+
+ @Test("Cells whose winning entry predates cutoff are gated out")
+ func cutoffGateExcludesStale() {
+ let stale = Date(timeIntervalSince1970: 5_000) // well before cutoff
+ let before: [UUID: [GridPosition: GridStateMerger.Provenance]] = [gameA: [:]]
+ let after = [gameA: snapshot([(0, 0, "A", "alice", stale)])]
+ let deltas = SyncEngine.authorDeltas(before: before, after: after, cutoff: cutoff)
+ #expect(deltas.isEmpty)
+ }
+
+ @Test("Adds and clears in the same batch aggregate per author")
+ func aggregatesPerAuthor() {
+ let before = [gameA: snapshot([
+ (0, 0, "X", "alice", recentEnough),
+ (0, 1, "Y", "alice", recentEnough),
+ ])]
+ let after = [gameA: snapshot([
+ (0, 0, "", "alice", recentEnough.addingTimeInterval(1)), // cleared
+ (0, 1, "Y", "alice", recentEnough), // unchanged
+ (0, 2, "Z", "alice", recentEnough.addingTimeInterval(2)), // added
+ (0, 3, "W", "alice", recentEnough.addingTimeInterval(3)), // added
+ ])]
+ let deltas = SyncEngine.authorDeltas(before: before, after: after, cutoff: cutoff)
+ #expect(deltas.count == 1)
+ #expect(deltas[0].added == 2)
+ #expect(deltas[0].cleared == 1)
+ #expect(deltas[0].latestUpdate == recentEnough.addingTimeInterval(3))
+ }
+
+ @Test("Multi-device same-author writes all attribute to one bucket")
+ func multiDeviceSameAuthor() {
+ // Alice's iPad wrote A at (0,0) in the prior batch; her iPhone
+ // arrives with a clear at the same cell. Both writes have authorID
+ // = "alice", so the cleared count rolls up into one bucket — the
+ // hard problem the design was supposed to solve.
+ let before = [gameA: snapshot([(0, 0, "A", "alice", recentEnough)])]
+ let after = [gameA: snapshot([(0, 0, "", "alice", recentEnough.addingTimeInterval(1))])]
+ let deltas = SyncEngine.authorDeltas(before: before, after: after, cutoff: cutoff)
+ #expect(deltas.count == 1)
+ #expect(deltas[0].authorID == "alice")
+ #expect(deltas[0].cleared == 1)
+ }
+
+ @Test("Different authors in the same game produce distinct buckets")
+ func separateAuthorsSeparateBuckets() {
+ let before: [UUID: [GridPosition: GridStateMerger.Provenance]] = [gameA: [:]]
+ let after = [gameA: snapshot([
+ (0, 0, "A", "alice", recentEnough),
+ (1, 1, "B", "bob", recentEnough),
+ ])]
+ let deltas = SyncEngine.authorDeltas(before: before, after: after, cutoff: cutoff)
+ let byAuthor = Dictionary(uniqueKeysWithValues: deltas.map { ($0.authorID, $0) })
+ #expect(byAuthor["alice"]?.added == 1)
+ #expect(byAuthor["bob"]?.added == 1)
+ }
+
+ @Test("Deltas across multiple games are surfaced independently")
+ func multiGameDeltas() {
+ let before: [UUID: [GridPosition: GridStateMerger.Provenance]] = [
+ gameA: [:],
+ gameB: [:],
+ ]
+ let after = [
+ gameA: snapshot([(0, 0, "A", "alice", recentEnough)]),
+ gameB: snapshot([(0, 0, "B", "bob", recentEnough)]),
+ ]
+ let deltas = SyncEngine.authorDeltas(before: before, after: after, cutoff: cutoff)
+ #expect(deltas.count == 2)
+ #expect(Set(deltas.map { $0.gameID }) == Set([gameA, gameB]))
+ }
+
+ @Test("LWW-loser writes don't show up: if the cell didn't change, no delta")
+ func lwwLoserIgnored() {
+ // Before: Alice already wrote A at t=20. After-apply still shows A
+ // at t=20 (LWW kept Alice's value despite a stale Bob write being
+ // applied at t=5, which lost to Alice and so doesn't change the
+ // merged-grid). The diff sees no transition.
+ let stamp = recentEnough.addingTimeInterval(20)
+ let same = snapshot([(0, 0, "A", "alice", stamp)])
+ let deltas = SyncEngine.authorDeltas(before: [gameA: same], after: [gameA: same], cutoff: cutoff)
+ #expect(deltas.isEmpty)
+ }
+}
diff --git a/Tests/Unit/Sync/SessionMonitorTests.swift b/Tests/Unit/Sync/SessionMonitorTests.swift
@@ -0,0 +1,248 @@
+import Foundation
+import Testing
+import UserNotifications
+
+@testable import Crossmate
+
+/// In-memory recorder for the slice of UNUserNotificationCenter that
+/// SessionMonitor uses. Lets tests inspect what would have been scheduled
+/// or withdrawn without involving the system notification center.
+final class RecordingNotificationScheduler: SessionNotificationScheduling, @unchecked Sendable {
+ private let lock = NSLock()
+ private var _added: [UNNotificationRequest] = []
+ private var _removedPending: [String] = []
+ private var _removedDelivered: [String] = []
+
+ var added: [UNNotificationRequest] { lock.withLock { _added } }
+ var removedPending: [String] { lock.withLock { _removedPending } }
+ var removedDelivered: [String] { lock.withLock { _removedDelivered } }
+
+ func add(_ request: UNNotificationRequest) async throws {
+ lock.withLock { _added.append(request) }
+ }
+
+ func removePendingNotificationRequests(withIdentifiers identifiers: [String]) {
+ lock.withLock { _removedPending.append(contentsOf: identifiers) }
+ }
+
+ func removeDeliveredNotifications(withIdentifiers identifiers: [String]) {
+ lock.withLock { _removedDelivered.append(contentsOf: identifiers) }
+ }
+}
+
+@MainActor
+private func makeMonitor(
+ scheduler: RecordingNotificationScheduler,
+ localAuthorID: String? = nil,
+ suppressionGate: @escaping @Sendable (UUID) -> Bool = { _ in false },
+ clock: @escaping @Sendable () -> Date = { Date() }
+) -> SessionMonitor {
+ SessionMonitor(
+ persistence: makeTestPersistence(),
+ localAuthorIDProvider: { localAuthorID },
+ notificationCenter: scheduler,
+ nameLookup: { _, authorID in ("Player \(authorID)", "Puzzle") },
+ suppressionGate: suppressionGate,
+ clock: clock
+ )
+}
+
+private func makeDelta(
+ gameID: UUID,
+ authorID: String,
+ added: Int = 0,
+ cleared: Int = 0,
+ latestUpdate: Date = Date()
+) -> AuthorDelta {
+ AuthorDelta(
+ gameID: gameID,
+ authorID: authorID,
+ added: added,
+ cleared: cleared,
+ latestUpdate: latestUpdate
+ )
+}
+
+@Suite("SessionMonitor")
+struct SessionMonitorTests {
+
+ @Test("First delta opens a bucket and schedules the end-of-session notification")
+ @MainActor func ingestSchedulesNotification() async {
+ let scheduler = RecordingNotificationScheduler()
+ let monitor = makeMonitor(scheduler: scheduler)
+ let gameID = UUID()
+ await monitor.ingest([makeDelta(gameID: gameID, authorID: "alice", added: 3)])
+
+ #expect(scheduler.added.count == 1)
+ let request = scheduler.added.first!
+ #expect(request.identifier == SessionMonitor.endIdentifier(for: gameID, authorID: "alice"))
+ #expect(request.content.body.contains("3"))
+ #expect(request.trigger is UNTimeIntervalNotificationTrigger)
+ }
+
+ @Test("Successive deltas for the same key accumulate; the bucket totals grow")
+ @MainActor func tallyAccumulates() async {
+ let scheduler = RecordingNotificationScheduler()
+ // Anchor every batch at a fresh "now" so the prior trigger never
+ // elapses between calls — exercises the accumulation path, not the
+ // session-rollover path.
+ let monitor = makeMonitor(scheduler: scheduler, clock: { Date() })
+ let gameID = UUID()
+ await monitor.ingest([makeDelta(gameID: gameID, authorID: "alice", added: 2, latestUpdate: Date())])
+ await monitor.ingest([makeDelta(gameID: gameID, authorID: "alice", added: 1, cleared: 4, latestUpdate: Date())])
+
+ let tally = await monitor.tally(gameID: gameID, authorID: "alice")
+ #expect(tally?.added == 3)
+ #expect(tally?.cleared == 4)
+ // Each ingest reschedules: two add() calls, both with the same id.
+ #expect(scheduler.added.count == 2)
+ }
+
+ @Test("Self-authored deltas (sibling device) are dropped")
+ @MainActor func selfAuthoredSkipped() async {
+ let scheduler = RecordingNotificationScheduler()
+ let monitor = makeMonitor(scheduler: scheduler, localAuthorID: "me")
+ let gameID = UUID()
+ await monitor.ingest([
+ makeDelta(gameID: gameID, authorID: "me", added: 99),
+ makeDelta(gameID: gameID, authorID: "alice", added: 1),
+ ])
+
+ #expect(scheduler.added.count == 1)
+ #expect(scheduler.added.first?.identifier
+ == SessionMonitor.endIdentifier(for: gameID, authorID: "alice"))
+ let selfTally = await monitor.tally(gameID: gameID, authorID: "me")
+ #expect(selfTally == nil)
+ }
+
+ @Test("Zero-count deltas are dropped")
+ @MainActor func zeroCountSkipped() async {
+ let scheduler = RecordingNotificationScheduler()
+ let monitor = makeMonitor(scheduler: scheduler)
+ let gameID = UUID()
+ await monitor.ingest([makeDelta(gameID: gameID, authorID: "alice", added: 0, cleared: 0)])
+
+ #expect(scheduler.added.isEmpty)
+ let tally = await monitor.tally(gameID: gameID, authorID: "alice")
+ #expect(tally == nil)
+ }
+
+ @Test("Active-puzzle suppression skips ingestion for that game")
+ @MainActor func suppressionGateBlocks() async {
+ let scheduler = RecordingNotificationScheduler()
+ let suppressed = UUID()
+ let other = UUID()
+ let monitor = makeMonitor(
+ scheduler: scheduler,
+ suppressionGate: { $0 == suppressed }
+ )
+ await monitor.ingest([
+ makeDelta(gameID: suppressed, authorID: "alice", added: 5),
+ makeDelta(gameID: other, authorID: "alice", added: 5),
+ ])
+
+ #expect(scheduler.added.count == 1)
+ #expect(scheduler.added.first?.identifier
+ == SessionMonitor.endIdentifier(for: other, authorID: "alice"))
+ }
+
+ @Test("Delta whose latestUpdate predates the quiescence window starts a fresh bucket after rollover")
+ @MainActor func sessionRollover() async {
+ let scheduler = RecordingNotificationScheduler()
+ let t0 = Date(timeIntervalSince1970: 1_000_000)
+ // After the first ingest the scheduledFor is at t0+window. Bump
+ // the clock past it; the next ingest must reset rather than
+ // accumulate.
+ let clock = MutableClock(initial: t0)
+ let monitor = makeMonitor(scheduler: scheduler, clock: clock.now)
+ let gameID = UUID()
+ await monitor.ingest([makeDelta(gameID: gameID, authorID: "alice", added: 4, latestUpdate: t0)])
+ clock.set(t0.addingTimeInterval(SessionMonitor.quiescenceWindow + 10))
+ await monitor.ingest([makeDelta(
+ gameID: gameID,
+ authorID: "alice",
+ added: 1,
+ latestUpdate: clock.now()
+ )])
+ let tally = await monitor.tally(gameID: gameID, authorID: "alice")
+ // Second session — first session's tally is treated as consumed.
+ #expect(tally?.added == 1)
+ }
+
+ @Test("cancel(gameID:) drops every author's bucket for the game and withdraws the requests")
+ @MainActor func cancelDropsAllAuthors() async {
+ let scheduler = RecordingNotificationScheduler()
+ let monitor = makeMonitor(scheduler: scheduler)
+ let gameID = UUID()
+ await monitor.ingest([
+ makeDelta(gameID: gameID, authorID: "alice", added: 1),
+ makeDelta(gameID: gameID, authorID: "bob", added: 1),
+ ])
+ #expect(await monitor.pendingBucketCount == 2)
+
+ await monitor.cancel(gameID: gameID)
+ #expect(await monitor.pendingBucketCount == 0)
+
+ let removed = Set(scheduler.removedPending).union(scheduler.removedDelivered)
+ #expect(removed.contains(SessionMonitor.endIdentifier(for: gameID, authorID: "alice")))
+ #expect(removed.contains(SessionMonitor.endIdentifier(for: gameID, authorID: "bob")))
+ }
+
+ @Test("cancel(gameID:authorID:) only drops the named author's bucket")
+ @MainActor func cancelTargetsOneAuthor() async {
+ let scheduler = RecordingNotificationScheduler()
+ let monitor = makeMonitor(scheduler: scheduler)
+ let gameID = UUID()
+ await monitor.ingest([
+ makeDelta(gameID: gameID, authorID: "alice", added: 1),
+ makeDelta(gameID: gameID, authorID: "bob", added: 1),
+ ])
+ await monitor.cancel(gameID: gameID, authorID: "alice")
+
+ let aliceTally = await monitor.tally(gameID: gameID, authorID: "alice")
+ let bobTally = await monitor.tally(gameID: gameID, authorID: "bob")
+ #expect(aliceTally == nil)
+ #expect(bobTally?.added == 1)
+ }
+
+ @Test("Scheduling a new end-of-session withdraws the matching session-begin banner")
+ @MainActor func endWithdrawsBegin() async {
+ let scheduler = RecordingNotificationScheduler()
+ let monitor = makeMonitor(scheduler: scheduler)
+ let gameID = UUID()
+ await monitor.ingest([makeDelta(gameID: gameID, authorID: "alice", added: 1)])
+
+ #expect(scheduler.removedDelivered.contains(
+ SessionMonitor.beginIdentifier(for: gameID, authorID: "alice")
+ ))
+ }
+
+ @Test("Notification body pluralises and combines added + cleared")
+ func bodyTextWording() {
+ let alice = SessionMonitor.bodyText(playerName: "Alice", puzzleTitle: "Tuesday Mini", added: 1, cleared: 0)
+ #expect(alice == "Alice added 1 letter in the puzzle 'Tuesday Mini'")
+ let bob = SessionMonitor.bodyText(playerName: "Bob", puzzleTitle: "Tuesday Mini", added: 4, cleared: 2)
+ #expect(bob == "Bob added 4 letters and cleared 2 letters in the puzzle 'Tuesday Mini'")
+ let nameless = SessionMonitor.bodyText(playerName: "", puzzleTitle: "", added: 3, cleared: 0)
+ #expect(nameless == "A player added 3 letters in the puzzle")
+ }
+}
+
+/// Mutable wall-clock for tests that need to advance time deterministically.
+private final class MutableClock: @unchecked Sendable {
+ private let lock = NSLock()
+ private var current: Date
+
+ init(initial: Date) { self.current = initial }
+
+ func set(_ date: Date) {
+ lock.withLock { current = date }
+ }
+
+ var now: @Sendable () -> Date {
+ { [weak self] in
+ guard let self else { return Date() }
+ return self.lock.withLock { self.current }
+ }
+ }
+}