commit ca0242332924fd1bb31f1ab579ca2fed5ab95456
parent 767d29a66d4fd810346eadc87cf5b3f9d1cb1e49
Author: Michael Camilleri <[email protected]>
Date: Thu, 11 Jun 2026 14:39:08 +0900
Skip session pushes to recipients leased into the game
A play or pause push fans out to every device of the recipient's
account, so a recipient who is in the game on one device still gets a
banner on their others — the worker resolves a per-(account, game)
address to all registered tokens, a backgrounded device never runs
willPresent, and the NSE cannot drop an alert without the filtering
entitlement. Gate at the only place that can decide per-recipient: the
sender.
PushRecipient now carries the recipient's presence lease (Player.readAt)
alongside the read watermark, and a new
SessionPushPlanner.absentRecipients filter — built on
PeerPresence.isPresent, the established single presence rule with its
bounce grace — drops leased-present recipients from the begin, end, and
win/resign fan-outs (replay stays untouched: it is a background push
with no banner). They watch the session live through the roster and
engagement channel; counts still window on readThrough, never the
lease.
Skipped recipients keep their notifiedThrough (nothing was reported to
them), and a pause whose recipients were all present still re-arms the
next 'play' announce and releases the per-game session state, just as a
sent pause would. The cost is the lease-collapse sync window: a
recipient who just left may miss one pause summary and catches up via
the in-app banner instead.
Co-Authored-By: Claude Fable 5 <[email protected]>
Diffstat:
3 files changed, 93 insertions(+), 13 deletions(-)
diff --git a/Crossmate/Services/SessionCoordinator.swift b/Crossmate/Services/SessionCoordinator.swift
@@ -215,7 +215,16 @@ final class SessionCoordinator {
syncMonitor.note("push(play): skipped (access revoked)")
return
}
- let addressees = plan.recipients.compactMap { recipient in
+ // A recipient whose presence lease is live is in the game watching us
+ // appear through the roster — pushing would banner their *other*
+ // devices for a session they're already in.
+ let recipients = SessionPushPlanner.absentRecipients(plan.recipients)
+ if recipients.count < plan.recipients.count {
+ syncMonitor.note(
+ "push(play): skipped \(plan.recipients.count - recipients.count) present recipient(s)"
+ )
+ }
+ let addressees = recipients.compactMap { recipient in
recipient.pushAddress.map {
PushClient.Addressee(address: $0, payload: PushPayload(event: .play))
}
@@ -238,12 +247,13 @@ final class SessionCoordinator {
session(for: gameID).noteBeginAnnounced()
}
- /// Sender-side session-end push. For each recipient, counts cells in
- /// the author's merged-across-devices Moves whose `updatedAt` is newer
- /// than that recipient's last-known `Player.readAt`, and ships a body
- /// describing only what *that* recipient hasn't seen. Recipients whose
- /// readAt already covers every author cell are dropped — they have
- /// nothing unseen, so a banner-and-badge for them would be misleading.
+ /// Sender-side session-end push. For each recipient, tallies this
+ /// device's journal entries newer than that recipient's read watermark
+ /// (`Player.readThrough`), and ships a body describing only what *that*
+ /// recipient hasn't seen. Caught-up recipients still get a presence-only
+ /// "stopped solving"; recipients whose presence lease shows them in the
+ /// game right now are dropped entirely — they watched the session live,
+ /// and the push would banner their other devices.
///
/// Suppresses the push when a peer device of this author wrote to
/// Player during the grace window — that device is still playing and
@@ -287,6 +297,24 @@ final class SessionCoordinator {
syncMonitor.note("push(pause): skipped (access revoked)")
return
}
+ // Same gate as the begin push: a leased-present recipient watched this
+ // session live, so the pause would only banner their other devices.
+ // Skipped recipients keep their `notifiedThrough` — nothing was
+ // reported to them, so the next pause re-tallies from their watermark.
+ let recipients = SessionPushPlanner.absentRecipients(plan.recipients)
+ if recipients.count < plan.recipients.count {
+ syncMonitor.note(
+ "push(pause): skipped \(plan.recipients.count - recipients.count) present recipient(s)"
+ )
+ }
+ guard !recipients.isEmpty else {
+ // Everyone watched live, so no push goes out — but the session
+ // still closed: re-arm the next "play" announce and release the
+ // per-game state machine, exactly as a sent pause would.
+ sessions[gameID]?.noteEndAnnounced()
+ pruneIfIdle(gameID)
+ return
+ }
// The pause counts are derived from this device's own journal (gesture
// history), not the merged grid, so the summary can name fills/clears/
// checks/reveals. The merged-grid measurements still ride the
@@ -304,7 +332,7 @@ final class SessionCoordinator {
// signal worth delivering even with nothing unseen (see
// `SessionPushPlanner.sessionEndAddressees`).
let addressees = SessionPushPlanner.sessionEndAddressees(
- recipients: plan.recipients,
+ recipients: recipients,
journalEntries: journalEntries,
playerName: preferences.name,
puzzleTitle: plan.title,
@@ -342,7 +370,7 @@ final class SessionCoordinator {
// repeating the same summary. Recipients we couldn't address (no push
// capability) keep their old watermark and catch up when reachable.
if let notifiedThrough = journalEntries.map(\.timestamp).max() {
- let addressed = plan.recipients
+ let addressed = recipients
.filter { $0.pushAddress != nil }
.map(\.authorID)
store.recordNotified(gameID: gameID, authorIDs: addressed, through: notifiedThrough)
@@ -367,8 +395,17 @@ final class SessionCoordinator {
syncMonitor.note("push(\(kindLabel)): skipped (no recipients)")
return
}
+ // Same gate as play/pause: a leased-present recipient sees the
+ // completion land in the grid live, so the push would only banner
+ // their other devices.
+ let recipients = SessionPushPlanner.absentRecipients(plan.recipients)
+ if recipients.count < plan.recipients.count {
+ syncMonitor.note(
+ "push(\(kindLabel)): skipped \(plan.recipients.count - recipients.count) present recipient(s)"
+ )
+ }
let event: PushPayload.Event = resigned ? .resign : .win
- let addressees = plan.recipients.compactMap { recipient in
+ let addressees = recipients.compactMap { recipient in
recipient.pushAddress.map {
PushClient.Addressee(address: $0, payload: PushPayload(event: event))
}
@@ -450,7 +487,7 @@ final class SessionCoordinator {
gReq.predicate = NSPredicate(format: "id == %@", gameID as CVarArg)
gReq.fetchLimit = 1
guard let game = try? ctx.fetch(gReq).first else { return .empty }
- var byAuthor: [String: (readThrough: Date?, notifiedThrough: Date?, pushAddress: String?)] = [:]
+ var byAuthor: [String: (readAt: Date?, readThrough: Date?, notifiedThrough: Date?, pushAddress: String?)] = [:]
let pReq = NSFetchRequest<PlayerEntity>(entityName: "PlayerEntity")
pReq.predicate = NSPredicate(format: "game == %@", game)
for p in (try? ctx.fetch(pReq)) ?? [] {
@@ -459,11 +496,12 @@ final class SessionCoordinator {
!a.isEmpty
else { continue }
if let authorID, a == authorID { continue }
- byAuthor[a] = (p.readThrough, p.notifiedThrough, p.pushAddress)
+ byAuthor[a] = (p.readAt, p.readThrough, p.notifiedThrough, p.pushAddress)
}
let recipients = byAuthor.map {
PushRecipient(
authorID: $0.key,
+ readAt: $0.value.readAt,
readThrough: $0.value.readThrough,
notifiedThrough: $0.value.notifiedThrough,
pushAddress: $0.value.pushAddress
diff --git a/Crossmate/Services/SessionPushPlanner.swift b/Crossmate/Services/SessionPushPlanner.swift
@@ -12,6 +12,12 @@ import Foundation
/// callers consult before emitting.
struct PushRecipient: Sendable, Equatable {
let authorID: String
+ /// The recipient's presence lease (`Player.readAt`), forward-dated while
+ /// one of their devices is in the puzzle. Used only to gate whether to
+ /// address them at all (`absentRecipients`) — a present recipient is
+ /// watching live, so a banner on their other devices is noise. Counts
+ /// never window on it; that's `readThrough`'s job.
+ let readAt: Date?
/// The recipient's read watermark (`Player.readThrough`): the latest
/// other-author move time they've actually seen. The session-end tally
/// windows on this — never the forward-dated presence lease — so a peer
@@ -34,6 +40,20 @@ struct PushRecipient: Sendable, Equatable {
}
enum SessionPushPlanner {
+ /// Drops recipients whose presence lease shows them in the game right now
+ /// (`PeerPresence.isPresent`, including its bounce grace). Their devices
+ /// render the session live through the roster and engagement channel, so a
+ /// play/pause banner — which lands on *every* device of their account,
+ /// including the one in the game — is noise. The cost is the sync window:
+ /// a recipient whose collapsed lease hasn't reached the sender yet is
+ /// skipped and catches up via the in-app summary banner instead.
+ static func absentRecipients(
+ _ recipients: [PushRecipient],
+ asOf now: Date = Date()
+ ) -> [PushRecipient] {
+ recipients.filter { !PeerPresence.isPresent(readAt: $0.readAt, asOf: now) }
+ }
+
/// Builds the per-recipient addressees for a session-end push. Every
/// addressable recipient is included — caught-up recipients too, with zero
/// counts — because a session end is a presence signal worth delivering
diff --git a/Tests/Unit/SessionPushPlannerTests.swift b/Tests/Unit/SessionPushPlannerTests.swift
@@ -33,10 +33,12 @@ struct SessionPushPlannerTests {
private func recipient(
_ address: String?,
readThrough: Date?,
- notifiedThrough: Date? = nil
+ notifiedThrough: Date? = nil,
+ readAt: Date? = nil
) -> PushRecipient {
PushRecipient(
authorID: "peer",
+ readAt: readAt,
readThrough: readThrough,
notifiedThrough: notifiedThrough,
pushAddress: address
@@ -301,6 +303,26 @@ struct SessionPushPlannerTests {
#expect(addressees.isEmpty)
}
+ @Test("A leased-present recipient is filtered out of the fan-out")
+ func presentRecipientFiltered() {
+ let now = Date(timeIntervalSince1970: 100_000)
+ // In the game: lease minted into the future.
+ let present = recipient("addr-present", readThrough: nil, readAt: now.addingTimeInterval(600))
+ // Bounced moments ago: collapsed lease still inside the presence grace.
+ let bounced = recipient("addr-bounced", readThrough: nil, readAt: now.addingTimeInterval(-30))
+ // Genuinely gone: lease lapsed beyond the grace.
+ let departed = recipient("addr-departed", readThrough: nil, readAt: now.addingTimeInterval(-120))
+ // Never present (no lease ever synced).
+ let never = recipient("addr-never", readThrough: nil)
+
+ let absent = SessionPushPlanner.absentRecipients(
+ [present, bounced, departed, never],
+ asOf: now
+ )
+
+ #expect(absent.map(\.pushAddress) == ["addr-departed", "addr-never"])
+ }
+
@Test("Caught-up and behind recipients are both addressed in one fan-out")
func mixedRecipientsAllIncluded() {
let edit = Date(timeIntervalSince1970: 1_000)