commit e9ddbc55ba936ba15acfdec7c6fb93735258d0e3
parent 49b44688003889ee15894ddee6182c7edacd813c
Author: Michael Camilleri <[email protected]>
Date: Wed, 20 May 2026 09:11:10 +0900
Replace .opened/.closed lease pings with Player.readAt
This commit wires the cursor from the previous commit. Inbound Player records
whose authorID matches the local user surface their readAt via a new
SyncEngine.onIncomingReadCursor callback; GameStore.noteIncomingReadCursor
merges it into lastReadOtherMoveAt as a monotonic max and fires the badge
signal. The Player buildRecord provider now writes the live cursor, so the
PuzzleDisplay lifecycle (grid open, scenePhase .active/.background,
onDisappear) just re-enqueues the Player record via the new
AppServices.publishReadCursor — CKSyncEngine coalesces pending saves so rapid
back-to-back triggers cost at most one round trip.
With the badge job covered, the rest of the lease subsystem comes out:
PingKind.opened/.closed, OpenedLease, enqueueOpenedPing/Closed,
applyOpenedPing, broadcastOpenedPing/Closed, refreshActiveLease and
closeActiveLease, sweepStaleOpenedPings (and openedPingTTL), the account-zone
branch of knownZones (no pings live there any more), the remote-lease half of
NotificationState (noteRemoteLease, isRemotelyActive, backing keys,
openLeaseDuration, leaseRefreshInterval, hourly sweep throttle), and the
every-2-min refresh inside pollOpenSyncedPuzzle. isSuppressed forwards to
isActive — same name, no caller churn. dismissDeliveredNotifications absorbs
withdrawDeliveredNotifications now that the .opened side effect is gone.
The internal 'seen' vocabulary is renamed in lockstep with the wire field.
GameEntity.lastSeenOtherMoveAt becomes lastReadOtherMoveAt — with
renamingIdentifier set and shouldMigrate/shouldInferMappingModel enabled on the
persistent store so existing on-disk values carry through lightweight
migration. The Swift surface follows: markOtherMovesRead,
unreadOtherMovesGameCount, hasUnreadOtherMoves, onUnreadOtherMovesChanged, the
new noteIncomingReadCursor(readAt:), SyncEngine's read-cursor callback set, and
GameListView's showsUnreadBadge. Test file is now
GameStoreUnreadMovesTests.swift.
Old .opened/.closed records still sit in each user's private-DB account zone,
which the developer Dashboard can only reach for one user. A new
SyncEngine.purgeLegacyLeasePings_v1 — gated by an App-Group flag
(NotificationState.legacyLeasePurgeNeeded/markLegacyLeasePurged), fired once
from start() — drains this device's slot on first launch and is slated for
removal in a future release. The Player.readAt schema field must be deployed to
Production before this build ships.
Co-Authored-By: Claude Opus 4.7 <[email protected]>
Diffstat:
15 files changed, 594 insertions(+), 1000 deletions(-)
diff --git a/Crossmate.xcodeproj/project.pbxproj b/Crossmate.xcodeproj/project.pbxproj
@@ -31,7 +31,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 */; };
- 453E30B78DFB4B689D70EE2C /* GameStoreUnseenMovesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3916C52FE26549625BD18A4 /* GameStoreUnseenMovesTests.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 */; };
4A89595E3F6AB50E1D9E6BA8 /* ImportService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 462CE0FD356F6137C9BFD30F /* ImportService.swift */; };
@@ -81,7 +81,6 @@
C1D97A4CD02BC9C22C4208BB /* NYTAuthServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED8154F949E1D94252F70765 /* NYTAuthServiceTests.swift */; };
C30C0C4E54E4209A22843872 /* CrossmateModel.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = F93AC31640C40FCC039570A3 /* CrossmateModel.xcdatamodeld */; };
C7370BCAD585EEFD366204E3 /* GridThumbnailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D9BB7D9759D27F7BA6734FDE /* GridThumbnailView.swift */; };
- C78698428F60300D25FC8694 /* OpenedLeaseTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B5DBE77D644674456D95627 /* OpenedLeaseTests.swift */; };
C89A15D812E372FE1C56039B /* PUZToXDConverterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE193CAB325C991952D7CE5 /* PUZToXDConverterTests.swift */; };
C8ACF431021E7BEE61A99153 /* FriendController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E655698481325C92EF5C348B /* FriendController.swift */; };
C944A5BD871C6ECC64DE8A5B /* CalendarDayCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0CAA5E17BD406AFEEF96196 /* CalendarDayCell.swift */; };
@@ -120,7 +119,6 @@
/* Begin PBXFileReference section */
07C57DEE9E0EFA684D8BD00B /* NYTLoginView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NYTLoginView.swift; sourceTree = "<group>"; };
- 0B5DBE77D644674456D95627 /* OpenedLeaseTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenedLeaseTests.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>"; };
11BF168D5C1CD85DAE5CAF9E /* PlayerSelectionPublisher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerSelectionPublisher.swift; sourceTree = "<group>"; };
@@ -133,6 +131,7 @@
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>"; };
+ 31C534911020BE4ED2E5065D /* GameStoreUnreadMovesTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameStoreUnreadMovesTests.swift; sourceTree = "<group>"; };
3292748EAE27B608C769D393 /* PlayerRoster.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerRoster.swift; sourceTree = "<group>"; };
33878A29B09A6154C7A63C82 /* KeychainHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainHelper.swift; sourceTree = "<group>"; };
38DDAD9D6470A894C3FD6F90 /* GameListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameListView.swift; sourceTree = "<group>"; };
@@ -202,7 +201,6 @@
CAB4BB9E160C3A59C653E7A9 /* GridView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GridView.swift; sourceTree = "<group>"; };
CBDC81CA6A9C80EB31E7F493 /* AppServices.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppServices.swift; sourceTree = "<group>"; };
D2F03A9F357672533E2A8DB0 /* PuzzleNotificationText.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PuzzleNotificationText.swift; sourceTree = "<group>"; };
- D3916C52FE26549625BD18A4 /* GameStoreUnseenMovesTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameStoreUnseenMovesTests.swift; sourceTree = "<group>"; };
D491B7232333AA8957732387 /* PendingEditFlagTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PendingEditFlagTests.swift; sourceTree = "<group>"; };
D97CBA409832A24D64DF0F5C /* Crossmate Unit Tests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = "Crossmate Unit Tests.xctest"; sourceTree = BUILT_PRODUCTS_DIR; };
D9BB7D9759D27F7BA6734FDE /* GridThumbnailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GridThumbnailView.swift; sourceTree = "<group>"; };
@@ -267,14 +265,13 @@
children = (
60E818B0F4689BAD57660B7C /* GameCursorStoreTests.swift */,
BFC1C59A30FB2571598273E4 /* GameMutatorTests.swift */,
- D3916C52FE26549625BD18A4 /* GameStoreUnseenMovesTests.swift */,
+ 31C534911020BE4ED2E5065D /* GameStoreUnreadMovesTests.swift */,
6C7F3A9BD7FAF81CB77032A6 /* GridStateMergerTests.swift */,
9AF6157D97271205626E207C /* MovesUpdaterTests.swift */,
FEDD63AD5E33E2B0399780EF /* NotificationNavigationBrokerTests.swift */,
47532AED239AEF476D8E9206 /* NotificationStateTests.swift */,
ED8154F949E1D94252F70765 /* NYTAuthServiceTests.swift */,
C54223FED97577A593B7964E /* NYTToXDConverterTests.swift */,
- 0B5DBE77D644674456D95627 /* OpenedLeaseTests.swift */,
D491B7232333AA8957732387 /* PendingEditFlagTests.swift */,
5DE04D53EC3BC7D2DA0093C3 /* PlayerNamePublisherTests.swift */,
1813630FA05C194AFF43855C /* PlayerRosterTests.swift */,
@@ -535,7 +532,7 @@
712A2764596A2D17A0BBBF3B /* FriendZoneTests.swift in Sources */,
85A798525FE1DC98210A9E82 /* GameCursorStoreTests.swift in Sources */,
2C0DFC182240A2519ED1FA6A /* GameMutatorTests.swift in Sources */,
- 453E30B78DFB4B689D70EE2C /* GameStoreUnseenMovesTests.swift in Sources */,
+ 449B0A09A36B276C93CFB9A4 /* GameStoreUnreadMovesTests.swift in Sources */,
AACC9F70AEEDCB3360FFDEFF /* GridStateMergerTests.swift in Sources */,
DE90CC8BE23A0EFC4A32FFA5 /* MovesInboundTests.swift in Sources */,
C1930083671621AC79CF95DD /* MovesUpdaterTests.swift in Sources */,
@@ -543,7 +540,6 @@
AA38A51862FC0AB8F7D34899 /* NYTToXDConverterTests.swift in Sources */,
18D5BB584DBF92A2EC580AEA /* NotificationNavigationBrokerTests.swift in Sources */,
E632562D090D8BE907F28C53 /* NotificationStateTests.swift in Sources */,
- C78698428F60300D25FC8694 /* OpenedLeaseTests.swift in Sources */,
C89A15D812E372FE1C56039B /* PUZToXDConverterTests.swift in Sources */,
A458AF9CA8579AB51B695B08 /* PendingChangeReapTests.swift in Sources */,
8225918652DCC822CA1C862F /* PendingEditFlagTests.swift in Sources */,
diff --git a/Crossmate/CrossmateApp.swift b/Crossmate/CrossmateApp.swift
@@ -428,6 +428,7 @@ private struct PuzzleDisplayView: View {
loadingMessage = "Loading puzzle..."
updateActiveNotificationPuzzleID(for: scenePhase)
Task { await services.dismissDeliveredNotifications(for: gameID) }
+ Task { await services.publishReadCursor(for: gameID) }
// Tapping an `.invite` ping notification navigates here at once,
// but the CKShare that materialises this game's `GameEntity` is
@@ -486,16 +487,15 @@ private struct PuzzleDisplayView: View {
.onChange(of: scenePhase) { _, newPhase in
updateActiveNotificationPuzzleID(for: newPhase)
// Only act on settled transitions. `.inactive` is transient (lock
- // animation, app switcher, Control Center, banners) — sending a
- // ping on it thrashed the lease open/closed many times per
- // lock/unlock. `.background` ends it promptly so siblings stop
- // suppressing; `.active` re-opens it without waiting for the poll.
+ // animation, app switcher, Control Center, banners), so a write
+ // there would thrash the Player record on every lock/unlock.
+ // `.background` publishes the cursor so sibling devices catch up
+ // promptly; `.active` republishes on resume in case moves arrived
+ // (and were marked seen in lockstep) while we were foregrounded.
let id = gameID
switch newPhase {
- case .active:
- Task { await services.refreshActiveLease(for: id) }
- case .background:
- Task { await services.closeActiveLease(for: id) }
+ case .active, .background:
+ Task { await services.publishReadCursor(for: id) }
case .inactive:
break
@unknown default:
@@ -512,7 +512,7 @@ private struct PuzzleDisplayView: View {
Task {
await movesUpdater.flush()
await selectionPublisher.clear()
- await services.closeActiveLease(for: id)
+ await services.publishReadCursor(for: id)
}
}
}
@@ -600,9 +600,6 @@ private struct PuzzleDisplayView: View {
private func pollOpenSyncedPuzzle() async {
guard let scope = syncedScope else { return }
await services.freshenPuzzleGrid(gameID: gameID, scope: scope, reason: .appeared)
- // The open ping fired alongside the view appearing; seed from now so
- // the first refresh lands one interval later.
- var lastLeaseSentAt = Date()
while !Task.isCancelled {
let interval = services.hadRecentRemoteNotification(within: Self.activityWindow)
? Self.activePollingInterval
@@ -614,18 +611,6 @@ private struct PuzzleDisplayView: View {
}
guard !Task.isCancelled else { break }
guard let scope = syncedScope else { break }
- // Only refresh the lease while this puzzle is genuinely the
- // foreground-active one. The poll loop isn't cancelled when the
- // app backgrounds (the view stays alive), so without this gate a
- // backgrounded puzzle kept re-broadcasting "I'm viewing" every
- // interval. `.background` already sent `.closed`; resume on
- // return to `.active`.
- if NotificationState.activePuzzleID() == gameID,
- Date().timeIntervalSince(lastLeaseSentAt)
- >= NotificationState.leaseRefreshInterval {
- await services.refreshActiveLease(for: gameID)
- lastLeaseSentAt = Date()
- }
await services.freshenPuzzleGrid(gameID: gameID, scope: scope, reason: .poll)
}
}
diff --git a/Crossmate/Models/CrossmateModel.xcdatamodeld/CrossmateModel.xcdatamodel/contents b/Crossmate/Models/CrossmateModel.xcdatamodeld/CrossmateModel.xcdatamodel/contents
@@ -18,7 +18,7 @@
<attribute name="hasPendingSave" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="id" attributeType="UUID" usesScalarValueType="NO"/>
<attribute name="isAccessRevoked" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
- <attribute name="lastSeenOtherMoveAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
+ <attribute name="lastReadOtherMoveAt" optional="YES" attributeType="Date" usesScalarValueType="NO" renamingIdentifier="lastSeenOtherMoveAt"/>
<attribute name="lastSyncedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="latestOtherMoveAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="puzzleResourceID" optional="YES" attributeType="String"/>
diff --git a/Crossmate/Persistence/GameStore.swift b/Crossmate/Persistence/GameStore.swift
@@ -30,7 +30,7 @@ struct GameSummary: Identifiable, Equatable {
/// a share (participant, `databaseScope == 1`).
let isShared: Bool
let isAccessRevoked: Bool
- let hasUnseenOtherMoves: Bool
+ let hasUnreadOtherMoves: Bool
init?(entity: GameEntity) {
guard let id = entity.id else { return nil }
@@ -108,23 +108,23 @@ struct GameSummary: Identifiable, Equatable {
self.isOwned = entity.databaseScope == 0
self.isShared = entity.ckShareRecordName != nil || entity.databaseScope == 1
self.isAccessRevoked = entity.isAccessRevoked
- self.hasUnseenOtherMoves = Self.computeHasUnseen(
+ self.hasUnreadOtherMoves = Self.computeHasUnread(
isShared: self.isShared,
completedAt: entity.completedAt,
latest: entity.latestOtherMoveAt,
- lastSeen: entity.lastSeenOtherMoveAt
+ lastRead: entity.lastReadOtherMoveAt
)
}
- fileprivate static func computeHasUnseen(
+ fileprivate static func computeHasUnread(
isShared: Bool,
completedAt: Date?,
latest: Date?,
- lastSeen: Date?
+ lastRead: Date?
) -> Bool {
guard isShared, completedAt == nil, let latest else { return false }
- guard let lastSeen else { return true }
- return latest > lastSeen
+ guard let lastRead else { return true }
+ return latest > lastRead
}
}
@@ -150,7 +150,7 @@ final class GameSummaryCache {
let updatedAt: Date?
let completedAt: Date?
let latestOther: Date?
- let lastSeenOther: Date?
+ let lastReadOther: Date?
let scope: Int16
let shareName: String?
let revoked: Bool
@@ -162,7 +162,7 @@ final class GameSummaryCache {
updatedAt: entity.updatedAt,
completedAt: entity.completedAt,
latestOther: entity.latestOtherMoveAt,
- lastSeenOther: entity.lastSeenOtherMoveAt,
+ lastReadOther: entity.lastReadOtherMoveAt,
scope: entity.databaseScope,
shareName: entity.ckShareRecordName,
revoked: entity.isAccessRevoked
@@ -234,7 +234,7 @@ final class GameStore {
/// may have changed (inbound moves merged, a game opened, a game
/// deleted). Consumers refresh the app-icon badge from here.
@ObservationIgnored
- var onUnseenOtherMovesChanged: (() -> Void)?
+ var onUnreadOtherMovesChanged: (() -> Void)?
init(
persistence: PersistenceController,
@@ -305,7 +305,7 @@ final class GameStore {
/// most recent sync batch; for each, we scan the now-persisted
/// `MovesEntity` rows and pick the latest `updatedAt` whose row is owned
/// by a different `authorID` than the local user. If the game is currently
- /// open, `lastSeenOtherMoveAt` is advanced in lockstep so the badge
+ /// open, `lastReadOtherMoveAt` is advanced in lockstep so the badge
/// doesn't appear for activity the user is already watching.
func noteIncomingMovesUpdate(gameIDs: Set<UUID>, currentAuthorID: String?) {
guard let currentAuthorID, !gameIDs.isEmpty else { return }
@@ -330,34 +330,35 @@ final class GameStore {
}
// Use the foreground-visible signal rather than `currentEntity` —
// the latter stays set after a normal back-out from the puzzle,
- // and would otherwise advance lastSeenOtherMoveAt in lockstep
+ // and would otherwise advance lastReadOtherMoveAt in lockstep
// even when the user is sitting on the library list, suppressing
// the badge that should appear there.
- // Suppressed = viewing here (incl. the local leave-grace) or a
- // sibling device holds an open lease — either way the user has
- // eyes on these moves, so keep the badge in lockstep.
+ // Suppressed = viewing here (incl. the local leave-grace) — the
+ // user has eyes on these moves, so keep the badge in lockstep.
+ // Sibling devices learn about the read via `Player.readAt`
+ // (published on grid open/background/dismiss), not from this path.
if NotificationState.isSuppressed(gameID: gameID) {
- entity.lastSeenOtherMoveAt = entity.latestOtherMoveAt
+ entity.lastReadOtherMoveAt = entity.latestOtherMoveAt
}
}
if context.hasChanges {
try? context.save()
}
- onUnseenOtherMovesChanged?()
+ onUnreadOtherMovesChanged?()
}
/// Number of shared games with unseen other-author moves — the same
- /// `hasUnseenOtherMoves` heuristic the library list uses, aggregated as
+ /// `hasUnreadOtherMoves` heuristic the library list uses, aggregated as
/// a count for the app-icon badge.
- func unseenOtherMovesGameCount() -> Int {
+ func unreadOtherMovesGameCount() -> Int {
let request = NSFetchRequest<NSNumber>(entityName: "GameEntity")
request.resultType = .countResultType
request.predicate = NSPredicate(
format: "(databaseScope == 1 OR ckShareRecordName != nil) "
+ "AND completedAt == nil "
+ "AND latestOtherMoveAt != nil "
- + "AND (lastSeenOtherMoveAt == nil OR latestOtherMoveAt > lastSeenOtherMoveAt)"
+ + "AND (lastReadOtherMoveAt == nil OR latestOtherMoveAt > lastReadOtherMoveAt)"
)
return (try? context.count(for: request)) ?? 0
}
@@ -382,7 +383,7 @@ final class GameStore {
currentGame = game
currentMutator = mutator
currentEntity = entity
- markOtherMovesSeen(for: entity)
+ markOtherMovesRead(for: entity)
return (game, mutator)
}
@@ -482,7 +483,7 @@ final class GameStore {
context.delete(entity)
try context.save()
onGameDeleted(deletion)
- onUnseenOtherMovesChanged?()
+ onUnreadOtherMovesChanged?()
}
// MARK: - Resign a game
@@ -556,7 +557,7 @@ final class GameStore {
currentGame = nil
currentMutator = nil
currentEntity = nil
- onUnseenOtherMovesChanged?()
+ onUnreadOtherMovesChanged?()
}
// MARK: - Legacy convenience
@@ -584,7 +585,7 @@ final class GameStore {
currentGame = game
currentMutator = mutator
currentEntity = entity
- markOtherMovesSeen(for: entity)
+ markOtherMovesRead(for: entity)
return (game, mutator)
}
@@ -598,26 +599,34 @@ final class GameStore {
return try context.fetch(request).first
}
- /// Marks any unseen other-author moves for `gameID` as seen without
- /// loading the game or disturbing the current-game pointer. Driven by
- /// `.opened` pings so a sibling device that opened the puzzle is treated
- /// as "the user saw the moves," keeping the badge in agreement across
- /// the user's devices.
- func markOtherMovesSeenWithoutLoading(gameID: UUID) {
+ /// Advances the per-account "read other-author moves" cursor to `readAt`
+ /// if it is later than the local value. The incoming timestamp is a
+ /// sibling device's claim of how far it has read; we treat it as a
+ /// monotonic floor on what this device should also consider read. Activity
+ /// newer than that point still badges normally. Replaces the `.opened`
+ /// lease ping's cross-device badge clear with a durable cursor carried on
+ /// the `Player` record.
+ func noteIncomingReadCursor(gameID: UUID, readAt: Date) {
let request = NSFetchRequest<GameEntity>(entityName: "GameEntity")
request.predicate = NSPredicate(format: "id == %@", gameID as CVarArg)
request.fetchLimit = 1
guard let entity = try? context.fetch(request).first else { return }
- markOtherMovesSeen(for: entity)
+ let isShared = entity.ckShareRecordName != nil || entity.databaseScope == 1
+ guard isShared else { return }
+ if (entity.lastReadOtherMoveAt ?? .distantPast) < readAt {
+ entity.lastReadOtherMoveAt = readAt
+ try? context.save()
+ onUnreadOtherMovesChanged?()
+ }
}
- private func markOtherMovesSeen(for entity: GameEntity) {
+ private func markOtherMovesRead(for entity: GameEntity) {
let isShared = entity.ckShareRecordName != nil || entity.databaseScope == 1
guard isShared, let latest = entity.latestOtherMoveAt else { return }
- if (entity.lastSeenOtherMoveAt ?? .distantPast) < latest {
- entity.lastSeenOtherMoveAt = latest
+ if (entity.lastReadOtherMoveAt ?? .distantPast) < latest {
+ entity.lastReadOtherMoveAt = latest
try? context.save()
- onUnseenOtherMovesChanged?()
+ onUnreadOtherMovesChanged?()
}
}
@@ -811,7 +820,7 @@ final class GameStore {
currentMutator = nil
currentEntity = nil
}
- onUnseenOtherMovesChanged?()
+ onUnreadOtherMovesChanged?()
}
/// Flips the active game's mutator to shared after `ShareController`
diff --git a/Crossmate/Persistence/PersistenceController.swift b/Crossmate/Persistence/PersistenceController.swift
@@ -25,6 +25,16 @@ final class PersistenceController {
let description = NSPersistentStoreDescription()
description.type = NSInMemoryStoreType
container.persistentStoreDescriptions = [description]
+ } else {
+ // Enable lightweight migration so additive schema changes — and
+ // attribute renames carrying a `renamingIdentifier` in the model
+ // — apply on launch without a hand-written mapping. Without these
+ // flags a model edit fails the loadPersistentStores call and the
+ // app refuses to open the existing store.
+ for description in container.persistentStoreDescriptions {
+ description.shouldMigrateStoreAutomatically = true
+ description.shouldInferMappingModelAutomatically = true
+ }
}
container.loadPersistentStores { _, error in
diff --git a/Crossmate/Services/AppServices.swift b/Crossmate/Services/AppServices.swift
@@ -201,7 +201,7 @@ final class AppServices {
nytAuth.loadStoredSession()
driveMonitor.start()
- store.onUnseenOtherMovesChanged = { [weak self] in
+ store.onUnreadOtherMovesChanged = { [weak self] in
guard let self else { return }
Task { await self.refreshAppBadge() }
}
@@ -247,6 +247,15 @@ final class AppServices {
await self?.reconcileFriendships(forGameIDs: gameIDs)
}
+ // A sibling device of the same iCloud account has recorded how far
+ // it has seen the collaborator's moves; merge it into our cursor so
+ // the unseen-moves badge agrees without a presence stream.
+ await syncEngine.setOnIncomingReadCursor { [store] pairs in
+ for (gameID, readAt) in pairs {
+ store.noteIncomingReadCursor(gameID: gameID, readAt: readAt)
+ }
+ }
+
await syncEngine.setOnPings { [weak self] pings in
guard let self else { return }
await self.presentPings(pings)
@@ -271,7 +280,7 @@ final class AppServices {
await syncEngine.setOnPingDeleted { [weak self] gameIDs in
guard let self else { return }
for gameID in gameIDs {
- await self.withdrawDeliveredNotifications(for: gameID)
+ await self.dismissDeliveredNotifications(for: gameID)
}
}
@@ -1145,22 +1154,14 @@ final class AppServices {
private func presentPings(_ pings: [Ping]) async {
guard !pings.isEmpty else { return }
applyInvitePings(pings)
- // `.opened`/`.closed` and `.friend` are system-only — no alert, so
- // they run without notification authorization. `.opened`/`.closed`
- // drive the cross-device lease (notification dismissal / badge);
- // `.friend` is the friendship-bootstrap handshake.
+ // `.friend` is the friendship-bootstrap handshake — system-only, no
+ // alert, runs without notification authorization. Everything else
+ // (the player-facing kinds) goes through the alert path below.
let (systemPings, playerFacingPings) = pings.partitioned {
- $0.kind == .opened || $0.kind == .closed || $0.kind == .friend
- }
- for ping in systemPings {
- switch ping.kind {
- case .opened, .closed:
- await applyOpenedPing(ping)
- case .friend:
- await friendController.applyFriendPing(ping)
- default:
- break
- }
+ $0.kind == .friend
+ }
+ for ping in systemPings where ping.kind == .friend {
+ await friendController.applyFriendPing(ping)
}
guard !playerFacingPings.isEmpty else { return }
guard await canPresentNotifications() else {
@@ -1339,11 +1340,10 @@ final class AppServices {
case .word: return "\(player) revealed a word in \(puzzleSuffix)"
case .puzzle, .none: return "\(player) revealed \(puzzleSuffix)"
}
- case .opened, .closed, .friend:
- // System-only kinds — `.opened`/`.closed` are handled by
- // `applyOpenedPing` and `.friend` by the friendship-bootstrap
- // path; none is ever presented as a notification. If this text
- // surfaces in a log or alert, `presentPings` dispatch has broken.
+ case .friend:
+ // System-only kind handled by the friendship-bootstrap path;
+ // never presented as a notification. If this text surfaces in
+ // a log or alert, `presentPings` dispatch has broken.
return "system-only ping should not be presented"
case .invite:
return "\(player) invited you to \(puzzleSuffix)"
@@ -1431,25 +1431,11 @@ final class AppServices {
}
/// Removes any already-delivered local notifications for `gameID` from
- /// Notification Center and broadcasts an `.opened` ping so the user's
- /// other devices can clear their copies too. The active-puzzle guard in
- /// `presentPings` / `presentSessions` prevents *new* notifications while
- /// a game is open, but anything delivered before the user navigated in
- /// still needs to be cleared explicitly so Notification Center isn't
- /// littered with stale entries for a puzzle they're now actively viewing.
- ///
- /// The ping fires unconditionally — a sibling device may have a
- /// notification we don't, and we can't see its Notification Center.
+ /// this device's Notification Center. Sibling devices of the same iCloud
+ /// account learn about the dismissal indirectly: a directed ping is
+ /// deleted on consumption (the `onPingDeleted` path then withdraws their
+ /// copy), and the unread-moves badge converges via `Player.readAt`.
func dismissDeliveredNotifications(for gameID: UUID) async {
- await withdrawDeliveredNotifications(for: gameID)
- await broadcastOpenedPing(for: gameID)
- }
-
- /// Removes this device's delivered notifications for `gameID` without the
- /// `.opened` lease side effect — used both by `dismissDeliveredNotifications`
- /// and by the inbound ping-deletion path, where a sibling has already
- /// consumed (and deleted) the directed ping, so any copy we showed is stale.
- private func withdrawDeliveredNotifications(for gameID: UUID) async {
let center = UNUserNotificationCenter.current()
let delivered = await center.deliveredNotifications()
let identifiers = delivered.compactMap { notification -> String? in
@@ -1465,98 +1451,24 @@ final class AppServices {
}
}
- /// Applies an inbound lease ping (`.opened`/`.closed`) from another device
- /// of the same iCloud user. Both refresh the `remoteActiveUntil` lease so
- /// `isSuppressed` agrees cross-device; `.opened` additionally clears this
- /// device's matching delivered notifications and marks unseen other-author
- /// moves as seen (the sibling is watching). `.closed` only ends the lease.
- /// Self-sends — same (authorID, deviceID) — are dropped.
- private func applyOpenedPing(_ ping: Ping) async {
- if ping.authorID == identity.currentID,
- ping.deviceID == RecordSerializer.localDeviceID {
- return
- }
- if NotificationState.wasShown(pingRecordName: ping.recordName) {
- return
- }
- NotificationState.recordShown(pingRecordName: ping.recordName)
-
- // Expiry is computed on this device's clock; the sender's clock is
- // trusted only to order its own pings (see OpenedLease / noteRemoteLease).
- let nowDate = Date()
- let lease = OpenedLease.decode(ping.payload)
- let sentAtMs = lease?.sentAtMs ?? Int64(nowDate.timeIntervalSince1970 * 1000)
- let leaseMs: Int64 = ping.kind == .closed
- ? 0
- : (lease?.leaseMs ?? Int64(NotificationState.openLeaseDuration * 1000))
- NotificationState.noteRemoteLease(
- gameID: ping.gameID,
- until: nowDate.addingTimeInterval(TimeInterval(leaseMs) / 1000),
- sentAtMs: sentAtMs,
- now: nowDate
- )
-
- if ping.kind == .opened {
- let center = UNUserNotificationCenter.current()
- let delivered = await center.deliveredNotifications()
- let identifiers = delivered.compactMap { notification -> String? in
- let userInfo = notification.request.content.userInfo
- guard let raw = userInfo["crossmateGameID"] as? String,
- raw == ping.gameID.uuidString
- else { return nil }
- return notification.request.identifier
- }
- if !identifiers.isEmpty {
- center.removeDeliveredNotifications(withIdentifiers: identifiers)
- syncMonitor.note("opened ping: dismissed \(identifiers.count) delivered for \(ping.gameID.uuidString)")
- }
- store.markOtherMovesSeenWithoutLoading(gameID: ping.gameID)
- }
- await refreshAppBadge()
- }
-
- private func broadcastOpenedPing(for gameID: UUID) async {
- guard let authorID = identity.currentID, !authorID.isEmpty else {
- syncMonitor.note("opened ping: skipped — no authorID for \(gameID.uuidString)")
- return
- }
- let playerName = preferences.name
- await syncEngine.enqueueOpenedPing(
- gameID: gameID,
- authorID: authorID,
- playerName: playerName
- )
- syncMonitor.note("opened ping: enqueued for \(gameID.uuidString)")
- }
-
- private func broadcastClosedPing(for gameID: UUID) async {
+ /// Publishes this account's "read other-author moves" cursor for
+ /// `gameID` by re-enqueuing its Player record — the `buildRecord`
+ /// provider reads the live `GameEntity.lastReadOtherMoveAt` value. Called
+ /// at grid open, on background/disappear, and when the scene returns to
+ /// `.active`, so sibling devices learn the cursor without a presence
+ /// stream. CKSyncEngine coalesces pending saves to the same record, so
+ /// rapid back-to-back triggers cost at most one CloudKit round trip.
+ func publishReadCursor(for gameID: UUID) async {
guard let authorID = identity.currentID, !authorID.isEmpty else { return }
- await syncEngine.enqueueClosedPing(
- gameID: gameID,
- authorID: authorID,
- playerName: preferences.name
- )
- syncMonitor.note("closed ping: enqueued for \(gameID.uuidString)")
- }
-
- /// Re-broadcasts the open lease while the puzzle stays open (called on the
- /// poll cadence). No local Notification Center sweep — that ran at open.
- func refreshActiveLease(for gameID: UUID) async {
- await broadcastOpenedPing(for: gameID)
- }
-
- /// Best-effort early release of the lease on a clean exit. Correctness
- /// rests on lease expiry; this only shortens the sibling's window.
- func closeActiveLease(for gameID: UUID) async {
- await broadcastClosedPing(for: gameID)
+ await syncEngine.enqueuePlayerRecord(gameID: gameID, authorID: authorID)
}
- /// Sets the app icon badge to the number of shared games with unseen
- /// other-author moves — the same `hasUnseenOtherMoves` signal that drives
+ /// Sets the app icon badge to the number of shared games with unread
+ /// other-author moves — the same `hasUnreadOtherMoves` signal that drives
/// the per-row dot in the library list. Silently no-ops when the user
/// hasn't granted badge permission; iOS just won't render the value.
func refreshAppBadge() async {
- let count = store.unseenOtherMovesGameCount()
+ let count = store.unreadOtherMovesGameCount()
do {
try await UNUserNotificationCenter.current().setBadgeCount(count)
} catch {
diff --git a/Crossmate/Sync/RecordSerializer.swift b/Crossmate/Sync/RecordSerializer.swift
@@ -255,7 +255,7 @@ enum RecordSerializer {
name: String,
updatedAt: Date,
selection: PlayerSelection?,
- seenOtherAt: Date? = nil,
+ readAt: Date? = nil,
zone: CKRecordZone.ID,
systemFields: Data?
) -> CKRecord {
@@ -279,24 +279,24 @@ enum RecordSerializer {
record["selCol"] = nil
record["selDir"] = nil
}
- if let seenOtherAt {
- record["seenOtherAt"] = seenOtherAt as CKRecordValue
+ if let readAt {
+ record["readAt"] = readAt as CKRecordValue
} else {
- record["seenOtherAt"] = nil
+ record["readAt"] = nil
}
return record
}
- /// Reads `seenOtherAt` off an inbound Player record — the per-account
- /// cursor of how far this user has seen their collaborator's moves. Any
- /// of the account's own devices writes it; sibling devices merge it into
- /// `GameEntity.lastSeenOtherMoveAt` (monotonic max) so the unseen-moves
+ /// Reads `readAt` off an inbound Player record — the per-account cursor
+ /// of how far this user has read their collaborator's moves. Any of the
+ /// account's own devices writes it; sibling devices merge it into
+ /// `GameEntity.lastReadOtherMoveAt` (monotonic max) so the unread-moves
/// badge agrees across the user's devices without a presence ping.
/// Returns `nil` if the field is missing — older records, or a slot that
/// has not yet recorded a view.
- static func parsePlayerSeenOtherAt(from record: CKRecord) -> Date? {
- record["seenOtherAt"] as? Date
+ static func parsePlayerReadAt(from record: CKRecord) -> Date? {
+ record["readAt"] as? Date
}
/// Reads `selRow`/`selCol`/`selDir` off an inbound Player record. These
diff --git a/Crossmate/Sync/SyncEngine.swift b/Crossmate/Sync/SyncEngine.swift
@@ -27,9 +27,9 @@ extension Notification.Name {
}
/// What a Ping record represents. Stored as a string in the CKRecord's
-/// `kind` field. `.opened` is sent between a single iCloud user's own devices
-/// in the account zone to coordinate notification dismissal; all other kinds
-/// flow between players in a per-game zone.
+/// `kind` field. Every kind flows between players in a per-game zone (or, for
+/// `.invite`, in a friend zone); there is no longer an own-devices presence
+/// kind — `Player.readAt` carries cross-device read state instead.
enum PingKind: String, Sendable {
case join
case win
@@ -39,12 +39,6 @@ enum PingKind: String, Sendable {
case resign
case check
case reveal
- case opened
- /// Early release of an `.opened` lease — sent on a clean exit so a
- /// sibling device stops suppressing before the lease would expire on its
- /// own. Same account-zone, same-user-only routing as `.opened`.
- /// Best-effort: correctness rests on lease expiry, never on this arriving.
- case closed
/// Friendship bootstrap. Written into a shared *game* zone; carries the
/// friend-zone share URL in `payload`. System-only — never user-facing.
case friend
@@ -83,7 +77,7 @@ struct Ping: Sendable {
let scope: PingScope?
/// Kind-specific JSON. `.friend`: `{friendShareURL,pairKey,ownerAuthorID}`;
/// `.invite`: `{gameShareURL}`; `.check`/`.reveal`: `{scope}` (see
- /// PingScope); `.opened`/`.closed`: the OpenedLease. nil for join/win.
+ /// PingScope). nil for join/win/resign.
let payload: String?
/// Recipient authorID for a directed ping (`.win`/`.resign`); nil ⇒
/// broadcast — every recipient acts on it. A device ignores a ping whose
@@ -100,25 +94,6 @@ struct Session: Sendable {
let updatedAt: Date
}
-/// JSON carried in an `.opened`/`.closed` ping's `payload`. `leaseMs` is how
-/// long the sender intends the game to stay suppressed on sibling devices (0
-/// for `.closed`). `sentAtMs` is the sender's wall clock at send time — used
-/// only to discard a stale ping that arrives after a newer one from the same
-/// device (CloudKit does not guarantee order); the *expiry* is recomputed on
-/// the receiver's own clock so device clock skew can't wedge suppression.
-struct OpenedLease: Codable, Sendable {
- let leaseMs: Int64
- let sentAtMs: Int64
-
- func encoded() -> String? {
- (try? JSONEncoder().encode(self)).flatMap { String(data: $0, encoding: .utf8) }
- }
-
- static func decode(_ string: String?) -> OpenedLease? {
- guard let data = string?.data(using: .utf8) else { return nil }
- return try? JSONDecoder().decode(OpenedLease.self, from: data)
- }
-}
/// `payload` JSON for `.check`/`.reveal` pings — the check/reveal granularity
/// that used to live in the legacy `Ping.scope` field (see PingScope). The
@@ -198,6 +173,12 @@ actor SyncEngine {
/// server (a sibling device consumed a directed ping). Drives cross-device
/// withdrawal of the notification this device may have shown for it.
private var onPingDeleted: (@MainActor @Sendable (Set<UUID>) async -> Void)?
+ /// Fires with (gameID, readAt) pairs lifted from inbound Player records
+ /// whose authorID matches the local user. A sibling device has recorded
+ /// how far the account has read the collaborator's moves; the listener
+ /// merges that into `GameEntity.lastReadOtherMoveAt` as a monotonic max
+ /// so the unread-moves badge agrees across devices.
+ private var onIncomingReadCursor: (@MainActor @Sendable ([(UUID, Date)]) async -> Void)?
private var localAuthorIDProvider: (@MainActor @Sendable () -> String?)?
private var tracer: (@MainActor @Sendable (String) -> Void)?
private var liveQueryCheckpoints: [String: Date] = [:]
@@ -250,6 +231,10 @@ actor SyncEngine {
onPingDeleted = cb
}
+ func setOnIncomingReadCursor(_ cb: @MainActor @Sendable @escaping ([(UUID, Date)]) async -> Void) {
+ onIncomingReadCursor = cb
+ }
+
func setLocalAuthorIDProvider(_ cb: @MainActor @Sendable @escaping () -> String?) {
localAuthorIDProvider = cb
}
@@ -307,6 +292,7 @@ actor SyncEngine {
// to its periodic poll. Create the database subscriptions ourselves;
// CKDatabase.save is idempotent for an existing subscriptionID.
Task { await ensureDatabaseSubscriptions() }
+ Task { await purgeLegacyLeasePings_v1() }
}
private func ensureDatabaseSubscriptions() async {
@@ -511,98 +497,19 @@ actor SyncEngine {
/// Written to the account zone in the private database, so only the
/// authoring user's own devices receive it. The receive side filters
/// self-sends by (authorID, deviceID).
- /// Sends a `.closed` early-release for the game's lease. Thin wrapper over
- /// the `.opened` path with a zero lease — same record family, reaped by
- /// the same sweep.
- func enqueueClosedPing(
- gameID: UUID,
- authorID: String,
- playerName: String
- ) {
- enqueueOpenedPing(
- gameID: gameID,
- authorID: authorID,
- playerName: playerName,
- kind: .closed,
- leaseMs: 0
- )
- }
-
- func enqueueOpenedPing(
- gameID: UUID,
- authorID: String,
- playerName: String,
- kind: PingKind = .opened,
- leaseMs: Int64 = Int64(NotificationState.openLeaseDuration * 1000)
- ) {
- guard let engine = privateEngine else { return }
- let ctx = persistence.container.newBackgroundContext()
- let title: String = ctx.performAndWait {
- let req = NSFetchRequest<GameEntity>(entityName: "GameEntity")
- req.predicate = NSPredicate(format: "id == %@", gameID as CVarArg)
- req.fetchLimit = 1
- let entity = try? ctx.fetch(req).first
- return Self.notificationTitle(for: entity)
- }
- let deviceID = RecordSerializer.localDeviceID
- let eventTimestampMs = Int64(Date().timeIntervalSince1970 * 1000)
- let recordName = RecordSerializer.recordName(
- forPingInGame: gameID,
- authorID: authorID,
- deviceID: deviceID,
- eventTimestampMs: eventTimestampMs
- )
- pendingPings[recordName] = PingPayload(
- gameID: gameID,
- authorID: authorID,
- deviceID: deviceID,
- playerName: playerName,
- puzzleTitle: title,
- eventTimestampMs: eventTimestampMs,
- kind: kind,
- scope: nil,
- payload: OpenedLease(
- leaseMs: leaseMs,
- sentAtMs: eventTimestampMs
- ).encoded(),
- addressee: nil
- )
- let zoneID = RecordSerializer.accountZoneID
- // Make sure the zone exists before the record write. CKSyncEngine
- // dedupes redundant saveZone requests, so it's safe to repeat.
- engine.state.add(pendingDatabaseChanges: [.saveZone(CKRecordZone(zoneID: zoneID))])
- let recordID = CKRecord.ID(recordName: recordName, zoneID: zoneID)
- engine.state.add(pendingRecordZoneChanges: [.saveRecord(recordID)])
- Task { try? await engine.sendChanges() }
- Task { await sweepStaleOpenedPings() }
- }
-
- /// Time after which this device's own `.opened`/`.closed` lease pings are
- /// eligible for reaping. They can't be consumed-then-deleted — a sibling
- /// that hasn't synced yet still needs the latest — so age is the only safe
- /// signal. Refresh sends accrue one record per `leaseRefreshInterval`
- /// while a puzzle is open (far more than the old one-per-open), so the TTL
- /// is a day, not weeks; a sibling offline longer than that is well past
- /// the lease and the app's eventual-consistency tolerance regardless.
- private static let openedPingTTL: TimeInterval = 86_400
-
- /// Best-effort reaper for this device's own stale `.opened`/`.closed`
- /// lease pings in the account zone, keeping that zone from growing without
- /// bound. Filtering is server-side — `Ping.kind` and `Ping.deviceID` are
- /// QUERYABLE — so the query returns only the records to delete. Scoped to
- /// this device's own records on the assumption every other device of the
- /// same user has synced within `openedPingTTL`. Throttled via shared
- /// defaults so the per-send call site stays cheap; the timestamp is
- /// written only on success, so a transient failure simply retries on the
- /// next send.
- func sweepStaleOpenedPings() async {
- guard NotificationState.shouldRunOpenedSweep() else { return }
- let cutoff = Date().addingTimeInterval(-Self.openedPingTTL)
+ /// One-shot cleanup of legacy `.opened`/`.closed` lease pings from this
+ /// device's slot in the private-DB account zone. The lease subsystem is
+ /// gone (cross-device read state now rides `Player.readAt`), so any
+ /// remaining records are dead weight — every device cleans its own slice
+ /// the first time it launches the new build. Gated by an App-Group flag;
+ /// failures retry on the next launch. Slated for removal in a future
+ /// release once every device of every user has run it.
+ func purgeLegacyLeasePings_v1() async {
+ guard NotificationState.legacyLeasePurgeNeeded() else { return }
let predicate = NSPredicate(
- format: "kind IN %@ AND deviceID == %@ AND modificationDate < %@",
- [PingKind.opened.rawValue, PingKind.closed.rawValue],
- RecordSerializer.localDeviceID,
- cutoff as NSDate
+ format: "kind IN %@ AND deviceID == %@",
+ ["opened", "closed"],
+ RecordSerializer.localDeviceID
)
do {
let records = try await queryRecords(
@@ -616,12 +523,12 @@ actor SyncEngine {
withIDs: records.map(\.recordID),
in: container.privateCloudDatabase
)
- NotificationState.markOpenedSweepRun()
+ NotificationState.markLegacyLeasePurged()
if !records.isEmpty {
- await trace("opened ping sweep: deleted \(records.count) stale own ping(s)")
+ await trace("legacy-lease purge: deleted \(records.count) record(s)")
}
} catch {
- await trace("opened ping sweep failed: \(describe(error))")
+ await trace("legacy-lease purge failed: \(describe(error))")
}
}
@@ -1589,11 +1496,12 @@ actor SyncEngine {
let ctx = persistence.container.newBackgroundContext()
ctx.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump
let localAuthorID = await currentLocalAuthorID()
- let (movesUpdatedGameIDs, affectedGameIDs, playersUpdatedGameIDs):
- (Set<UUID>, Set<UUID>, Set<UUID>) = ctx.performAndWait {
+ let (movesUpdatedGameIDs, affectedGameIDs, playersUpdatedGameIDs, readCursors):
+ (Set<UUID>, Set<UUID>, Set<UUID>, [(UUID, Date)]) = ctx.performAndWait {
var movesUpdated = Set<UUID>()
var affected = Set<UUID>()
var playersUpdated = Set<UUID>()
+ var read: [(UUID, Date)] = []
for record in records {
switch record.recordType {
case "Game":
@@ -1612,7 +1520,13 @@ actor SyncEngine {
}
case "Player":
if let (gameID, _) = RecordSerializer.parsePlayerRecordName(record.recordID.recordName) {
- self.applyPlayerRecord(record, in: ctx) { playersUpdated.insert($0) }
+ self.applyPlayerRecord(
+ record,
+ in: ctx,
+ localAuthorID: localAuthorID,
+ onFirstTime: { playersUpdated.insert($0) },
+ onReadCursor: { read.append(($0, $1)) }
+ )
affected.insert(gameID)
}
default:
@@ -1644,7 +1558,7 @@ actor SyncEngine {
)
}
}
- return (movesUpdated, affected, playersUpdated)
+ return (movesUpdated, affected, playersUpdated, read)
}
if let onRemoteMovesUpdated, !movesUpdatedGameIDs.isEmpty {
@@ -1653,6 +1567,9 @@ actor SyncEngine {
if let onRemotePlayersUpdated, !playersUpdatedGameIDs.isEmpty {
await onRemotePlayersUpdated(playersUpdatedGameIDs)
}
+ if let onIncomingReadCursor, !readCursors.isEmpty {
+ await onIncomingReadCursor(readCursors)
+ }
let pingDeletedGameIDs = Set(deletions.compactMap { deletion -> UUID? in
deletion.0.recordName.hasPrefix("ping-")
? gameID(fromRecordName: deletion.0.recordName) : nil
@@ -1963,21 +1880,9 @@ actor SyncEngine {
let createdAt = entity.createdAt ?? Date(timeIntervalSince1970: 0)
result.append((CKRecordZone.ID(zoneName: zoneName, ownerName: ownerName), createdAt))
}
- // The account zone lives in the private database alongside the
- // per-game zones and currently carries `.opened` pings between a
- // single user's own devices. No GameEntity backs it, so it's
- // appended explicitly. The lookback floor is `.distantPast`: any
- // `.opened` ping we haven't observed yet should be processed.
- if scope == 0 {
- let zoneID = RecordSerializer.accountZoneID
- let key = "\(zoneID.ownerName)|\(zoneID.zoneName)"
- if seen.insert(key).inserted {
- result.append((zoneID, Date(timeIntervalSince1970: 0)))
- }
- }
// Friend zones carry `.invite` / `.friend` pings but no
- // GameEntity, so — like the account zone — they're appended
- // explicitly. The owner sees the zone in the private DB
+ // GameEntity, so they're appended explicitly. The owner sees the
+ // zone in the private DB
// (scope 0); the participant sees it in the shared DB
// (scope 1). Blocked friends are skipped so we stop reading
// anything from them. Floor is `.distantPast`: any unseen
@@ -2164,6 +2069,7 @@ actor SyncEngine {
name: renderedName,
updatedAt: updatedAt,
selection: selection,
+ readAt: entity.game?.lastReadOtherMoveAt,
zone: zoneID,
systemFields: entity.ckSystemFields
)
@@ -2186,10 +2092,18 @@ actor SyncEngine {
/// callback, because the freshly created `PlayerEntity` is not visible to
/// that work until this background context has been saved and merged into
/// the view context.
+ ///
+ /// `onReadCursor` fires with `(gameID, readAt)` when the inbound record
+ /// carries a `readAt` field and its `authorID` matches the local user —
+ /// i.e. a sibling device of this account recorded how far it has read the
+ /// collaborator's moves. The listener merges that into
+ /// `GameEntity.lastReadOtherMoveAt` after the context save lands.
private nonisolated func applyPlayerRecord(
_ record: CKRecord,
in ctx: NSManagedObjectContext,
- onFirstTime: (UUID) -> Void
+ localAuthorID: String?,
+ onFirstTime: (UUID) -> Void,
+ onReadCursor: (UUID, Date) -> Void
) {
let ckName = record.recordID.recordName
guard let (gameID, authorID) = RecordSerializer.parsePlayerRecordName(ckName) else {
@@ -2200,6 +2114,11 @@ actor SyncEngine {
?? record.modificationDate
?? Date()
+ if authorID == localAuthorID,
+ let readAt = RecordSerializer.parsePlayerReadAt(from: record) {
+ onReadCursor(gameID, readAt)
+ }
+
let req = NSFetchRequest<PlayerEntity>(entityName: "PlayerEntity")
req.predicate = NSPredicate(format: "ckRecordName == %@", ckName)
req.fetchLimit = 1
@@ -2447,13 +2366,14 @@ actor SyncEngine {
let ctx = persistence.container.newBackgroundContext()
ctx.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump
let localAuthorID = await currentLocalAuthorID()
- let (movesUpdatedGameIDs, affectedGameIDs, pings, playersUpdatedGameIDs, removedGameIDs):
- (Set<UUID>, Set<UUID>, [Ping], Set<UUID>, Set<UUID>) = ctx.performAndWait {
+ let (movesUpdatedGameIDs, affectedGameIDs, pings, playersUpdatedGameIDs, removedGameIDs, readCursors):
+ (Set<UUID>, Set<UUID>, [Ping], Set<UUID>, Set<UUID>, [(UUID, Date)]) = 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)] = []
for mod in event.modifications {
let record = mod.record
switch record.recordType {
@@ -2473,7 +2393,13 @@ actor SyncEngine {
}
case "Player":
if let (gameID, _) = RecordSerializer.parsePlayerRecordName(record.recordID.recordName) {
- self.applyPlayerRecord(record, in: ctx) { playersUpdated.insert($0) }
+ self.applyPlayerRecord(
+ record,
+ in: ctx,
+ localAuthorID: localAuthorID,
+ onFirstTime: { playersUpdated.insert($0) },
+ onReadCursor: { read.append(($0, $1)) }
+ )
affected.insert(gameID)
}
case "Ping":
@@ -2531,7 +2457,7 @@ actor SyncEngine {
)
}
}
- return (movesUpdated, affected, pings, playersUpdated, removed)
+ return (movesUpdated, affected, pings, playersUpdated, removed, read)
}
if let onRemoteMovesUpdated, !movesUpdatedGameIDs.isEmpty {
@@ -2540,6 +2466,9 @@ actor SyncEngine {
if let onRemotePlayersUpdated, !playersUpdatedGameIDs.isEmpty {
await onRemotePlayersUpdated(playersUpdatedGameIDs)
}
+ if let onIncomingReadCursor, !readCursors.isEmpty {
+ await onIncomingReadCursor(readCursors)
+ }
if let onPings, !pings.isEmpty {
await onPings(pings)
}
diff --git a/Crossmate/Views/GameListView.swift b/Crossmate/Views/GameListView.swift
@@ -354,7 +354,7 @@ private struct GameRowView: View {
@State private var isShowingShareSheet = false
var body: some View {
- let showsUnseenBadge = game.hasUnseenOtherMoves
+ let showsUnreadBadge = game.hasUnreadOtherMoves
HStack(spacing: 12) {
GridThumbnailView(
@@ -363,7 +363,7 @@ private struct GameRowView: View {
cells: game.thumbnailCells
)
.overlay(alignment: .topTrailing) {
- if showsUnseenBadge {
+ if showsUnreadBadge {
Circle()
.fill(.red)
.frame(width: 14, height: 14)
diff --git a/Shared/NotificationState.swift b/Shared/NotificationState.swift
@@ -5,13 +5,13 @@ import Foundation
/// State tracked:
/// - `activePuzzleID` (+ local leave-grace) — this device is viewing a puzzle,
/// so notifications and the unseen-moves badge for it are skipped.
-/// - `remoteActiveUntil` — a sibling device of the same user holds an open
-/// lease for a game; same suppression, driven by `.opened`/`.closed` pings.
/// - `shownByGame` — a `[gameID: Date]` map used to debounce inferred
/// session notifications. Once activity for game X has been shown, further
/// session notifications for X within `dedupWindow` are suppressed.
///
-/// `isSuppressed(gameID:)` is the unified gate over the first two.
+/// `isSuppressed(gameID:)` is the unified gate; presently it is just the
+/// local-active check, kept under that name so callers don't need to know
+/// whether sibling-device presence is part of the rule.
enum NotificationState {
static let appGroup = "group.net.inqk.crossmate"
@@ -25,8 +25,6 @@ enum NotificationState {
private static let shownKey = "notif.shownByGame"
private static let shownPingNamesKey = "notif.shownPingNames"
private static let localActiveUntilKey = "notif.localActiveUntil"
- private static let remoteActiveUntilKey = "notif.remoteActiveUntil"
- private static let remoteLeaseSentKey = "notif.remoteLeaseSentAt"
/// Grace window after the user leaves a puzzle during which the game is
/// still treated as active. Inbound moves or pings fetched while the
@@ -35,17 +33,6 @@ enum NotificationState {
/// user already watched arrive as unseen (and can re-notify for them).
static let leaveGraceWindow: TimeInterval = 15
- /// How long an `.opened` ping asks sibling devices to treat the game as
- /// remotely active. The lease is refreshed every `leaseRefreshInterval`
- /// while the puzzle stays open and ended early by a best-effort `.closed`;
- /// if both are lost it simply expires (fail-open — notifications resume).
- static let openLeaseDuration: TimeInterval = 300
-
- /// How often the open puzzle re-broadcasts its lease. Must be shorter than
- /// `openLeaseDuration` with margin so a single missed/slow send (the idle
- /// poll cadence is 60s) doesn't drop the lease.
- static let leaseRefreshInterval: TimeInterval = 120
-
/// Maximum number of recently-presented ping record names retained for
/// dedup. FIFO; older entries are evicted as new ones come in. 200 covers
/// the worst-case overlap between the push fast path and the eventual
@@ -128,63 +115,13 @@ enum NotificationState {
defaults?.dictionary(forKey: localActiveUntilKey) as? [String: TimeInterval] ?? [:]
}
- /// True if a sibling device of the same iCloud user currently holds (or
- /// recently refreshed) an open lease for `gameID` — i.e. that device is
- /// viewing the puzzle, so this device shouldn't badge or notify for moves
- /// it is actively making.
- static func isRemotelyActive(gameID: UUID, now: Date = Date()) -> Bool {
- guard let until = remoteActiveMap()[gameID.uuidString] else { return false }
- return now.timeIntervalSince1970 < until
- }
-
- /// The unified suppression gate: the user is viewing `gameID` here (incl.
- /// the local leave-grace tail) *or* a sibling device is. Notifications and
- /// the unseen-moves badge both consult this so they always agree.
+ /// The unified suppression gate: the user is viewing `gameID` here
+ /// (including the local leave-grace tail). Sibling-device presence used
+ /// to factor in here via the `.opened`/`.closed` lease; that subsystem is
+ /// gone — cross-device read state now rides `Player.readAt`. The
+ /// gate is kept under this name so callers don't need to change.
static func isSuppressed(gameID: UUID, now: Date = Date()) -> Bool {
isActive(gameID: gameID, now: now)
- || isRemotelyActive(gameID: gameID, now: now)
- }
-
- /// Applies an inbound lease ping. `until` is computed on *this* device's
- /// clock by the caller (so sender clock skew can't wedge suppression);
- /// `sentAtMs` is the sender's clock, used only to reject a stale ping that
- /// arrived after a newer one from the same device — without this an
- /// out-of-order `.opened` could undo a `.closed`. A `.closed` passes an
- /// already-elapsed `until`, expiring the lease while still advancing
- /// `sentAtMs` so a late refresh can't resurrect it.
- static func noteRemoteLease(
- gameID: UUID,
- until: Date,
- sentAtMs: Int64,
- now: Date = Date()
- ) {
- guard let defaults else { return }
- let key = gameID.uuidString
- var sent = remoteLeaseSentMap()
- if let prior = sent[key], Int64(prior) > sentAtMs { return }
- sent[key] = TimeInterval(sentAtMs)
-
- var map = remoteActiveMap()
- map[key] = until.timeIntervalSince1970
- // Drop entries that have already expired (a just-applied `.closed`
- // included) so the map can't grow without bound.
- let nowTS = now.timeIntervalSince1970
- map = map.filter { $0.value > nowTS }
- // Keep the sent-at map to live leases plus the key just touched, so a
- // later stale ping for a now-closed game is still rejected without the
- // map growing unbounded.
- sent = sent.filter { map[$0.key] != nil || $0.key == key }
-
- defaults.set(map, forKey: remoteActiveUntilKey)
- defaults.set(sent, forKey: remoteLeaseSentKey)
- }
-
- private static func remoteActiveMap() -> [String: TimeInterval] {
- defaults?.dictionary(forKey: remoteActiveUntilKey) as? [String: TimeInterval] ?? [:]
- }
-
- private static func remoteLeaseSentMap() -> [String: TimeInterval] {
- defaults?.dictionary(forKey: remoteLeaseSentKey) as? [String: TimeInterval] ?? [:]
}
/// True if a notification for this specific Ping record name has already
@@ -214,27 +151,19 @@ enum NotificationState {
defaults?.stringArray(forKey: shownPingNamesKey) ?? []
}
- private static let openedSweepAtKey = "notif.openedSweepAt"
-
- /// Minimum spacing between account-zone lease-ping sweeps. The sweep is
- /// invoked from every lease send (open + each `leaseRefreshInterval` while
- /// a puzzle is open + `.closed`), which is far too hot to query CloudKit
- /// each time; hourly bounds the now faster-accruing zone without churn.
- static let openedSweepInterval: TimeInterval = 60 * 60
+ private static let legacyLeasePurgeKey = "migration.legacyLeasePurge.v1"
- /// True if at least `openedSweepInterval` has elapsed since the last
- /// successful sweep, or it has never run. Returns false when the shared
- /// suite is unavailable so a missing throttle never means "sweep on every
- /// open".
- static func shouldRunOpenedSweep(now: Date = Date()) -> Bool {
- guard let defaults else { return false }
- let last = defaults.double(forKey: openedSweepAtKey)
- guard last > 0 else { return true }
- return now.timeIntervalSince1970 - last >= openedSweepInterval
+ /// True if the one-shot cleanup of legacy `.opened`/`.closed` lease pings
+ /// has not yet run successfully on this device. The flag is per-device
+ /// (App Group UserDefaults), so each device drains its own slice of the
+ /// account zone exactly once.
+ static func legacyLeasePurgeNeeded() -> Bool {
+ defaults?.bool(forKey: legacyLeasePurgeKey) == false
}
- /// Records that a sweep just completed successfully.
- static func markOpenedSweepRun(now: Date = Date()) {
- defaults?.set(now.timeIntervalSince1970, forKey: openedSweepAtKey)
+ /// Records that the legacy-lease purge completed successfully so the next
+ /// launch skips it.
+ static func markLegacyLeasePurged() {
+ defaults?.set(true, forKey: legacyLeasePurgeKey)
}
}
diff --git a/Tests/Unit/GameStoreUnreadMovesTests.swift b/Tests/Unit/GameStoreUnreadMovesTests.swift
@@ -0,0 +1,359 @@
+import CoreData
+import Foundation
+import Testing
+
+@testable import Crossmate
+
+/// Pins down the Date-based unread-badge heuristic on `GameStore`. A shared
+/// game gains an unread badge when another author's `MovesEntity` row has a
+/// later `updatedAt` than the local user's last open.
+@Suite("GameStore unread badge", .serialized)
+@MainActor
+struct GameStoreUnreadMovesTests {
+
+ private static let localAuthorID = "local-author"
+ private static let otherAuthorID = "other-author"
+
+ private static let sharedPuzzleSource = """
+ 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 func makeSharedGame(
+ in ctx: NSManagedObjectContext
+ ) throws -> (GameEntity, UUID) {
+ let gameID = UUID()
+ let xd = try XD.parse(Self.sharedPuzzleSource)
+ let puzzle = Puzzle(xd: xd)
+
+ let entity = GameEntity(context: ctx)
+ entity.id = gameID
+ entity.title = "Shared"
+ entity.puzzleSource = Self.sharedPuzzleSource
+ entity.createdAt = Date()
+ entity.updatedAt = Date()
+ entity.ckRecordName = "game-\(gameID.uuidString)"
+ entity.ckZoneName = "game-\(gameID.uuidString)"
+ entity.ckZoneOwnerName = "_someOtherUser"
+ entity.databaseScope = 1
+ // Pre-populate the cached summary fields so `GameSummary.init?` takes
+ // the fast path and doesn't have to re-parse XD.
+ entity.populateCachedSummaryFields(from: puzzle)
+ try ctx.save()
+ return (entity, gameID)
+ }
+
+ private func addMovesRow(
+ for entity: GameEntity,
+ gameID: UUID,
+ authorID: String,
+ updatedAt: Date,
+ in ctx: NSManagedObjectContext
+ ) throws {
+ let row = MovesEntity(context: ctx)
+ row.game = entity
+ row.authorID = authorID
+ row.deviceID = "test-\(authorID)"
+ row.cells = Data()
+ row.updatedAt = updatedAt
+ row.ckRecordName = RecordSerializer.recordName(
+ forMovesInGame: gameID,
+ authorID: authorID,
+ deviceID: "test-\(authorID)"
+ )
+ try ctx.save()
+ }
+
+ @Test("Other-author Moves update marks the shared game unread")
+ func otherAuthorMoveMarksSharedGameUnread() throws {
+ let persistence = makeTestPersistence()
+ let store = makeTestStore(persistence: persistence)
+ let (entity, gameID) = try makeSharedGame(in: persistence.viewContext)
+ let updatedAt = Date(timeIntervalSinceNow: -10)
+ try addMovesRow(
+ for: entity,
+ gameID: gameID,
+ authorID: Self.otherAuthorID,
+ updatedAt: updatedAt,
+ in: persistence.viewContext
+ )
+
+ store.noteIncomingMovesUpdate(
+ gameIDs: [gameID],
+ currentAuthorID: Self.localAuthorID
+ )
+
+ let summary = try #require(GameSummary(entity: entity))
+ #expect(entity.latestOtherMoveAt == updatedAt)
+ #expect(entity.lastReadOtherMoveAt == nil)
+ #expect(summary.hasUnreadOtherMoves)
+ }
+
+ @Test("Own Moves update does not mark the shared game unread")
+ func ownMoveDoesNotMarkSharedGameUnread() throws {
+ let persistence = makeTestPersistence()
+ let store = makeTestStore(persistence: persistence)
+ let (entity, gameID) = try makeSharedGame(in: persistence.viewContext)
+ try addMovesRow(
+ for: entity,
+ gameID: gameID,
+ authorID: Self.localAuthorID,
+ updatedAt: Date(),
+ in: persistence.viewContext
+ )
+
+ store.noteIncomingMovesUpdate(
+ gameIDs: [gameID],
+ currentAuthorID: Self.localAuthorID
+ )
+
+ let summary = try #require(GameSummary(entity: entity))
+ #expect(entity.latestOtherMoveAt == nil)
+ #expect(!summary.hasUnreadOtherMoves)
+ }
+
+ @Test("Opening a game advances lastReadOtherMoveAt to latestOtherMoveAt")
+ func openingGameMarksOtherMovesSeen() throws {
+ let persistence = makeTestPersistence()
+ let store = makeTestStore(persistence: persistence)
+ let ctx = persistence.viewContext
+ let (entity, _) = try makeSharedGame(in: ctx)
+ let latest = Date(timeIntervalSinceNow: -10)
+ entity.latestOtherMoveAt = latest
+ try ctx.save()
+
+ _ = try store.loadGame(id: entity.id!)
+
+ #expect(entity.lastReadOtherMoveAt == latest)
+ let summary = try #require(GameSummary(entity: entity))
+ #expect(!summary.hasUnreadOtherMoves)
+ }
+
+ @Test("unreadOtherMovesGameCount tallies shared games with pending other-author moves")
+ func unreadOtherMovesGameCountAcrossGames() throws {
+ let persistence = makeTestPersistence()
+ let store = makeTestStore(persistence: persistence)
+ let ctx = persistence.viewContext
+
+ // Unseen: shared game with other-author moves and no lastSeen.
+ let (gameA, gameAID) = try makeSharedGame(in: ctx)
+ try addMovesRow(
+ for: gameA,
+ gameID: gameAID,
+ authorID: Self.otherAuthorID,
+ updatedAt: Date(timeIntervalSinceNow: -20),
+ in: ctx
+ )
+ store.noteIncomingMovesUpdate(
+ gameIDs: [gameAID],
+ currentAuthorID: Self.localAuthorID
+ )
+
+ // Seen: shared game whose lastSeen catches up to latest.
+ let (gameB, gameBID) = try makeSharedGame(in: ctx)
+ let seenLatest = Date(timeIntervalSinceNow: -30)
+ gameB.latestOtherMoveAt = seenLatest
+ gameB.lastReadOtherMoveAt = seenLatest
+ try ctx.save()
+
+ #expect(store.unreadOtherMovesGameCount() == 1)
+
+ // Opening the unseen game advances lastSeen and clears the badge tally.
+ _ = try store.loadGame(id: gameAID)
+ #expect(store.unreadOtherMovesGameCount() == 0)
+ }
+
+ @Test("Inbound moves while the puzzle is visible advance lastReadOtherMoveAt")
+ func inboundMovesWhilePuzzleVisibleMarkSeen() throws {
+ let persistence = makeTestPersistence()
+ let store = makeTestStore(persistence: persistence)
+ let (entity, gameID) = try makeSharedGame(in: persistence.viewContext)
+ let updatedAt = Date(timeIntervalSinceNow: -10)
+ try addMovesRow(
+ for: entity,
+ gameID: gameID,
+ authorID: Self.otherAuthorID,
+ updatedAt: updatedAt,
+ in: persistence.viewContext
+ )
+
+ NotificationState.setActivePuzzleID(gameID)
+ defer { NotificationState.setActivePuzzleID(nil) }
+
+ store.noteIncomingMovesUpdate(
+ gameIDs: [gameID],
+ currentAuthorID: Self.localAuthorID
+ )
+
+ #expect(entity.lastReadOtherMoveAt == updatedAt)
+ let summary = try #require(GameSummary(entity: entity))
+ #expect(!summary.hasUnreadOtherMoves)
+ }
+
+ @Test("Inbound moves after backing out of a puzzle still mark it unseen")
+ func inboundMovesAfterBackOutMarkUnseen() throws {
+ let persistence = makeTestPersistence()
+ let store = makeTestStore(persistence: persistence)
+ let (entity, gameID) = try makeSharedGame(in: persistence.viewContext)
+ // Simulate a prior open: `currentEntity` is set inside the store and
+ // `lastReadOtherMoveAt` is up-to-date with no pending moves. The user
+ // then backs out — `NotificationState.activePuzzleID` clears, but the
+ // store's `currentEntity` deliberately stays put.
+ _ = try store.loadGame(id: gameID)
+ NotificationState.setActivePuzzleID(nil)
+
+ let updatedAt = Date()
+ try addMovesRow(
+ for: entity,
+ gameID: gameID,
+ authorID: Self.otherAuthorID,
+ updatedAt: updatedAt,
+ in: persistence.viewContext
+ )
+
+ store.noteIncomingMovesUpdate(
+ gameIDs: [gameID],
+ currentAuthorID: Self.localAuthorID
+ )
+
+ #expect(entity.latestOtherMoveAt == updatedAt)
+ #expect(entity.lastReadOtherMoveAt == nil)
+ let summary = try #require(GameSummary(entity: entity))
+ #expect(summary.hasUnreadOtherMoves)
+ }
+
+ @Test("Inbound moves within the leave grace after backing out stay seen")
+ func inboundMovesWithinLeaveGraceStaySeen() throws {
+ let persistence = makeTestPersistence()
+ let store = makeTestStore(persistence: persistence)
+ let (entity, gameID) = try makeSharedGame(in: persistence.viewContext)
+ _ = try store.loadGame(id: gameID)
+
+ // Simulate the real open → back-out path: the view sets the active
+ // puzzle on appear and clears it on `.onDisappear`, which now opens a
+ // short grace window rather than dropping the active state instantly.
+ NotificationState.setActivePuzzleID(gameID)
+ NotificationState.clearActivePuzzleID(if: gameID)
+ defer { NotificationState.setActivePuzzleID(nil) }
+
+ let updatedAt = Date()
+ try addMovesRow(
+ for: entity,
+ gameID: gameID,
+ authorID: Self.otherAuthorID,
+ updatedAt: updatedAt,
+ in: persistence.viewContext
+ )
+
+ // An inbound batch (or back-out catch-up) that finishes processing a
+ // beat after the view disappeared is still treated as seen — the user
+ // watched these moves arrive while the grid was on screen.
+ store.noteIncomingMovesUpdate(
+ gameIDs: [gameID],
+ currentAuthorID: Self.localAuthorID
+ )
+
+ #expect(entity.lastReadOtherMoveAt == updatedAt)
+ let summary = try #require(GameSummary(entity: entity))
+ #expect(!summary.hasUnreadOtherMoves)
+ }
+
+ @Test("A sibling's readAt advances the cursor and clears the badge")
+ func incomingReadCursorAdvancesBadge() throws {
+ let persistence = makeTestPersistence()
+ let store = makeTestStore(persistence: persistence)
+ let (entity, gameID) = try makeSharedGame(in: persistence.viewContext)
+
+ let earlier = Date(timeIntervalSinceNow: -30)
+ let later = Date(timeIntervalSinceNow: -10)
+ try addMovesRow(
+ for: entity,
+ gameID: gameID,
+ authorID: Self.otherAuthorID,
+ updatedAt: later,
+ in: persistence.viewContext
+ )
+ store.noteIncomingMovesUpdate(
+ gameIDs: [gameID],
+ currentAuthorID: Self.localAuthorID
+ )
+ #expect(entity.lastReadOtherMoveAt == nil)
+ #expect(store.unreadOtherMovesGameCount() == 1)
+
+ // A sibling read up to `earlier` — older than the latest move, so the
+ // badge still fires but the floor moves forward.
+ store.noteIncomingReadCursor(gameID: gameID, readAt: earlier)
+ #expect(entity.lastReadOtherMoveAt == earlier)
+ #expect(store.unreadOtherMovesGameCount() == 1)
+
+ // A sibling read up to `later` — catches the latest, badge clears.
+ store.noteIncomingReadCursor(gameID: gameID, readAt: later)
+ #expect(entity.lastReadOtherMoveAt == later)
+ #expect(store.unreadOtherMovesGameCount() == 0)
+
+ // Monotonic — an older sibling claim never regresses the cursor.
+ store.noteIncomingReadCursor(gameID: gameID, readAt: earlier)
+ #expect(entity.lastReadOtherMoveAt == later)
+ }
+
+ @Test("Completed shared games do not show as unseen even with later other-author moves")
+ func completedSharedGameSuppressesUnseen() throws {
+ let persistence = makeTestPersistence()
+ let store = makeTestStore(persistence: persistence)
+ let ctx = persistence.viewContext
+
+ let (entity, gameID) = try makeSharedGame(in: ctx)
+ entity.completedAt = Date(timeIntervalSinceNow: -100)
+ try ctx.save()
+
+ try addMovesRow(
+ for: entity,
+ gameID: gameID,
+ authorID: Self.otherAuthorID,
+ updatedAt: Date(),
+ in: ctx
+ )
+
+ store.noteIncomingMovesUpdate(
+ gameIDs: [gameID],
+ currentAuthorID: Self.localAuthorID
+ )
+
+ let summary = try #require(GameSummary(entity: entity))
+ #expect(!summary.hasUnreadOtherMoves)
+ #expect(store.unreadOtherMovesGameCount() == 0)
+ }
+
+ @Test("Opening a stale CmVer game reparses source and records current CmVer")
+ func openingStaleCmVerGameReparsesSource() throws {
+ let persistence = makeTestPersistence()
+ let store = makeTestStore(persistence: persistence)
+ let ctx = persistence.viewContext
+ let (entity, _) = try makeSharedGame(in: ctx)
+ entity.puzzleCmVersion = 0
+ entity.gridWidth = 0
+ entity.gridHeight = 0
+ entity.blockMask = nil
+ try ctx.save()
+
+ _ = try store.loadGame(id: entity.id!)
+
+ #expect(entity.puzzleCmVersion == Int64(XD.currentCmVersion))
+ #expect(entity.gridWidth == 3)
+ #expect(entity.gridHeight == 3)
+ #expect(entity.blockMask?.count == 9)
+ }
+}
diff --git a/Tests/Unit/GameStoreUnseenMovesTests.swift b/Tests/Unit/GameStoreUnseenMovesTests.swift
@@ -1,364 +0,0 @@
-import CoreData
-import Foundation
-import Testing
-
-@testable import Crossmate
-
-/// Pins down the Date-based unread-badge heuristic on `GameStore`. A shared
-/// game gains an unread badge when another author's `MovesEntity` row has a
-/// later `updatedAt` than the local user's last open.
-@Suite("GameStore unread badge", .serialized)
-@MainActor
-struct GameStoreUnseenMovesTests {
-
- private static let localAuthorID = "local-author"
- private static let otherAuthorID = "other-author"
-
- private static let sharedPuzzleSource = """
- 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 func makeSharedGame(
- in ctx: NSManagedObjectContext
- ) throws -> (GameEntity, UUID) {
- let gameID = UUID()
- let xd = try XD.parse(Self.sharedPuzzleSource)
- let puzzle = Puzzle(xd: xd)
-
- let entity = GameEntity(context: ctx)
- entity.id = gameID
- entity.title = "Shared"
- entity.puzzleSource = Self.sharedPuzzleSource
- entity.createdAt = Date()
- entity.updatedAt = Date()
- entity.ckRecordName = "game-\(gameID.uuidString)"
- entity.ckZoneName = "game-\(gameID.uuidString)"
- entity.ckZoneOwnerName = "_someOtherUser"
- entity.databaseScope = 1
- // Pre-populate the cached summary fields so `GameSummary.init?` takes
- // the fast path and doesn't have to re-parse XD.
- entity.populateCachedSummaryFields(from: puzzle)
- try ctx.save()
- return (entity, gameID)
- }
-
- private func addMovesRow(
- for entity: GameEntity,
- gameID: UUID,
- authorID: String,
- updatedAt: Date,
- in ctx: NSManagedObjectContext
- ) throws {
- let row = MovesEntity(context: ctx)
- row.game = entity
- row.authorID = authorID
- row.deviceID = "test-\(authorID)"
- row.cells = Data()
- row.updatedAt = updatedAt
- row.ckRecordName = RecordSerializer.recordName(
- forMovesInGame: gameID,
- authorID: authorID,
- deviceID: "test-\(authorID)"
- )
- try ctx.save()
- }
-
- @Test("Other-author Moves update marks the shared game unread")
- func otherAuthorMoveMarksSharedGameUnread() throws {
- let persistence = makeTestPersistence()
- let store = makeTestStore(persistence: persistence)
- let (entity, gameID) = try makeSharedGame(in: persistence.viewContext)
- let updatedAt = Date(timeIntervalSinceNow: -10)
- try addMovesRow(
- for: entity,
- gameID: gameID,
- authorID: Self.otherAuthorID,
- updatedAt: updatedAt,
- in: persistence.viewContext
- )
-
- store.noteIncomingMovesUpdate(
- gameIDs: [gameID],
- currentAuthorID: Self.localAuthorID
- )
-
- let summary = try #require(GameSummary(entity: entity))
- #expect(entity.latestOtherMoveAt == updatedAt)
- #expect(entity.lastSeenOtherMoveAt == nil)
- #expect(summary.hasUnseenOtherMoves)
- }
-
- @Test("Own Moves update does not mark the shared game unread")
- func ownMoveDoesNotMarkSharedGameUnread() throws {
- let persistence = makeTestPersistence()
- let store = makeTestStore(persistence: persistence)
- let (entity, gameID) = try makeSharedGame(in: persistence.viewContext)
- try addMovesRow(
- for: entity,
- gameID: gameID,
- authorID: Self.localAuthorID,
- updatedAt: Date(),
- in: persistence.viewContext
- )
-
- store.noteIncomingMovesUpdate(
- gameIDs: [gameID],
- currentAuthorID: Self.localAuthorID
- )
-
- let summary = try #require(GameSummary(entity: entity))
- #expect(entity.latestOtherMoveAt == nil)
- #expect(!summary.hasUnseenOtherMoves)
- }
-
- @Test("Opening a game advances lastSeenOtherMoveAt to latestOtherMoveAt")
- func openingGameMarksOtherMovesSeen() throws {
- let persistence = makeTestPersistence()
- let store = makeTestStore(persistence: persistence)
- let ctx = persistence.viewContext
- let (entity, _) = try makeSharedGame(in: ctx)
- let latest = Date(timeIntervalSinceNow: -10)
- entity.latestOtherMoveAt = latest
- try ctx.save()
-
- _ = try store.loadGame(id: entity.id!)
-
- #expect(entity.lastSeenOtherMoveAt == latest)
- let summary = try #require(GameSummary(entity: entity))
- #expect(!summary.hasUnseenOtherMoves)
- }
-
- @Test("unseenOtherMovesGameCount tallies shared games with pending other-author moves")
- func unseenOtherMovesGameCountAcrossGames() throws {
- let persistence = makeTestPersistence()
- let store = makeTestStore(persistence: persistence)
- let ctx = persistence.viewContext
-
- // Unseen: shared game with other-author moves and no lastSeen.
- let (gameA, gameAID) = try makeSharedGame(in: ctx)
- try addMovesRow(
- for: gameA,
- gameID: gameAID,
- authorID: Self.otherAuthorID,
- updatedAt: Date(timeIntervalSinceNow: -20),
- in: ctx
- )
- store.noteIncomingMovesUpdate(
- gameIDs: [gameAID],
- currentAuthorID: Self.localAuthorID
- )
-
- // Seen: shared game whose lastSeen catches up to latest.
- let (gameB, gameBID) = try makeSharedGame(in: ctx)
- let seenLatest = Date(timeIntervalSinceNow: -30)
- gameB.latestOtherMoveAt = seenLatest
- gameB.lastSeenOtherMoveAt = seenLatest
- try ctx.save()
-
- #expect(store.unseenOtherMovesGameCount() == 1)
-
- // Opening the unseen game advances lastSeen and clears the badge tally.
- _ = try store.loadGame(id: gameAID)
- #expect(store.unseenOtherMovesGameCount() == 0)
- }
-
- @Test("Inbound moves while the puzzle is visible advance lastSeenOtherMoveAt")
- func inboundMovesWhilePuzzleVisibleMarkSeen() throws {
- let persistence = makeTestPersistence()
- let store = makeTestStore(persistence: persistence)
- let (entity, gameID) = try makeSharedGame(in: persistence.viewContext)
- let updatedAt = Date(timeIntervalSinceNow: -10)
- try addMovesRow(
- for: entity,
- gameID: gameID,
- authorID: Self.otherAuthorID,
- updatedAt: updatedAt,
- in: persistence.viewContext
- )
-
- NotificationState.setActivePuzzleID(gameID)
- defer { NotificationState.setActivePuzzleID(nil) }
-
- store.noteIncomingMovesUpdate(
- gameIDs: [gameID],
- currentAuthorID: Self.localAuthorID
- )
-
- #expect(entity.lastSeenOtherMoveAt == updatedAt)
- let summary = try #require(GameSummary(entity: entity))
- #expect(!summary.hasUnseenOtherMoves)
- }
-
- @Test("Inbound moves after backing out of a puzzle still mark it unseen")
- func inboundMovesAfterBackOutMarkUnseen() throws {
- let persistence = makeTestPersistence()
- let store = makeTestStore(persistence: persistence)
- let (entity, gameID) = try makeSharedGame(in: persistence.viewContext)
- // Simulate a prior open: `currentEntity` is set inside the store and
- // `lastSeenOtherMoveAt` is up-to-date with no pending moves. The user
- // then backs out — `NotificationState.activePuzzleID` clears, but the
- // store's `currentEntity` deliberately stays put.
- _ = try store.loadGame(id: gameID)
- NotificationState.setActivePuzzleID(nil)
-
- let updatedAt = Date()
- try addMovesRow(
- for: entity,
- gameID: gameID,
- authorID: Self.otherAuthorID,
- updatedAt: updatedAt,
- in: persistence.viewContext
- )
-
- store.noteIncomingMovesUpdate(
- gameIDs: [gameID],
- currentAuthorID: Self.localAuthorID
- )
-
- #expect(entity.latestOtherMoveAt == updatedAt)
- #expect(entity.lastSeenOtherMoveAt == nil)
- let summary = try #require(GameSummary(entity: entity))
- #expect(summary.hasUnseenOtherMoves)
- }
-
- @Test("Inbound moves within the leave grace after backing out stay seen")
- func inboundMovesWithinLeaveGraceStaySeen() throws {
- let persistence = makeTestPersistence()
- let store = makeTestStore(persistence: persistence)
- let (entity, gameID) = try makeSharedGame(in: persistence.viewContext)
- _ = try store.loadGame(id: gameID)
-
- // Simulate the real open → back-out path: the view sets the active
- // puzzle on appear and clears it on `.onDisappear`, which now opens a
- // short grace window rather than dropping the active state instantly.
- NotificationState.setActivePuzzleID(gameID)
- NotificationState.clearActivePuzzleID(if: gameID)
- defer { NotificationState.setActivePuzzleID(nil) }
-
- let updatedAt = Date()
- try addMovesRow(
- for: entity,
- gameID: gameID,
- authorID: Self.otherAuthorID,
- updatedAt: updatedAt,
- in: persistence.viewContext
- )
-
- // An inbound batch (or back-out catch-up) that finishes processing a
- // beat after the view disappeared is still treated as seen — the user
- // watched these moves arrive while the grid was on screen.
- store.noteIncomingMovesUpdate(
- gameIDs: [gameID],
- currentAuthorID: Self.localAuthorID
- )
-
- #expect(entity.lastSeenOtherMoveAt == updatedAt)
- let summary = try #require(GameSummary(entity: entity))
- #expect(!summary.hasUnseenOtherMoves)
- }
-
- @Test("A sibling device's open lease keeps inbound moves seen here")
- func remoteLeaseKeepsInboundMovesSeen() throws {
- let persistence = makeTestPersistence()
- let store = makeTestStore(persistence: persistence)
- let (entity, gameID) = try makeSharedGame(in: persistence.viewContext)
- _ = try store.loadGame(id: gameID)
-
- // Not viewing here, but a sibling device of the same user holds an
- // open lease — the user has eyes on the puzzle there.
- NotificationState.setActivePuzzleID(nil)
- let sentMs = Int64(Date().timeIntervalSince1970 * 1000)
- NotificationState.noteRemoteLease(
- gameID: gameID,
- until: Date().addingTimeInterval(NotificationState.openLeaseDuration),
- sentAtMs: sentMs
- )
- defer {
- NotificationState.noteRemoteLease(
- gameID: gameID,
- until: Date().addingTimeInterval(-1),
- sentAtMs: sentMs + 1
- )
- }
-
- let updatedAt = Date()
- try addMovesRow(
- for: entity,
- gameID: gameID,
- authorID: Self.otherAuthorID,
- updatedAt: updatedAt,
- in: persistence.viewContext
- )
-
- store.noteIncomingMovesUpdate(
- gameIDs: [gameID],
- currentAuthorID: Self.localAuthorID
- )
-
- #expect(entity.lastSeenOtherMoveAt == updatedAt)
- let summary = try #require(GameSummary(entity: entity))
- #expect(!summary.hasUnseenOtherMoves)
- }
-
- @Test("Completed shared games do not show as unseen even with later other-author moves")
- func completedSharedGameSuppressesUnseen() throws {
- let persistence = makeTestPersistence()
- let store = makeTestStore(persistence: persistence)
- let ctx = persistence.viewContext
-
- let (entity, gameID) = try makeSharedGame(in: ctx)
- entity.completedAt = Date(timeIntervalSinceNow: -100)
- try ctx.save()
-
- try addMovesRow(
- for: entity,
- gameID: gameID,
- authorID: Self.otherAuthorID,
- updatedAt: Date(),
- in: ctx
- )
-
- store.noteIncomingMovesUpdate(
- gameIDs: [gameID],
- currentAuthorID: Self.localAuthorID
- )
-
- let summary = try #require(GameSummary(entity: entity))
- #expect(!summary.hasUnseenOtherMoves)
- #expect(store.unseenOtherMovesGameCount() == 0)
- }
-
- @Test("Opening a stale CmVer game reparses source and records current CmVer")
- func openingStaleCmVerGameReparsesSource() throws {
- let persistence = makeTestPersistence()
- let store = makeTestStore(persistence: persistence)
- let ctx = persistence.viewContext
- let (entity, _) = try makeSharedGame(in: ctx)
- entity.puzzleCmVersion = 0
- entity.gridWidth = 0
- entity.gridHeight = 0
- entity.blockMask = nil
- try ctx.save()
-
- _ = try store.loadGame(id: entity.id!)
-
- #expect(entity.puzzleCmVersion == Int64(XD.currentCmVersion))
- #expect(entity.gridWidth == 3)
- #expect(entity.gridHeight == 3)
- #expect(entity.blockMask?.count == 9)
- }
-}
diff --git a/Tests/Unit/NotificationStateTests.swift b/Tests/Unit/NotificationStateTests.swift
@@ -43,114 +43,8 @@ struct NotificationStateTests {
NotificationState.setActivePuzzleID(nil)
}
- @Test("Opened sweep is suppressed until the interval elapses, then allowed")
- func openedSweepThrottleBoundary() {
- let base = Date(timeIntervalSince1970: 1_000_000)
- let interval = NotificationState.openedSweepInterval
- NotificationState.markOpenedSweepRun(now: base)
-
- // Immediately after a run, and right up to one tick before the
- // interval, the sweep stays suppressed.
- #expect(!NotificationState.shouldRunOpenedSweep(now: base))
- #expect(!NotificationState.shouldRunOpenedSweep(
- now: base.addingTimeInterval(interval - 1)
- ))
-
- // The window opens exactly at the interval (the `>=` boundary) and
- // stays open afterwards.
- #expect(NotificationState.shouldRunOpenedSweep(
- now: base.addingTimeInterval(interval)
- ))
- #expect(NotificationState.shouldRunOpenedSweep(
- now: base.addingTimeInterval(interval + 1)
- ))
- }
-
- @Test("Recording a later sweep re-arms the throttle")
- func openedSweepThrottleReArms() {
- let base = Date(timeIntervalSince1970: 2_000_000)
- let interval = NotificationState.openedSweepInterval
- NotificationState.markOpenedSweepRun(now: base)
-
- let due = base.addingTimeInterval(interval)
- #expect(NotificationState.shouldRunOpenedSweep(now: due))
-
- // A successful sweep at `due` pushes the next eligible time forward
- // by another full interval.
- NotificationState.markOpenedSweepRun(now: due)
- #expect(!NotificationState.shouldRunOpenedSweep(now: due))
- #expect(!NotificationState.shouldRunOpenedSweep(
- now: due.addingTimeInterval(interval - 1)
- ))
- #expect(NotificationState.shouldRunOpenedSweep(
- now: due.addingTimeInterval(interval)
- ))
- }
-
- @Test("Remote lease suppresses until expiry; .closed ends it early")
- func remoteLeaseLifecycle() {
- let gameID = UUID()
- let base = Date(timeIntervalSince1970: 5_000_000)
- NotificationState.setActivePuzzleID(nil)
-
- NotificationState.noteRemoteLease(
- gameID: gameID,
- until: base.addingTimeInterval(NotificationState.openLeaseDuration),
- sentAtMs: 1000,
- now: base
- )
- #expect(NotificationState.isRemotelyActive(gameID: gameID, now: base))
- #expect(NotificationState.isSuppressed(gameID: gameID, now: base))
- // Expires on this device's clock at the lease boundary.
- #expect(!NotificationState.isRemotelyActive(
- gameID: gameID,
- now: base.addingTimeInterval(NotificationState.openLeaseDuration)
- ))
-
- // A later .closed (higher sentAtMs, already-elapsed until) ends it now.
- NotificationState.noteRemoteLease(
- gameID: gameID,
- until: base,
- sentAtMs: 2000,
- now: base
- )
- #expect(!NotificationState.isRemotelyActive(gameID: gameID, now: base))
- #expect(!NotificationState.isSuppressed(gameID: gameID, now: base))
- }
-
- @Test("A stale lease ping arriving after a newer one is ignored")
- func staleLeasePingIgnored() {
- let gameID = UUID()
- let base = Date(timeIntervalSince1970: 6_000_000)
- NotificationState.setActivePuzzleID(nil)
-
- // .closed at sentAtMs 5000 ends the lease.
- NotificationState.noteRemoteLease(
- gameID: gameID, until: base, sentAtMs: 5000, now: base
- )
- #expect(!NotificationState.isRemotelyActive(gameID: gameID, now: base))
-
- // An out-of-order earlier .opened must not resurrect it.
- NotificationState.noteRemoteLease(
- gameID: gameID,
- until: base.addingTimeInterval(NotificationState.openLeaseDuration),
- sentAtMs: 4000,
- now: base
- )
- #expect(!NotificationState.isRemotelyActive(gameID: gameID, now: base))
-
- // A genuinely newer .opened is accepted.
- NotificationState.noteRemoteLease(
- gameID: gameID,
- until: base.addingTimeInterval(NotificationState.openLeaseDuration),
- sentAtMs: 6000,
- now: base
- )
- #expect(NotificationState.isRemotelyActive(gameID: gameID, now: base))
- }
-
- @Test("isSuppressed is true for local active OR a remote lease")
- func suppressedCombinesLocalAndRemote() {
+ @Test("isSuppressed tracks the local active puzzle (and its grace tail)")
+ func suppressedTracksLocalActive() {
let gameID = UUID()
let base = Date(timeIntervalSince1970: 7_000_000)
NotificationState.setActivePuzzleID(nil)
@@ -158,15 +52,15 @@ struct NotificationStateTests {
NotificationState.setActivePuzzleID(gameID)
#expect(NotificationState.isSuppressed(gameID: gameID, now: base))
- NotificationState.setActivePuzzleID(nil)
- #expect(!NotificationState.isSuppressed(gameID: gameID, now: base))
- NotificationState.noteRemoteLease(
- gameID: gameID,
- until: base.addingTimeInterval(60),
- sentAtMs: 1,
- now: base
- )
+ // Clearing the active ID keeps the just-left puzzle suppressed
+ // through the grace tail and releases it after.
+ NotificationState.clearActivePuzzleID(if: gameID, now: base)
#expect(NotificationState.isSuppressed(gameID: gameID, now: base))
+ #expect(!NotificationState.isSuppressed(
+ gameID: gameID,
+ now: base.addingTimeInterval(NotificationState.leaveGraceWindow)
+ ))
+ NotificationState.setActivePuzzleID(nil)
}
}
diff --git a/Tests/Unit/OpenedLeaseTests.swift b/Tests/Unit/OpenedLeaseTests.swift
@@ -1,65 +0,0 @@
-import Foundation
-import Testing
-
-@testable import Crossmate
-
-@Suite("Opened lease payload")
-struct OpenedLeaseTests {
- @Test("Round-trips through its JSON payload")
- func roundTrip() throws {
- let lease = OpenedLease(leaseMs: 300_000, sentAtMs: 1_715_000_000_000)
- let encoded = try #require(lease.encoded())
- let decoded = try #require(OpenedLease.decode(encoded))
- #expect(decoded.leaseMs == lease.leaseMs)
- #expect(decoded.sentAtMs == lease.sentAtMs)
- }
-
- @Test("A .closed lease (zero duration) round-trips")
- func closedRoundTrips() throws {
- let lease = OpenedLease(leaseMs: 0, sentAtMs: 42)
- let decoded = try #require(OpenedLease.decode(lease.encoded()))
- #expect(decoded.leaseMs == 0)
- #expect(decoded.sentAtMs == 42)
- }
-
- @Test("decode is nil for missing or malformed payload")
- func decodeNilOnGarbage() {
- #expect(OpenedLease.decode(nil) == nil)
- #expect(OpenedLease.decode("") == nil)
- #expect(OpenedLease.decode("not json") == nil)
- #expect(OpenedLease.decode("{\"leaseMs\":1}") == nil)
- }
-}
-
-@Suite("Ping scope payload")
-struct PingScopePayloadTests {
- @Test("Round-trips through its JSON payload")
- func roundTrip() throws {
- for scope in [PingScope.square, .word, .puzzle] {
- let encoded = try #require(
- PingScopePayload(scope: scope.rawValue).encoded()
- )
- let decoded = try #require(PingScopePayload.decode(encoded))
- #expect(PingScope(rawValue: decoded.scope) == scope)
- }
- }
-
- @Test("decode is nil for missing or malformed payload")
- func decodeNilOnGarbage() {
- #expect(PingScopePayload.decode(nil) == nil)
- #expect(PingScopePayload.decode("") == nil)
- #expect(PingScopePayload.decode("not json") == nil)
- }
-
- @Test("Other kinds' payloads don't false-match as a scope payload")
- func crossKindIsolation() {
- // A lease payload has no `scope` key.
- let lease = OpenedLease(leaseMs: 300_000, sentAtMs: 1).encoded()
- #expect(PingScopePayload.decode(lease) == nil)
- // …and a scope payload isn't a valid lease.
- let scope = PingScopePayload(scope: "word").encoded()
- #expect(OpenedLease.decode(scope) == nil)
- // An invite-shaped payload also has no `scope` key.
- #expect(PingScopePayload.decode(#"{"gameShareURL":"https://x"}"#) == nil)
- }
-}
diff --git a/Tests/Unit/RecordSerializerTests.swift b/Tests/Unit/RecordSerializerTests.swift
@@ -68,27 +68,27 @@ struct RecordSerializerTests {
#expect(RecordSerializer.parsePlayerRecordName("player-12345678-1234-1234-1234-123456789ABC") == nil)
}
- @Test("playerRecord writes seenOtherAt and parses it back")
- func playerRecordSeenOtherAtRoundTrip() {
+ @Test("playerRecord writes readAt and parses it back")
+ func playerRecordReadAtRoundTrip() {
let id = UUID()
let zone = CKRecordZone.ID(zoneName: "test-zone", ownerName: CKCurrentUserDefaultName)
- let seen = Date(timeIntervalSince1970: 1_700_000_000)
+ let readAt = Date(timeIntervalSince1970: 1_700_000_000)
let record = RecordSerializer.playerRecord(
gameID: id,
authorID: "alice",
name: "Alice",
updatedAt: Date(timeIntervalSince1970: 1_700_000_100),
selection: nil,
- seenOtherAt: seen,
+ readAt: readAt,
zone: zone,
systemFields: nil
)
- #expect(record["seenOtherAt"] as? Date == seen)
- #expect(RecordSerializer.parsePlayerSeenOtherAt(from: record) == seen)
+ #expect(record["readAt"] as? Date == readAt)
+ #expect(RecordSerializer.parsePlayerReadAt(from: record) == readAt)
}
- @Test("playerRecord omits seenOtherAt when nil and parser returns nil")
- func playerRecordSeenOtherAtNil() {
+ @Test("playerRecord omits readAt when nil and parser returns nil")
+ func playerRecordReadAtNil() {
let id = UUID()
let zone = CKRecordZone.ID(zoneName: "test-zone", ownerName: CKCurrentUserDefaultName)
let record = RecordSerializer.playerRecord(
@@ -100,8 +100,8 @@ struct RecordSerializerTests {
zone: zone,
systemFields: nil
)
- #expect(record["seenOtherAt"] == nil)
- #expect(RecordSerializer.parsePlayerSeenOtherAt(from: record) == nil)
+ #expect(record["readAt"] == nil)
+ #expect(RecordSerializer.parsePlayerReadAt(from: record) == nil)
}
// MARK: - Ping
@@ -129,13 +129,13 @@ struct RecordSerializerTests {
playerName: "Alice",
puzzleTitle: "Puzzle",
eventTimestampMs: 1700000000000,
- kind: .opened,
+ kind: .win,
scope: nil,
zone: zone
)
#expect(record["authorID"] as? String == "alice")
#expect(record["deviceID"] as? String == "deviceA")
- #expect(record["kind"] as? String == "opened")
+ #expect(record["kind"] as? String == "win")
}
@Test("pingRecord writes payload when provided and omits it when nil")