crossmate

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

commit 36b2e5296a3dc8cfeb3c6ab201d48f5b1d0dcced
parent 8d5056949d19c941dc24f22ebc6c300936a7a487
Author: Michael Camilleri <[email protected]>
Date:   Mon, 18 May 2026 07:10:56 +0900

Sync the active-puzzle lease across a user's own devices

Phase 1 fixed the single-device false badge with a local leave-grace, but a
sibling device has no idea the user is in a puzzle: the .opened ping was a
one-shot fired at open, applyOpenedPing only cleared the backlog already
delivered and NotificationState.isActive was a local-only flag. As a result,
the other device kept badging and notifying for moves the user was actively
watching on the first device.

The .opened ping now carries an OpenedLease payload {leaseMs,sentAtMs} and is
refreshed every leaseRefreshInterval while the puzzle stays open; a best-effort
.closed ping (new PingKind) ends it early on a clean exit, with lease expiry as
the fail-open backstop if either is lost. The receiver computes the deadline on
its own clock (sender clock skew can't wedge suppression) and rejects an
out-of-order ping by sentAtMs so a late .opened can't undo a .closed.
NotificationState gains remoteActiveUntil + isRemotelyActive, and a single
isSuppressed gate (local active/grace OR remote lease) replaces isActive at
presentPings, presentSessions, the GameStore badge lock-step, and the
foreground-notification handler, so notifications and the badge agree across
devices. The lease-ping sweep now reaps .closed too; with refresh sends
accruing far faster than one-per-open, the TTL drops to a day and the sweep
throttle to an hour.

Scope stays on the user's own devices — .opened/.closed are account-zone,
same-user-only, so a collaborator is never silenced.

Co-Authored-By: Claude Opus 4.7 <[email protected]>

Diffstat:
MCrossmate.xcodeproj/project.pbxproj | 4++++
MCrossmate/CrossmateApp.swift | 23++++++++++++++++++++++-
MCrossmate/Persistence/GameStore.swift | 5++++-
MCrossmate/Services/AppServices.swift | 99++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------------------
MCrossmate/Sync/SyncEngine.swift | 90+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------
MShared/NotificationState.swift | 91+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------
MTests/Unit/GameStoreUnseenMovesTests.swift | 43+++++++++++++++++++++++++++++++++++++++++++
MTests/Unit/NotificationStateTests.swift | 83+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
ATests/Unit/OpenedLeaseTests.swift | 32++++++++++++++++++++++++++++++++
9 files changed, 412 insertions(+), 58 deletions(-)

diff --git a/Crossmate.xcodeproj/project.pbxproj b/Crossmate.xcodeproj/project.pbxproj @@ -80,6 +80,7 @@ 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 */; }; @@ -118,6 +119,7 @@ /* 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>"; }; @@ -269,6 +271,7 @@ 47532AED239AEF476D8E9206 /* NotificationStateTests.swift */, ED8154F949E1D94252F70765 /* NYTAuthServiceTests.swift */, C54223FED97577A593B7964E /* NYTToXDConverterTests.swift */, + 0B5DBE77D644674456D95627 /* OpenedLeaseTests.swift */, D491B7232333AA8957732387 /* PendingEditFlagTests.swift */, 5DE04D53EC3BC7D2DA0093C3 /* PlayerNamePublisherTests.swift */, 1813630FA05C194AFF43855C /* PlayerRosterTests.swift */, @@ -536,6 +539,7 @@ C1D97A4CD02BC9C22C4208BB /* NYTAuthServiceTests.swift in Sources */, AA38A51862FC0AB8F7D34899 /* NYTToXDConverterTests.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 @@ -97,7 +97,7 @@ final class AppDelegate: NSObject, UIApplicationDelegate, @preconcurrency UNUser if isSession { NotificationState.recordShown(gameID: gameID) } - if NotificationState.isActive(gameID: gameID) { + if NotificationState.isSuppressed(gameID: gameID) { completionHandler([]) } else { completionHandler([.banner, .sound]) @@ -424,6 +424,17 @@ private struct PuzzleDisplayView: View { } .onChange(of: scenePhase) { _, newPhase in updateActiveNotificationPuzzleID(for: newPhase) + // Releasing/refreshing the cross-device lease mirrors the local + // active state: backgrounding ends it promptly so siblings stop + // suppressing; returning re-opens it without waiting for the poll. + let id = gameID + Task { + if newPhase == .active { + await services.refreshActiveLease(for: id) + } else { + await services.closeActiveLease(for: id) + } + } } .onDisappear { openPuzzleFollowUpTask?.cancel() @@ -431,9 +442,11 @@ private struct PuzzleDisplayView: View { NotificationState.clearActivePuzzleID(if: gameID) let selectionPublisher = services.playerSelectionPublisher let movesUpdater = services.movesUpdater + let id = gameID Task { await movesUpdater.flush() await selectionPublisher.clear() + await services.closeActiveLease(for: id) } } } @@ -529,6 +542,9 @@ 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 @@ -540,6 +556,11 @@ private struct PuzzleDisplayView: View { } guard !Task.isCancelled else { break } guard let scope = syncedScope else { break } + if 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/Persistence/GameStore.swift b/Crossmate/Persistence/GameStore.swift @@ -333,7 +333,10 @@ final class GameStore { // and would otherwise advance lastSeenOtherMoveAt in lockstep // even when the user is sitting on the library list, suppressing // the badge that should appear there. - if NotificationState.isActive(gameID: gameID) { + // 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. + if NotificationState.isSuppressed(gameID: gameID) { entity.lastSeenOtherMoveAt = entity.latestOtherMoveAt } } diff --git a/Crossmate/Services/AppServices.swift b/Crossmate/Services/AppServices.swift @@ -1087,16 +1087,16 @@ final class AppServices { private func presentPings(_ pings: [Ping]) async { guard !pings.isEmpty else { return } applyInvitePings(pings) - // `.opened` and `.friend` are system-only — no alert, so they run - // without notification authorization. `.opened` reconciles - // cross-device notification dismissal / badge; `.friend` is the - // friendship-bootstrap handshake. + // `.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. let (systemPings, playerFacingPings) = pings.partitioned { - $0.kind == .opened || $0.kind == .friend + $0.kind == .opened || $0.kind == .closed || $0.kind == .friend } for ping in systemPings { switch ping.kind { - case .opened: + case .opened, .closed: await applyOpenedPing(ping) case .friend: await friendController.applyFriendPing(ping) @@ -1126,7 +1126,7 @@ final class AppServices { } defer { NotificationState.recordShown(pingRecordName: ping.recordName) } - if NotificationState.isActive(gameID: ping.gameID) { + if NotificationState.isSuppressed(gameID: ping.gameID) { syncMonitor.note("ping(\(ping.kind.rawValue)): suppressed — puzzle is active for \(ping.gameID.uuidString)") continue } @@ -1178,7 +1178,7 @@ final class AppServices { syncMonitor.note("session: suppressed by win ping for \(session.gameID.uuidString)") continue } - if NotificationState.isActive(gameID: session.gameID) { + if NotificationState.isSuppressed(gameID: session.gameID) { NotificationState.recordShown(gameID: session.gameID) syncMonitor.note("session: suppressed — puzzle is active for \(session.gameID.uuidString)") continue @@ -1245,11 +1245,11 @@ final class AppServices { case .word: return "\(player) revealed a word in \(puzzleSuffix)" case .puzzle, .none: return "\(player) revealed \(puzzleSuffix)" } - case .opened, .friend: - // System-only kinds — `.opened` is handled by `applyOpenedPing` - // and `.friend` by the friendship-bootstrap path; neither is ever - // presented as a notification. If this text surfaces in a log or - // alert, the dispatch in `presentPings` has broken. + 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. return "system-only ping should not be presented" case .invite: return "\(player) invited you to \(puzzleSuffix)" @@ -1363,9 +1363,11 @@ final class AppServices { await broadcastOpenedPing(for: gameID) } - /// Applies an `.opened` ping observed from another device of the same - /// iCloud user: clears matching delivered notifications on this device - /// and marks any unseen other-author moves as seen so the badge agrees. + /// 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, @@ -1377,20 +1379,37 @@ final class AppServices { } NotificationState.recordShown(pingRecordName: ping.recordName) - 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)") + // 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) } - store.markOtherMovesSeenWithoutLoading(gameID: ping.gameID) await refreshAppBadge() } @@ -1408,6 +1427,28 @@ final class AppServices { syncMonitor.note("opened ping: enqueued for \(gameID.uuidString)") } + private func broadcastClosedPing(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) + } + /// Sets the app icon badge to the number of shared games with unseen /// other-author moves — the same `hasUnseenOtherMoves` signal that drives /// the per-row dot in the library list. Silently no-ops when the user diff --git a/Crossmate/Sync/SyncEngine.swift b/Crossmate/Sync/SyncEngine.swift @@ -33,6 +33,11 @@ enum PingKind: String, Sendable { 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 @@ -72,6 +77,26 @@ 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) + } +} + /// Owns the CloudKit sync lifecycle via two `CKSyncEngine` instances — one for /// the private database (owned games and shares) and one for the shared /// database (joined games). Zone creation, subscription setup, change-token @@ -435,11 +460,30 @@ 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). - func enqueueOpenedPing( + /// 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 { @@ -464,9 +508,12 @@ actor SyncEngine { playerName: playerName, puzzleTitle: title, eventTimestampMs: eventTimestampMs, - kind: .opened, + kind: kind, scope: nil, - payload: nil + payload: OpenedLease( + leaseMs: leaseMs, + sentAtMs: eventTimestampMs + ).encoded() ) let zoneID = RecordSerializer.accountZoneID // Make sure the zone exists before the record write. CKSyncEngine @@ -478,27 +525,30 @@ actor SyncEngine { Task { await sweepStaleOpenedPings() } } - /// Time after which this device's own `.opened` pings are eligible for - /// reaping. `.opened` records can't be consumed-then-deleted — a sibling - /// device that hasn't synced yet still needs them — so age is the only - /// safe signal. A missed dismissal past this window is well within the - /// app's eventual-consistency tolerance. - private static let openedPingTTL: TimeInterval = 14 * 86_400 - - /// Best-effort reaper for this device's own stale `.opened` 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-open call site stays cheap; the timestamp is written only on - /// success, so a transient failure simply retries on the next open. + /// 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) let predicate = NSPredicate( - format: "kind == %@ AND deviceID == %@ AND modificationDate < %@", - PingKind.opened.rawValue, + format: "kind IN %@ AND deviceID == %@ AND modificationDate < %@", + [PingKind.opened.rawValue, PingKind.closed.rawValue], RecordSerializer.localDeviceID, cutoff as NSDate ) diff --git a/Shared/NotificationState.swift b/Shared/NotificationState.swift @@ -2,12 +2,16 @@ import Foundation /// Notification suppression state persisted via App Group UserDefaults. /// -/// Two pieces of state are tracked: -/// - `activePuzzleID` — set by the app while the user is viewing a puzzle in -/// the foreground so local notifications for that same puzzle can be skipped. +/// 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. enum NotificationState { static let appGroup = "group.net.inqk.crossmate" @@ -21,6 +25,8 @@ 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 @@ -29,6 +35,17 @@ 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 @@ -111,6 +128,65 @@ 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. + 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 /// been presented. Used to keep the push fast path and the eventual /// CKSyncEngine catch-up from double-notifying for the same ping. @@ -140,10 +216,11 @@ enum NotificationState { private static let openedSweepAtKey = "notif.openedSweepAt" - /// Minimum spacing between account-zone `.opened` ping sweeps. The sweep - /// is invoked from the per-open ping broadcast, which is far too hot to - /// query CloudKit each time; once a day bounds zone growth without churn. - static let openedSweepInterval: TimeInterval = 24 * 60 * 60 + /// 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 /// True if at least `openedSweepInterval` has elapsed since the last /// successful sweep, or it has never run. Returns false when the shared diff --git a/Tests/Unit/GameStoreUnseenMovesTests.swift b/Tests/Unit/GameStoreUnseenMovesTests.swift @@ -271,6 +271,49 @@ struct GameStoreUnseenMovesTests { #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() diff --git a/Tests/Unit/NotificationStateTests.swift b/Tests/Unit/NotificationStateTests.swift @@ -86,4 +86,87 @@ struct NotificationStateTests { 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() { + let gameID = UUID() + let base = Date(timeIntervalSince1970: 7_000_000) + NotificationState.setActivePuzzleID(nil) + #expect(!NotificationState.isSuppressed(gameID: gameID, now: base)) + + 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 + ) + #expect(NotificationState.isSuppressed(gameID: gameID, now: base)) + } } diff --git a/Tests/Unit/OpenedLeaseTests.swift b/Tests/Unit/OpenedLeaseTests.swift @@ -0,0 +1,32 @@ +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) + } +}