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:
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)
+ }
+}