crossmate

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

commit 17489693da2f2692065dec074795980c5d9824c1
parent 6ea7d94a8b610fa65fa5bf946d42af87e26046f9
Author: Michael Camilleri <[email protected]>
Date:   Wed, 20 May 2026 13:00:28 +0900

Use lease-based suppression strategy while a puzzle is open

Player.readAt was a durable monotonic cursor, so an active viewer had to push
each watched inbound move promptly if sibling devices were going to clear their
unread badges. This commit treats it as a read horizon instead: while the
puzzle is active, publish now + 10 minutes; refresh only once the horizon has
less than five minutes left; and on background/disappear publish the current
time to close the lease. Watched inbound moves still advance the local horizon
when needed, but no longer shorten an existing future lease.

Because readAt can now move backward when a lease closes, inbound same-account
readAt values are applied only after the Player record passes the existing
last-writer-wins freshness checks. The store applies accepted readAt values
directly rather than as a monotonic max, and tests cover closing a future
lease, thresholded lease refresh, and preserving a future lease while visible
moves arrive.

Co-Authored-By: Codex GPT 5.5 <[email protected]>

Diffstat:
MCrossmate/CrossmateApp.swift | 18++++++++++++++----
MCrossmate/Persistence/GameStore.swift | 55++++++++++++++++++++++++++++++++++++-------------------
MCrossmate/Services/AppServices.swift | 52++++++++++++++++++++++++++++++++++++----------------
MCrossmate/Sync/RecordSerializer.swift | 11++++++-----
MCrossmate/Sync/SyncEngine.swift | 23+++++++++++------------
MTests/Unit/GameStoreUnreadMovesTests.swift | 78++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
6 files changed, 177 insertions(+), 60 deletions(-)

diff --git a/Crossmate/CrossmateApp.swift b/Crossmate/CrossmateApp.swift @@ -349,6 +349,7 @@ private struct PuzzleDisplayView: View { /// How recent a push must be to count as "active". Slightly longer than /// the active interval so a burst with brief pauses keeps tight polling. private static let activityWindow: TimeInterval = 30 + private static let readLeaseRefreshInterval: TimeInterval = 5 * 60 /// When opened from an `.invite` ping notification, the game's local /// `GameEntity` may not exist yet — the CKShare is accepted and the zone /// fetched on a separate path that can land seconds after the tap. Keep @@ -428,7 +429,6 @@ 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 @@ -450,6 +450,7 @@ private struct PuzzleDisplayView: View { let newRoster = services.makePlayerRoster(for: gameID, preferences: preferences) roster = newRoster session = newSession + Task { await services.publishReadCursor(for: gameID, mode: .activeLease) } openPuzzleFollowUpTask = Task { @MainActor in await finishOpeningPuzzle( session: newSession, @@ -494,8 +495,10 @@ private struct PuzzleDisplayView: View { // (and were marked seen in lockstep) while we were foregrounded. let id = gameID switch newPhase { - case .active, .background: - Task { await services.publishReadCursor(for: id) } + case .active: + Task { await services.publishReadCursor(for: id, mode: .activeLease) } + case .background: + Task { await services.publishReadCursor(for: id, mode: .currentTime) } case .inactive: break @unknown default: @@ -512,7 +515,7 @@ private struct PuzzleDisplayView: View { Task { await movesUpdater.flush() await selectionPublisher.clear() - await services.publishReadCursor(for: id) + await services.publishReadCursor(for: id, mode: .currentTime) } } } @@ -599,6 +602,7 @@ private struct PuzzleDisplayView: View { private func pollOpenSyncedPuzzle() async { guard let scope = syncedScope else { return } + var lastReadLeaseRefresh = Date.distantPast await services.freshenPuzzleGrid(gameID: gameID, scope: scope, reason: .appeared) while !Task.isCancelled { let interval = services.hadRecentRemoteNotification(within: Self.activityWindow) @@ -611,6 +615,12 @@ private struct PuzzleDisplayView: View { } guard !Task.isCancelled else { break } guard let scope = syncedScope else { break } + let now = Date() + if scenePhase == .active, + now.timeIntervalSince(lastReadLeaseRefresh) >= Self.readLeaseRefreshInterval { + await services.publishReadCursor(for: gameID, mode: .activeLease) + lastReadLeaseRefresh = now + } await services.freshenPuzzleGrid(gameID: gameID, scope: scope, reason: .poll) } } diff --git a/Crossmate/Persistence/GameStore.swift b/Crossmate/Persistence/GameStore.swift @@ -334,11 +334,13 @@ final class GameStore { // 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) — 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.lastReadOtherMoveAt = entity.latestOtherMoveAt + // user has eyes on these moves, so keep the read horizon at + // least caught up. Do not shorten an active future read lease. + // Sibling devices learn about the read via `Player.readAt`; + // AppServices publishes the active read lease after this merge. + if NotificationState.isSuppressed(gameID: gameID), + (entity.lastReadOtherMoveAt ?? .distantPast) < latest { + entity.lastReadOtherMoveAt = latest } } @@ -599,25 +601,40 @@ final class GameStore { return try context.fetch(request).first } - /// 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. + /// Applies this account's `Player.readAt` value from a sibling device. + /// The sync layer has already accepted the Player record under its normal + /// last-writer-wins freshness checks, so the value is allowed to move + /// backward when an active read lease is closed with a current-time write. func noteIncomingReadCursor(gameID: UUID, readAt: Date) { + _ = setReadCursor(gameID: gameID, readAt: readAt) + } + + /// Sets the per-account read horizon for other-author moves. When + /// `minimumExistingReadAt` is provided, the write is skipped if the + /// current horizon already reaches that floor; active sessions use this + /// to refresh a future read lease only when it is getting close to expiry. + @discardableResult + func setReadCursor( + gameID: UUID, + readAt: Date, + minimumExistingReadAt: Date? = nil + ) -> Bool { 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 } + guard let entity = try? context.fetch(request).first else { return false } let isShared = entity.ckShareRecordName != nil || entity.databaseScope == 1 - guard isShared else { return } - if (entity.lastReadOtherMoveAt ?? .distantPast) < readAt { - entity.lastReadOtherMoveAt = readAt - try? context.save() - onUnreadOtherMovesChanged?() - } + guard isShared else { return false } + if let minimumExistingReadAt, + let current = entity.lastReadOtherMoveAt, + current >= minimumExistingReadAt { + return false + } + guard entity.lastReadOtherMoveAt != readAt else { return false } + entity.lastReadOtherMoveAt = readAt + try? context.save() + onUnreadOtherMovesChanged?() + return true } private func markOtherMovesRead(for entity: GameEntity) { diff --git a/Crossmate/Services/AppServices.swift b/Crossmate/Services/AppServices.swift @@ -5,6 +5,14 @@ import UserNotifications @MainActor final class AppServices { + enum ReadCursorPublishMode { + case activeLease + case currentTime + } + + private static let readLeaseDuration: TimeInterval = 10 * 60 + private static let readLeaseRefreshFloor: TimeInterval = 5 * 60 + enum FreshenReason { case appeared case foreground @@ -229,7 +237,7 @@ final class AppServices { identity.currentID } - await syncEngine.setOnRemoteMovesUpdated { [store, identity, syncEngine] gameIDs in + await syncEngine.setOnRemoteMovesUpdated { [weak self, store, identity] gameIDs in store.noteIncomingMovesUpdate( gameIDs: gameIDs, currentAuthorID: identity.currentID @@ -237,10 +245,8 @@ final class AppServices { if let currentID = store.currentEntity?.id, gameIDs.contains(currentID) { store.refreshCurrentGame() - if NotificationState.isSuppressed(gameID: currentID), - let authorID = identity.currentID, - !authorID.isEmpty { - await syncEngine.enqueuePlayerRecord(gameID: currentID, authorID: authorID) + if NotificationState.isSuppressed(gameID: currentID) { + await self?.publishReadCursor(for: currentID, mode: .activeLease) } } } @@ -252,9 +258,9 @@ 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. + // A sibling device of the same iCloud account has published its read + // horizon; apply it directly because SyncEngine has already accepted + // the Player record under last-writer-wins freshness checks. await syncEngine.setOnIncomingReadCursor { [store] pairs in for (gameID, readAt) in pairs { store.noteIncomingReadCursor(gameID: gameID, readAt: readAt) @@ -1456,15 +1462,29 @@ final class AppServices { } } - /// 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 { + /// Publishes this account's read horizon for other-author moves by + /// updating `GameEntity.lastReadOtherMoveAt` and re-enqueuing its Player + /// record. Active puzzle sessions write a future lease and refresh it + /// only when less than `readLeaseRefreshFloor` remains; exits/background + /// write the current time, which can intentionally close that lease. + func publishReadCursor( + for gameID: UUID, + mode: ReadCursorPublishMode = .activeLease + ) async { guard let authorID = identity.currentID, !authorID.isEmpty else { return } + let now = Date() + let didUpdate: Bool + switch mode { + case .activeLease: + didUpdate = store.setReadCursor( + gameID: gameID, + readAt: now.addingTimeInterval(Self.readLeaseDuration), + minimumExistingReadAt: now.addingTimeInterval(Self.readLeaseRefreshFloor) + ) + case .currentTime: + didUpdate = store.setReadCursor(gameID: gameID, readAt: now) + } + guard didUpdate else { return } await syncEngine.enqueuePlayerRecord(gameID: gameID, authorID: authorID) } diff --git a/Crossmate/Sync/RecordSerializer.swift b/Crossmate/Sync/RecordSerializer.swift @@ -288,11 +288,12 @@ enum RecordSerializer { return record } - /// 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. + /// Reads `readAt` off an inbound Player record — the per-account horizon + /// for collaborator moves this user has read or is actively watching. + /// Active puzzle sessions may lease the horizon into the near future and + /// close it with a lower current-time write, so callers must apply this + /// only after accepting the Player record under last-writer-wins + /// freshness checks. /// Returns `nil` if the field is missing — older records, or a slot that /// has not yet recorded a view. static func parsePlayerReadAt(from record: CKRecord) -> Date? { diff --git a/Crossmate/Sync/SyncEngine.swift b/Crossmate/Sync/SyncEngine.swift @@ -29,7 +29,7 @@ extension Notification.Name { /// What a Ping record represents. Stored as a string in the CKRecord's /// `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. +/// kind — `Player.readAt` carries cross-device read horizon state instead. enum PingKind: String, Sendable { case join case win @@ -175,9 +175,8 @@ actor SyncEngine { 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. + /// the account's read horizon; active sessions may move it into the near + /// future and later close it with a lower current-time value. private var onIncomingReadCursor: (@MainActor @Sendable ([(UUID, Date)]) async -> Void)? private var localAuthorIDProvider: (@MainActor @Sendable () -> String?)? private var tracer: (@MainActor @Sendable (String) -> Void)? @@ -2095,9 +2094,10 @@ actor SyncEngine { /// /// `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. + /// i.e. a sibling device of this account updated its read horizon. It is + /// emitted only after the Player record passes the same freshness checks + /// as the row's value fields, because active read leases can move the + /// horizon backward when they close. private nonisolated func applyPlayerRecord( _ record: CKRecord, in ctx: NSManagedObjectContext, @@ -2114,11 +2114,6 @@ 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 @@ -2177,6 +2172,10 @@ actor SyncEngine { entity.selCol = nil entity.selDir = nil } + if authorID == localAuthorID, + let readAt = RecordSerializer.parsePlayerReadAt(from: record) { + onReadCursor(gameID, readAt) + } if !foundExisting { onFirstTime(gameID) } diff --git a/Tests/Unit/GameStoreUnreadMovesTests.swift b/Tests/Unit/GameStoreUnreadMovesTests.swift @@ -203,6 +203,38 @@ struct GameStoreUnreadMovesTests { #expect(!summary.hasUnreadOtherMoves) } + @Test("Inbound moves while visible do not shorten an active read lease") + func inboundMovesWhileVisiblePreserveFutureLease() throws { + let persistence = makeTestPersistence() + let store = makeTestStore(persistence: persistence) + let (entity, gameID) = try makeSharedGame(in: persistence.viewContext) + let lease = Date(timeIntervalSinceNow: 10 * 60) + entity.lastReadOtherMoveAt = lease + try persistence.viewContext.save() + + 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.latestOtherMoveAt == updatedAt) + #expect(entity.lastReadOtherMoveAt == lease) + 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() @@ -271,14 +303,15 @@ struct GameStoreUnreadMovesTests { #expect(!summary.hasUnreadOtherMoves) } - @Test("A sibling's readAt advances the cursor and clears the badge") - func incomingReadCursorAdvancesBadge() throws { + @Test("A sibling's readAt sets the cursor and can close a future lease") + func incomingReadCursorSetsBadgeHorizon() 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) + let future = Date(timeIntervalSinceNow: 10 * 60) try addMovesRow( for: entity, gameID: gameID, @@ -304,9 +337,46 @@ struct GameStoreUnreadMovesTests { #expect(entity.lastReadOtherMoveAt == later) #expect(store.unreadOtherMovesGameCount() == 0) - // Monotonic — an older sibling claim never regresses the cursor. + // Active reads can lease the cursor into the future. + store.noteIncomingReadCursor(gameID: gameID, readAt: future) + #expect(entity.lastReadOtherMoveAt == future) + #expect(store.unreadOtherMovesGameCount() == 0) + + // Closing that lease is allowed to move the cursor backward. The sync + // layer only calls this after accepting the Player record under LWW + // freshness checks, so the store applies the value directly. store.noteIncomingReadCursor(gameID: gameID, readAt: earlier) - #expect(entity.lastReadOtherMoveAt == later) + #expect(entity.lastReadOtherMoveAt == earlier) + #expect(store.unreadOtherMovesGameCount() == 1) + } + + @Test("Active read leases refresh only when the horizon is below the floor") + func activeReadLeaseRefreshesAtFloor() throws { + let persistence = makeTestPersistence() + let store = makeTestStore(persistence: persistence) + let (entity, gameID) = try makeSharedGame(in: persistence.viewContext) + + let now = Date() + let farEnough = now.addingTimeInterval(6 * 60) + let floor = now.addingTimeInterval(5 * 60) + let refreshed = now.addingTimeInterval(10 * 60) + + #expect(store.setReadCursor(gameID: gameID, readAt: farEnough)) + #expect(!store.setReadCursor( + gameID: gameID, + readAt: refreshed, + minimumExistingReadAt: floor + )) + #expect(entity.lastReadOtherMoveAt == farEnough) + + let tooClose = now.addingTimeInterval(4 * 60) + #expect(store.setReadCursor(gameID: gameID, readAt: tooClose)) + #expect(store.setReadCursor( + gameID: gameID, + readAt: refreshed, + minimumExistingReadAt: floor + )) + #expect(entity.lastReadOtherMoveAt == refreshed) } @Test("Completed shared games do not show as unseen even with later other-author moves")