crossmate

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

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:
MCrossmate/Services/SessionCoordinator.swift | 62++++++++++++++++++++++++++++++++++++++++++++++++++------------
MCrossmate/Services/SessionPushPlanner.swift | 20++++++++++++++++++++
MTests/Unit/SessionPushPlannerTests.swift | 24+++++++++++++++++++++++-
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)