commit 9052bda0be81c81b035f94a5990166e7e78b63d9
parent e11b8ed41f538acb1a502254f66c3344e8c9c41f
Author: Michael Camilleri <[email protected]>
Date: Thu, 28 May 2026 11:44:48 +0900
Share session snapshot across an author's devices
A player on multiple devices used to send one pause push per device, each
reporting only the letters typed on that device — peers got '+30 from iPad'
then '+10 from iPhone' instead of one cumulative '+40'. The baseline that
anchors the diff lived in an in-memory LocalSessionTracker per device, with no
way for sibling devices to share it.
The shared baseline now lives on the Player CKRecord as a new sessionSnapshot
Bytes field, mirrored locally on PlayerEntity. On session-begin the device
captures the author's merged-across-devices snapshot (or adopts an existing one
if Player.updatedAt is within 30 minutes — anything older is treated as a
crashed-session leftover and overwritten). On session-end the device diffs
current merged-author state against that baseline so whichever device pauses
last reports the cumulative delta, then clears the field so the next begin
re-anchors fresh.
To stop multiple devices each emitting a pause push for the same session,
scheduleSessionEndPush captures pauseStart at scheduling time and
publishSessionEndPush suppresses the push when Player.updatedAt has advanced
past pauseStart during the grace window — the local device wrote nothing in
that window by construction, so a fresher updatedAt can only mean another
device of this author is still active. Its eventual pause will report
cumulatively against the still-intact baseline.
LocalSessionTracker is gone; LocalMovesSnapshot moves to Models/ now that both
sender and receiver baselines flow through GameStore. The receiver-side
per-device lastMovesSnapshotData is unchanged — that's a separate 'what this
device has seen of each peer' cursor, deliberately not cross-device.
Co-Authored-By: Claude Opus 4.7 <[email protected]>
Diffstat:
11 files changed, 337 insertions(+), 98 deletions(-)
diff --git a/Crossmate.xcodeproj/project.pbxproj b/Crossmate.xcodeproj/project.pbxproj
@@ -35,6 +35,7 @@
3A5483EF2893AE325DF27EE8 /* GameMutator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43DC132D49361C56DE79C13E /* GameMutator.swift */; };
3C54AE4AA04342CCF5705B20 /* PlayerNamePublisher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71DFD035381B6252DCD873C9 /* PlayerNamePublisher.swift */; };
40256E08EE741F4C414B842B /* PuzzleNotificationText.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2F03A9F357672533E2A8DB0 /* PuzzleNotificationText.swift */; };
+ 42D795339FFF80C3E730395B /* GameStoreSessionSnapshotTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9E3B149B506E74AC1B89892 /* GameStoreSessionSnapshotTests.swift */; };
449B0A09A36B276C93CFB9A4 /* GameStoreUnreadMovesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 31C534911020BE4ED2E5065D /* GameStoreUnreadMovesTests.swift */; };
47584CBEF819C2F507D06DFF /* PlayerColor.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB55FC337CF72C650373210A /* PlayerColor.swift */; };
4819D7FBB407C9D76510EA2A /* TestHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = F97B399E89BBB37730F2F1E9 /* TestHelpers.swift */; };
@@ -47,6 +48,7 @@
5ECF5B80D08E5E999A540782 /* SessionMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0EB332831AB173ACF6BFEC59 /* SessionMonitor.swift */; };
5EFCD28B3B682DCCF38068D6 /* AnnouncementCenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D3ECD0DE71BE567BCEE15F6 /* AnnouncementCenter.swift */; };
5FB26F40F5DB52111E3D1BDC /* CheckResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0FD9A43789D0ED123F7A99B0 /* CheckResult.swift */; };
+ 66A2B6DAB2F132950789CA98 /* LocalMovesSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A3E1CB3BA66CA884A4CC985 /* LocalMovesSnapshot.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 */; };
@@ -125,7 +127,6 @@
ED6C21CD9F5AB286B69A02E4 /* GridStateMerger.swift in Sources */ = {isa = PBXBuildFile; fileRef = 14B05C19BD4705876B3DF0EC /* GridStateMerger.swift */; };
F34EDFD45E2F5006807DDAC7 /* PuzzleCatalogTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8560440C548752EE93E0ED9 /* PuzzleCatalogTests.swift */; };
F46733AB3C72749A4A992667 /* SyncState+Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A49C3C31F49A85764B84A15 /* SyncState+Helpers.swift */; };
- F63FCB29A7E9EE5208ED2C7E /* LocalSessionTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 19C3ED4479869ADC66808C7D /* LocalSessionTracker.swift */; };
F77177F48728ECEACD3B28B3 /* KeyboardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7D28E8CBB1AFFD801E87D4E3 /* KeyboardView.swift */; };
F8DDA34AC1A6B6499C5D222E /* PlayerPreferences.swift in Sources */ = {isa = PBXBuildFile; fileRef = 46148CF0F4D719692F81A6EC /* PlayerPreferences.swift */; };
FFBE2EC8A3A60E119A0D314F /* NYTBrowseView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F2BE43E18B1CC6AAD27DC6D /* NYTBrowseView.swift */; };
@@ -156,7 +157,6 @@
16E1DA8C1B4E73AFB779CC06 /* DebuggingMonitors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DebuggingMonitors.swift; sourceTree = "<group>"; };
1813630FA05C194AFF43855C /* PlayerRosterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerRosterTests.swift; sourceTree = "<group>"; };
18C701DAE36000DE19F7CC95 /* EngagementHost.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EngagementHost.swift; sourceTree = "<group>"; };
- 19C3ED4479869ADC66808C7D /* LocalSessionTracker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalSessionTracker.swift; sourceTree = "<group>"; };
1D3ECD0DE71BE567BCEE15F6 /* AnnouncementCenter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnnouncementCenter.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>"; };
@@ -198,6 +198,7 @@
71DFD035381B6252DCD873C9 /* PlayerNamePublisher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerNamePublisher.swift; sourceTree = "<group>"; };
73DDDED719CFFDD6035C3B48 /* SyncEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncEngine.swift; sourceTree = "<group>"; };
74FEFF257CDDD3EF0E77CBF7 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; };
+ 7A3E1CB3BA66CA884A4CC985 /* LocalMovesSnapshot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalMovesSnapshot.swift; sourceTree = "<group>"; };
7A4AFF292381C9B33C0F2CD6 /* FriendZone.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FriendZone.swift; sourceTree = "<group>"; };
7B3E1A382B24A7803701D947 /* Crossmate.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Crossmate.entitlements; sourceTree = "<group>"; };
7D28E8CBB1AFFD801E87D4E3 /* KeyboardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyboardView.swift; sourceTree = "<group>"; };
@@ -266,6 +267,7 @@
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>"; };
+ F9E3B149B506E74AC1B89892 /* GameStoreSessionSnapshotTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameStoreSessionSnapshotTests.swift; sourceTree = "<group>"; };
FDE193CAB325C991952D7CE5 /* PUZToXDConverterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PUZToXDConverterTests.swift; sourceTree = "<group>"; };
FEDD63AD5E33E2B0399780EF /* NotificationNavigationBrokerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationNavigationBrokerTests.swift; sourceTree = "<group>"; };
FF159746D076E051C2CB590C /* PlayerSelectionPublisherTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerSelectionPublisherTests.swift; sourceTree = "<group>"; };
@@ -322,6 +324,7 @@
978A96CC6F550ED7A73F8D96 /* AnnouncementCenterTests.swift */,
60E818B0F4689BAD57660B7C /* GameCursorStoreTests.swift */,
BFC1C59A30FB2571598273E4 /* GameMutatorTests.swift */,
+ F9E3B149B506E74AC1B89892 /* GameStoreSessionSnapshotTests.swift */,
31C534911020BE4ED2E5065D /* GameStoreUnreadMovesTests.swift */,
6C7F3A9BD7FAF81CB77032A6 /* GridStateMergerTests.swift */,
9AF6157D97271205626E207C /* MovesUpdaterTests.swift */,
@@ -356,6 +359,7 @@
B09D52DB46731E92C3E9297C /* EngagementStore.swift */,
465F2BB469EFE84CF3733398 /* Game.swift */,
8D2AD5021F1AF0DB44FA4540 /* GameCursorStore.swift */,
+ 7A3E1CB3BA66CA884A4CC985 /* LocalMovesSnapshot.swift */,
DB55FC337CF72C650373210A /* PlayerColor.swift */,
46148CF0F4D719692F81A6EC /* PlayerPreferences.swift */,
3292748EAE27B608C769D393 /* PlayerRoster.swift */,
@@ -491,7 +495,6 @@
462CE0FD356F6137C9BFD30F /* ImportService.swift */,
6BDD06460A76D4AF31077732 /* InputMonitor.swift */,
33878A29B09A6154C7A63C82 /* KeychainHelper.swift */,
- 19C3ED4479869ADC66808C7D /* LocalSessionTracker.swift */,
A253416F4FEA271A80B22A73 /* NYTAuthService.swift */,
B0938B0ACB40772EE522D77C /* NYTPuzzleFetcher.swift */,
CE54EF557E8D808BAA20EA54 /* NYTPuzzleUpgrader.swift */,
@@ -607,6 +610,7 @@
712A2764596A2D17A0BBBF3B /* FriendZoneTests.swift in Sources */,
85A798525FE1DC98210A9E82 /* GameCursorStoreTests.swift in Sources */,
2C0DFC182240A2519ED1FA6A /* GameMutatorTests.swift in Sources */,
+ 42D795339FFF80C3E730395B /* GameStoreSessionSnapshotTests.swift in Sources */,
449B0A09A36B276C93CFB9A4 /* GameStoreUnreadMovesTests.swift in Sources */,
AACC9F70AEEDCB3360FFDEFF /* GridStateMergerTests.swift in Sources */,
DE90CC8BE23A0EFC4A32FFA5 /* MovesInboundTests.swift in Sources */,
@@ -685,7 +689,7 @@
1A19D13D9B820E276C60819E /* InputMonitor.swift in Sources */,
F77177F48728ECEACD3B28B3 /* KeyboardView.swift in Sources */,
38C913D00ED762BD9E355A2D /* KeychainHelper.swift in Sources */,
- F63FCB29A7E9EE5208ED2C7E /* LocalSessionTracker.swift in Sources */,
+ 66A2B6DAB2F132950789CA98 /* LocalMovesSnapshot.swift in Sources */,
91703E54DB4679C1911BF994 /* Moves.swift in Sources */,
4D90B39AD2F79959FB8089EE /* MovesUpdater.swift in Sources */,
1F4E5473F78A5CEDBA9719CE /* NYTAuthService.swift in Sources */,
diff --git a/Crossmate/CrossmateApp.swift b/Crossmate/CrossmateApp.swift
@@ -655,7 +655,7 @@ private struct PuzzleDisplayView: View {
Task {
services.handlePuzzleOpened(gameID: id)
if !resumed {
- await services.publishSessionStartPush(gameID: id)
+ await services.publishSessionBeginPush(gameID: id)
}
}
case .background:
diff --git a/Crossmate/Models/CrossmateModel.xcdatamodeld/CrossmateModel.xcdatamodel/contents b/Crossmate/Models/CrossmateModel.xcdatamodeld/CrossmateModel.xcdatamodel/contents
@@ -41,6 +41,7 @@
<attribute name="selCol" optional="YES" attributeType="Integer 64" usesScalarValueType="NO"/>
<attribute name="selDir" optional="YES" attributeType="Integer 64" usesScalarValueType="NO"/>
<attribute name="selRow" optional="YES" attributeType="Integer 64" usesScalarValueType="NO"/>
+ <attribute name="sessionSnapshot" optional="YES" attributeType="Binary"/>
<attribute name="updatedAt" attributeType="Date" usesScalarValueType="NO"/>
<relationship name="game" maxCount="1" deletionRule="Nullify" destinationEntity="GameEntity" inverseName="players" inverseEntity="GameEntity"/>
<fetchIndex name="byGameAndAuthor">
diff --git a/Crossmate/Models/LocalMovesSnapshot.swift b/Crossmate/Models/LocalMovesSnapshot.swift
@@ -0,0 +1,24 @@
+import Foundation
+
+/// A point-in-time view of an author's merged Moves state, partitioned by
+/// whether each cell currently holds a letter. Captured at session begin
+/// and diffed at session end to derive net adds and net clears for the
+/// pause-push body and the in-app catch-up banner — both sender and
+/// receiver share the same algorithm so the numbers agree.
+struct LocalMovesSnapshot: Equatable, Sendable, Codable {
+ /// Positions where the Moves row currently has a non-empty letter.
+ let filled: Set<GridPosition>
+ /// Positions where the Moves row currently has an explicit empty entry
+ /// (cells the user cleared, including ones a peer had filled).
+ let cleared: Set<GridPosition>
+
+ static let empty = LocalMovesSnapshot(filled: [], cleared: [])
+
+ func encoded() throws -> Data {
+ try JSONEncoder().encode(self)
+ }
+
+ static func decode(_ data: Data) throws -> LocalMovesSnapshot {
+ try JSONDecoder().decode(LocalMovesSnapshot.self, from: data)
+ }
+}
diff --git a/Crossmate/Persistence/GameStore.swift b/Crossmate/Persistence/GameStore.swift
@@ -917,6 +917,57 @@ final class GameStore {
saveContext("setLastMovesSnapshot")
}
+ /// Returns the author's current `sessionSnapshot` (decoded) and Player
+ /// `updatedAt` for `(gameID, authorID)`. Both fields are nil when no
+ /// PlayerEntity row exists yet. The snapshot is the baseline an active
+ /// session writes at begin and clears at end; `updatedAt` doubles as the
+ /// staleness check (an old snapshot with a fresh `updatedAt` is live;
+ /// an old snapshot with an old `updatedAt` is a crash leftover) and as
+ /// the peer-device liveness probe during the pause grace window.
+ func sessionState(
+ for gameID: UUID,
+ by authorID: String
+ ) -> (snapshot: LocalMovesSnapshot?, updatedAt: Date?) {
+ guard let entity = fetchPlayerEntity(gameID: gameID, authorID: authorID) else {
+ return (nil, nil)
+ }
+ let snapshot = entity.sessionSnapshot.flatMap { try? LocalMovesSnapshot.decode($0) }
+ return (snapshot, entity.updatedAt)
+ }
+
+ /// Writes `snapshot` (encoded) to `PlayerEntity.sessionSnapshot` and
+ /// bumps `updatedAt` to `now`. Pass `nil` to clear the field at session
+ /// end. Creates a stub PlayerEntity if none exists yet, mirroring
+ /// `setLastMovesSnapshot`. Unlike `setLastMovesSnapshot`, the value is
+ /// included in the outbound Player CKRecord — the caller is responsible
+ /// for calling `SyncEngine.enqueuePlayer` to ship the change.
+ func setSessionSnapshot(
+ _ snapshot: LocalMovesSnapshot?,
+ for gameID: UUID,
+ by authorID: String,
+ now: Date = Date()
+ ) {
+ let entity: PlayerEntity
+ if let existing = fetchPlayerEntity(gameID: gameID, authorID: authorID) {
+ entity = existing
+ } else {
+ let gameRequest = NSFetchRequest<GameEntity>(entityName: "GameEntity")
+ gameRequest.predicate = NSPredicate(format: "id == %@", gameID as CVarArg)
+ gameRequest.fetchLimit = 1
+ guard let game = try? context.fetch(gameRequest).first else { return }
+ entity = PlayerEntity(context: context)
+ entity.game = game
+ entity.authorID = authorID
+ entity.ckRecordName = RecordSerializer.recordName(
+ forPlayerInGame: gameID,
+ authorID: authorID
+ )
+ }
+ entity.sessionSnapshot = snapshot.flatMap { try? $0.encoded() }
+ entity.updatedAt = now
+ saveContext("setSessionSnapshot")
+ }
+
/// Distinct authorIDs that have written a `MovesEntity` for `gameID`,
/// with `excluding` filtered out. The session-summary banner uses this
/// to enumerate peers whose activity it should diff.
diff --git a/Crossmate/Services/AppServices.swift b/Crossmate/Services/AppServices.swift
@@ -58,12 +58,19 @@ final class AppServices {
let playerSelectionPublisher: PlayerSelectionPublisher
let identity: AuthorIdentity
let pushClient: PushClient?
- let localSessionTracker = LocalSessionTracker()
/// Grace window before a backgrounded session is treated as ended. A
/// briefly-backgrounded puzzle (phone sleep, app switcher peek, taking a
/// call) should not fan out pause/play pings to peers on every flicker —
/// only a sustained absence does.
static let sessionEndGrace: TimeInterval = 120
+ /// Window past which a non-nil `Player.sessionSnapshot` is treated as a
+ /// crashed-session leftover rather than a live session belonging to
+ /// another device of this author. Comfortably exceeds any plausible
+ /// mid-session idle gap (selection writes, readAt writes, and the
+ /// pause-grace window are all much shorter), and well below "user came
+ /// back the next day" so a real abandoned baseline doesn't suppress a
+ /// genuine new-session begin.
+ static let sessionSnapshotStaleness: TimeInterval = 30 * 60
private var pendingSessionEndTasks: [UUID: Task<Void, Never>] = [:]
let shareController: ShareController
let friendController: FriendController
@@ -600,26 +607,21 @@ final class AppServices {
await publishCompletionPush(gameID: gameID, resigned: resigned)
}
- /// Sender-side session-start push. Replaces the receiver-side
+ /// Sender-side session-begin push. Replaces the receiver-side
/// `SessionMonitor.presentBegins(...)` path: the opening device owns the
/// notification timing, so peers get "Alice is solving X" the instant
- /// Alice opens the puzzle. Also resets the local edit tracker so the
- /// matching pause push counts only this segment. Kind is `play` (rather
- /// than `join`) to avoid collision with the `PingKind.join` invite-accept
- /// ping — the user-facing event is "started playing", not "joined".
- func publishSessionStartPush(gameID: UUID) async {
+ /// Alice opens the puzzle. Anchors the matching pause push by adopting
+ /// or writing a shared `Player.sessionSnapshot` so the cumulative delta
+ /// reported at session end spans every device of this author, not just
+ /// this one. Kind is `play` (rather than `join`) to avoid collision with
+ /// the `PingKind.join` invite-accept ping — the user-facing event is
+ /// "started playing", not "joined".
+ func publishSessionBeginPush(gameID: UUID) async {
guard let localAuthorID = identity.currentID, !localAuthorID.isEmpty else {
syncMonitor.note("push(play): skipped (no authorID)")
return
}
- localSessionTracker.begin(
- gameID: gameID,
- snapshot: store.movesSnapshot(
- for: gameID,
- by: localAuthorID,
- on: RecordSerializer.localDeviceID
- )
- )
+ await anchorSessionSnapshot(gameID: gameID, authorID: localAuthorID)
guard let pushClient else {
syncMonitor.note("push(play): skipped (no pushClient)")
return
@@ -652,32 +654,53 @@ final class AppServices {
)
}
- /// Sender-side session-end push. Drains the local edit tracker, formats
- /// the same summary text the receiver-side quiescence path used to build
- /// (`SessionMonitor.bodyText`), and ships it. A session with no edits
- /// (opened and closed without typing, or already drained by a prior
- /// background-publish) skips the push entirely. Kind is `pause` — the
- /// user is stepping away from a puzzle they may return to, not
- /// permanently leaving the game.
+ /// Adopts `Player.sessionSnapshot` if a fresh one already exists (another
+ /// device of this author began a session within the staleness window),
+ /// otherwise captures the author's merged-across-devices snapshot and
+ /// writes it to Player. Either way, when the matching pause push fires
+ /// on whichever device pauses last, it diffs against this shared
+ /// baseline so the reported delta covers every device's contribution.
+ private func anchorSessionSnapshot(gameID: UUID, authorID: String) async {
+ let now = Date()
+ let state = store.sessionState(for: gameID, by: authorID)
+ if state.snapshot != nil,
+ let updatedAt = state.updatedAt,
+ now.timeIntervalSince(updatedAt) < Self.sessionSnapshotStaleness {
+ syncMonitor.note("session-snapshot: adopted")
+ return
+ }
+ let snapshot = store.movesSnapshot(for: gameID, by: authorID, on: nil)
+ store.setSessionSnapshot(snapshot, for: gameID, by: authorID, now: now)
+ await syncEngine.enqueuePlayer(
+ gameID: gameID,
+ authorID: authorID,
+ reason: "session-begin"
+ )
+ }
+
/// Defer the session-end push by `seconds`. Cancels any previously
/// scheduled pause for the same game. If the user resumes within the
/// grace window, call `cancelPendingSessionEndPush` to drop the timer
- /// and skip the matching session-start push so peers don't get a
- /// pause/play pair for a brief absence.
+ /// and skip the matching session-begin push so peers don't get a
+ /// pause/play pair for a brief absence. The wall-clock at scheduling
+ /// time is passed through so the fire-time peer-device-active check has
+ /// a stable reference point for "did anyone other than me write to
+ /// Player during the grace window."
func scheduleSessionEndPush(gameID: UUID, after seconds: TimeInterval) {
+ let pauseStart = Date()
pendingSessionEndTasks[gameID]?.cancel()
pendingSessionEndTasks[gameID] = Task { [weak self] in
try? await Task.sleep(for: .seconds(seconds))
guard !Task.isCancelled else { return }
guard let self else { return }
self.pendingSessionEndTasks[gameID] = nil
- await self.publishSessionEndPush(gameID: gameID)
+ await self.publishSessionEndPush(gameID: gameID, pauseStart: pauseStart)
}
}
/// Cancel any pending scheduled session-end push for `gameID`. Returns
/// `true` if a pending task was dropped, i.e. the caller is inside the
- /// grace window and should suppress the matching session-start push.
+ /// grace window and should suppress the matching session-begin push.
@discardableResult
func cancelPendingSessionEndPush(gameID: UUID) -> Bool {
guard let task = pendingSessionEndTasks.removeValue(forKey: gameID) else {
@@ -687,7 +710,14 @@ final class AppServices {
return true
}
- func publishSessionEndPush(gameID: UUID) async {
+ /// Sender-side session-end push. Diffs the author's current
+ /// merged-across-devices Moves snapshot against the shared
+ /// `Player.sessionSnapshot` baseline and ships the summary. Suppresses
+ /// the push when a peer device of this author wrote to Player during
+ /// the grace window — that device is still playing and will report the
+ /// cumulative delta when it pauses. Otherwise clears the shared
+ /// snapshot so the next session-begin re-anchors fresh.
+ func publishSessionEndPush(gameID: UUID, pauseStart: Date = Date()) async {
// A direct call (e.g. from `.onDisappear`) supersedes any pending
// grace-window timer for this game — drop it so we don't fire a
// second pause once the timer elapses.
@@ -696,15 +726,35 @@ final class AppServices {
syncMonitor.note("push(pause): skipped (no authorID)")
return
}
- let counts = localSessionTracker.consume(
+ let state = store.sessionState(for: gameID, by: localAuthorID)
+ // During the grace window this device wrote nothing to Player
+ // (any local activity would have reset the timer via
+ // `cancelPendingSessionEndPush`). A Player `updatedAt` newer than
+ // pauseStart therefore came from another device of this author —
+ // that device is still active, leave the baseline intact so its
+ // eventual pause reports cumulatively.
+ if let updatedAt = state.updatedAt, updatedAt > pauseStart {
+ syncMonitor.note("push(pause): skipped (peer device active)")
+ return
+ }
+ guard let baseline = state.snapshot else {
+ syncMonitor.note("push(pause): skipped (no session snapshot)")
+ return
+ }
+ let current = store.movesSnapshot(for: gameID, by: localAuthorID, on: nil)
+ let added = current.filled.subtracting(baseline.filled).count
+ let cleared = current.cleared.subtracting(baseline.cleared).count
+ // Session is over from this device's perspective. Clear the shared
+ // snapshot regardless of whether we actually send a push — no-edit
+ // sessions, no-recipient sessions, and completed games all still
+ // need the field cleared so a future begin re-anchors fresh.
+ store.setSessionSnapshot(nil, for: gameID, by: localAuthorID)
+ await syncEngine.enqueuePlayer(
gameID: gameID,
- snapshot: store.movesSnapshot(
- for: gameID,
- by: localAuthorID,
- on: RecordSerializer.localDeviceID
- )
+ authorID: localAuthorID,
+ reason: "session-end"
)
- guard counts.added > 0 || counts.cleared > 0 else {
+ guard added > 0 || cleared > 0 else {
syncMonitor.note("push(pause): skipped (no edits)")
return
}
@@ -717,7 +767,7 @@ final class AppServices {
syncMonitor.note("push(pause): skipped (no recipients)")
return
}
- // Symmetric with `publishSessionStartPush`: a finished or revoked
+ // Symmetric with `publishSessionBeginPush`: a finished or revoked
// game has no live play session, so a pause summary is meaningless.
guard plan.completedAt == nil else {
syncMonitor.note("push(pause): skipped (game completed)")
@@ -730,11 +780,11 @@ final class AppServices {
let playerName = preferences.name.isEmpty ? "A player" : preferences.name
let puzzleSuffix = plan.title.isEmpty ? "the puzzle" : "the puzzle '\(plan.title)'"
var parts: [String] = []
- if counts.added > 0 {
- parts.append("added \(counts.added) \(counts.added == 1 ? "letter" : "letters")")
+ if added > 0 {
+ parts.append("added \(added) \(added == 1 ? "letter" : "letters")")
}
- if counts.cleared > 0 {
- parts.append("cleared \(counts.cleared) \(counts.cleared == 1 ? "letter" : "letters")")
+ if cleared > 0 {
+ parts.append("cleared \(cleared) \(cleared == 1 ? "letter" : "letters")")
}
let action = parts.joined(separator: " and ")
await pushClient.publish(
@@ -1319,7 +1369,7 @@ final class AppServices {
}
}
// Session-start notifications now ride on sender-side APNs
- // (see `publishSessionStartPush`); the receiver-side
+ // (see `publishSessionBeginPush`); the receiver-side
// `presentBegins` path is no longer wired up. The catch-up
// banner that summarises peer adds/clears still consumes the
// SessionMonitor buckets via `consumeOnOpen` — see
diff --git a/Crossmate/Services/LocalSessionTracker.swift b/Crossmate/Services/LocalSessionTracker.swift
@@ -1,53 +0,0 @@
-import Foundation
-
-/// A point-in-time view of this device's `MovesEntity` row for a game,
-/// partitioned by whether the cell currently holds a letter. The session
-/// tracker captures one at session start and one at session end, then diffs
-/// each set to derive net adds and net clears.
-struct LocalMovesSnapshot: Equatable, Sendable, Codable {
- /// Positions where the local Moves row currently has a non-empty letter.
- let filled: Set<GridPosition>
- /// Positions where the local Moves row currently has an explicit empty
- /// entry (cells the user cleared, including ones a peer had filled).
- let cleared: Set<GridPosition>
-
- static let empty = LocalMovesSnapshot(filled: [], cleared: [])
-
- func encoded() throws -> Data {
- try JSONEncoder().encode(self)
- }
-
- static func decode(_ data: Data) throws -> LocalMovesSnapshot {
- try JSONDecoder().decode(LocalMovesSnapshot.self, from: data)
- }
-}
-
-/// Snapshots the local Moves row at session start, then diffs against the
-/// same row at session end to build the body text of the pause APN. Net
-/// delta — not keystroke count — so overtypes and trial-and-error don't
-/// inflate the figure. Reset on every `begin(gameID:snapshot:)` so a
-/// background/foreground cycle on the same puzzle starts each segment from
-/// a fresh baseline.
-@MainActor
-final class LocalSessionTracker {
- private(set) var activeGameID: UUID?
- private var baseline: LocalMovesSnapshot = .empty
-
- func begin(gameID: UUID, snapshot: LocalMovesSnapshot) {
- activeGameID = gameID
- baseline = snapshot
- }
-
- /// Diffs `snapshot` against the baseline captured at `begin` and clears
- /// state. A nil or mismatched `gameID` returns zero counts and leaves
- /// state untouched, so a stray end-call from a stale view doesn't drain a
- /// fresh session.
- func consume(gameID: UUID, snapshot: LocalMovesSnapshot) -> (added: Int, cleared: Int) {
- guard gameID == activeGameID else { return (0, 0) }
- let added = snapshot.filled.subtracting(baseline.filled).count
- let cleared = snapshot.cleared.subtracting(baseline.cleared).count
- baseline = .empty
- activeGameID = nil
- return (added, cleared)
- }
-}
diff --git a/Crossmate/Sync/RecordApplier.swift b/Crossmate/Sync/RecordApplier.swift
@@ -193,6 +193,7 @@ extension SyncEngine {
}
let incomingReadAt = RecordSerializer.parsePlayerReadAt(from: record)
entity.readAt = incomingReadAt
+ entity.sessionSnapshot = RecordSerializer.parsePlayerSessionSnapshot(from: record)
if authorID == localAuthorID, let readAt = incomingReadAt {
onReadCursor(gameID, readAt)
}
diff --git a/Crossmate/Sync/RecordBuilder.swift b/Crossmate/Sync/RecordBuilder.swift
@@ -99,6 +99,7 @@ extension SyncEngine {
updatedAt: updatedAt,
selection: selection,
readAt: entity.game?.lastReadOtherMoveAt,
+ sessionSnapshot: entity.sessionSnapshot,
zone: zoneID,
systemFields: entity.ckSystemFields
)
diff --git a/Crossmate/Sync/RecordSerializer.swift b/Crossmate/Sync/RecordSerializer.swift
@@ -247,6 +247,7 @@ enum RecordSerializer {
updatedAt: Date,
selection: PlayerSelection?,
readAt: Date? = nil,
+ sessionSnapshot: Data? = nil,
zone: CKRecordZone.ID,
systemFields: Data?
) -> CKRecord {
@@ -275,6 +276,11 @@ enum RecordSerializer {
} else {
record["readAt"] = nil
}
+ if let sessionSnapshot {
+ record["sessionSnapshot"] = sessionSnapshot as CKRecordValue
+ } else {
+ record["sessionSnapshot"] = nil
+ }
return record
}
@@ -304,6 +310,15 @@ enum RecordSerializer {
return PlayerSelection(row: Int(row), col: Int(col), direction: direction)
}
+ /// Reads `sessionSnapshot` off an inbound Player record — the encoded
+ /// `LocalMovesSnapshot` captured at session begin and cleared at session
+ /// end. Shared across all of the author's devices so a pause push from
+ /// any device reports the cumulative session delta. Returns `nil` when
+ /// no session is active or the field is missing from older records.
+ static func parsePlayerSessionSnapshot(from record: CKRecord) -> Data? {
+ record["sessionSnapshot"] as? Data
+ }
+
/// Parses an incoming `Player` record name back into its `(gameID,
/// authorID)` components. Returns `nil` if the name doesn't match the
/// `player-<UUID>-<authorID>` shape.
diff --git a/Tests/Unit/GameStoreSessionSnapshotTests.swift b/Tests/Unit/GameStoreSessionSnapshotTests.swift
@@ -0,0 +1,145 @@
+import CoreData
+import Foundation
+import Testing
+
+@testable import Crossmate
+
+/// Pins down the shared per-author session-snapshot field on `PlayerEntity`.
+/// The sender-side pause push anchors against this baseline so the cumulative
+/// delta spans every device of an author, not just the device emitting the
+/// push (see `AppServices.anchorSessionSnapshot` / `publishSessionEndPush`).
+@Suite("GameStore session snapshot", .isolatedNotificationState)
+@MainActor
+struct GameStoreSessionSnapshotTests {
+
+ private static let authorID = "author-1"
+
+ private static let puzzleSource = """
+ Title: Test Puzzle
+ Author: Test
+
+
+ ABC
+ D#E
+ FGH
+
+
+ A1. Across 1 ~ ABC
+ A4. Across 4 ~ DE
+ A5. Across 5 ~ FGH
+ D1. Down 1 ~ ADF
+ D2. Down 2 ~ BG
+ D3. Down 3 ~ CEH
+ """
+
+ private struct Fixture {
+ let persistence: PersistenceController
+ let store: GameStore
+ let gameID: UUID
+ }
+
+ private func makeFixture() throws -> Fixture {
+ let persistence = makeTestPersistence()
+ let store = makeTestStore(persistence: persistence)
+ let ctx = persistence.viewContext
+ let gameID = UUID()
+ let xd = try XD.parse(Self.puzzleSource)
+ let entity = GameEntity(context: ctx)
+ entity.id = gameID
+ entity.title = "Test"
+ entity.puzzleSource = Self.puzzleSource
+ entity.createdAt = Date()
+ entity.updatedAt = Date()
+ entity.ckRecordName = "game-\(gameID.uuidString)"
+ entity.populateCachedSummaryFields(from: Puzzle(xd: xd))
+ try ctx.save()
+ return Fixture(persistence: persistence, store: store, gameID: gameID)
+ }
+
+ private func snapshot(filled: [(Int, Int)] = [], cleared: [(Int, Int)] = []) -> LocalMovesSnapshot {
+ LocalMovesSnapshot(
+ filled: Set(filled.map { GridPosition(row: $0.0, col: $0.1) }),
+ cleared: Set(cleared.map { GridPosition(row: $0.0, col: $0.1) })
+ )
+ }
+
+ @Test("sessionState returns (nil, nil) when no PlayerEntity exists")
+ func sessionStateMissingRow() throws {
+ let fixture = try makeFixture()
+ let state = fixture.store.sessionState(for: fixture.gameID, by: Self.authorID)
+ #expect(state.snapshot == nil)
+ #expect(state.updatedAt == nil)
+ }
+
+ @Test("setSessionSnapshot creates a stub PlayerEntity and round-trips the value")
+ func setSessionSnapshotCreatesStub() throws {
+ let fixture = try makeFixture()
+ let baseline = snapshot(filled: [(0, 0), (0, 1)], cleared: [(2, 2)])
+ let now = Date()
+
+ fixture.store.setSessionSnapshot(baseline, for: fixture.gameID, by: Self.authorID, now: now)
+
+ let state = fixture.store.sessionState(for: fixture.gameID, by: Self.authorID)
+ #expect(state.snapshot == baseline)
+ #expect(state.updatedAt == now)
+ }
+
+ @Test("setSessionSnapshot(nil) clears the field while leaving the row intact")
+ func setSessionSnapshotClears() throws {
+ let fixture = try makeFixture()
+ let baseline = snapshot(filled: [(0, 0)])
+ fixture.store.setSessionSnapshot(baseline, for: fixture.gameID, by: Self.authorID)
+
+ let clearAt = Date().addingTimeInterval(10)
+ fixture.store.setSessionSnapshot(nil, for: fixture.gameID, by: Self.authorID, now: clearAt)
+
+ let state = fixture.store.sessionState(for: fixture.gameID, by: Self.authorID)
+ #expect(state.snapshot == nil)
+ // updatedAt is still bumped — the clear write is itself activity that
+ // peer devices' suppression checks must see.
+ #expect(state.updatedAt == clearAt)
+ }
+
+ @Test("setSessionSnapshot bumps updatedAt on every write")
+ func setSessionSnapshotBumpsUpdatedAt() throws {
+ let fixture = try makeFixture()
+ let first = Date()
+ fixture.store.setSessionSnapshot(snapshot(filled: [(0, 0)]), for: fixture.gameID, by: Self.authorID, now: first)
+ let firstState = fixture.store.sessionState(for: fixture.gameID, by: Self.authorID)
+ #expect(firstState.updatedAt == first)
+
+ let second = first.addingTimeInterval(60)
+ fixture.store.setSessionSnapshot(snapshot(filled: [(0, 0), (0, 1)]), for: fixture.gameID, by: Self.authorID, now: second)
+ let secondState = fixture.store.sessionState(for: fixture.gameID, by: Self.authorID)
+ #expect(secondState.updatedAt == second)
+ #expect(secondState.snapshot == snapshot(filled: [(0, 0), (0, 1)]))
+ }
+
+ @Test("setSessionSnapshot reuses an existing PlayerEntity rather than creating a duplicate")
+ func setSessionSnapshotReusesRow() throws {
+ let fixture = try makeFixture()
+ let ctx = fixture.persistence.viewContext
+ let recordName = RecordSerializer.recordName(
+ forPlayerInGame: fixture.gameID,
+ authorID: Self.authorID
+ )
+ let gameReq = NSFetchRequest<GameEntity>(entityName: "GameEntity")
+ gameReq.predicate = NSPredicate(format: "id == %@", fixture.gameID as CVarArg)
+ let game = try #require(try ctx.fetch(gameReq).first)
+ let existing = PlayerEntity(context: ctx)
+ existing.game = game
+ existing.authorID = Self.authorID
+ existing.ckRecordName = recordName
+ existing.name = "Original"
+ existing.updatedAt = Date()
+ try ctx.save()
+
+ fixture.store.setSessionSnapshot(snapshot(filled: [(1, 0)]), for: fixture.gameID, by: Self.authorID)
+
+ let req = NSFetchRequest<PlayerEntity>(entityName: "PlayerEntity")
+ req.predicate = NSPredicate(format: "ckRecordName == %@", recordName)
+ let rows = try ctx.fetch(req)
+ #expect(rows.count == 1)
+ #expect(rows.first?.name == "Original")
+ }
+}