crossmate

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

commit 002eff09abc718780159be29e0c828fd829f578a
parent 3eac7a3ecace999445bbf690b467831b51276709
Author: Michael Camilleri <[email protected]>
Date:   Sun, 31 May 2026 06:40:12 +0900

Defer the session-begin push behind a grace window

Opening a puzzle announced the play session to peers at once — the
instant a shared game became active, every other device got 'Alice is
solving X'. A user who popped in and straight back out (opened the wrong
puzzle, peeked at a shared grid, fat-fingered a row in the list) fanned
that push to everyone for a visit that never happened.

This commit mirrors the existing pause grace onto the begin side.
scheduleSessionBeginPush defers the play push by sessionBeginGrace (30s)
and cancelPendingSessionBeginPush drops a still-waiting one, reporting
whether it was pending so the caller can tell 'never announced' from
'announced, now leaving'. The scene-phase .active path schedules instead
of firing; backing out before the grace elapses — the app backgrounding
or the puzzle view disappearing — cancels the timer and, since there was
no play to pause, skips the matching pause push rather than firing an
unpaired one.

Unlike the pause timer this holds no background-execution assertion: the
user is foregrounded on the puzzle for the whole window, and any
departure cancels, so there is nothing to keep alive under suspension. A
visit that outlasts the grace announces exactly as before, 30s later.
The change is confined to the play push; the readAt presence lease and
engagement socket still arm on open.

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

Diffstat:
MCrossmate/CrossmateApp.swift | 26++++++++++++++++++++------
MCrossmate/Services/AppServices.swift | 45+++++++++++++++++++++++++++++++++++++++++----
2 files changed, 61 insertions(+), 10 deletions(-)

diff --git a/Crossmate/CrossmateApp.swift b/Crossmate/CrossmateApp.swift @@ -617,6 +617,9 @@ private struct PuzzleDisplayView: View { let syncEngine = services.syncEngine let id = gameID services.scheduleEngagementEnd(gameID: id) + // A visit that never outlasted the begin grace was never announced + // to peers, so there's no play session to pause on the way out. + let neverAnnounced = services.cancelPendingSessionBeginPush(gameID: id) Task { await movesUpdater.flush() // Mirror the open-burst pattern: the clear-cursor and @@ -632,7 +635,9 @@ private struct PuzzleDisplayView: View { // Backgrounding may have already drained the tracker; the // skip-if-zero guard inside publishSessionEndPush keeps the // close-after-background case from firing a second push. - await services.publishSessionEndPush(gameID: id) + if !neverAnnounced { + await services.publishSessionEndPush(gameID: id) + } } } } @@ -682,14 +687,23 @@ private struct PuzzleDisplayView: View { // pause/play pair, for a brief absence (phone sleep, a call, // app-switcher peek that escalated to background). let resumed = services.cancelPendingSessionEndPush(gameID: id) - Task { - services.handlePuzzleOpened(gameID: id) - if !resumed { - await services.publishSessionBeginPush(gameID: id) - } + services.handlePuzzleOpened(gameID: id) + if !resumed { + // Defer the play push so a quick pop-in/pop-out reaches no one; + // backing out before the grace elapses cancels it below. + services.scheduleSessionBeginPush( + gameID: id, + after: AppServices.sessionBeginGrace + ) } case .background: NotificationState.clearActivePuzzleID(if: gameID) + // A session still inside its begin grace was never announced, so + // there's nothing to pause — drop the deferred play push and skip + // the matching pause rather than firing an unpaired one. + if services.cancelPendingSessionBeginPush(gameID: id) { + break + } services.scheduleSessionEndPush( gameID: id, after: AppServices.sessionEndGrace diff --git a/Crossmate/Services/AppServices.swift b/Crossmate/Services/AppServices.swift @@ -69,6 +69,13 @@ final class AppServices { let playerSelectionPublisher: PlayerSelectionPublisher let identity: AuthorIdentity let pushClient: PushClient? + /// Grace window before opening a puzzle is announced to peers as a play + /// session. A user who pops in and out — opens the wrong puzzle, peeks at + /// a shared grid, then leaves — within this window should fan out nothing. + /// The begin push is deferred by this much and cancelled if the user backs + /// out (app backgrounded or navigated away) before it elapses. + static let sessionBeginGrace: TimeInterval = 30 + private var pendingSessionBeginTasks: [UUID: Task<Void, Never>] = [:] /// Grace window before a backgrounded session is treated as ended. A /// briefly-backgrounded puzzle (phone sleep, app switcher peek, taking a /// call) should not fan out pause/play pings to peers on every flicker — @@ -645,10 +652,40 @@ final class AppServices { pushClient.setAddresses(result.addresses) } - /// Sender-side session-begin push. The opening device owns the - /// notification timing, so peers get "Alice is solving X" the instant - /// Alice opens the puzzle. Kind is `play` because the user-facing event is - /// "started playing", not the durable share-acceptance fact "joined". + /// Defer the session-begin push by `seconds`, replacing any pending timer + /// for the same game. The opening device owns the notification timing, but + /// a brief visit shouldn't reach peers at all, so the "Alice is solving X" + /// push waits out `sessionBeginGrace`; backing out before it elapses (app + /// backgrounded, or the puzzle view disappearing) calls + /// `cancelPendingSessionBeginPush` to drop it silently. Unlike the pause + /// timer this holds no background assertion: the user is foregrounded and + /// looking at the puzzle for the whole window, and any departure cancels. + func scheduleSessionBeginPush(gameID: UUID, after seconds: TimeInterval) { + pendingSessionBeginTasks.removeValue(forKey: gameID)?.cancel() + pendingSessionBeginTasks[gameID] = Task { [weak self] in + try? await Task.sleep(for: .seconds(seconds)) + guard !Task.isCancelled, let self else { return } + self.pendingSessionBeginTasks.removeValue(forKey: gameID) + await self.publishSessionBeginPush(gameID: gameID) + } + } + + /// Drop a begin push still waiting out its grace window. Returns whether + /// one was pending — i.e. the session was never announced to peers — so + /// the caller can skip the matching pause push (there's no play to pause). + @discardableResult + func cancelPendingSessionBeginPush(gameID: UUID) -> Bool { + guard let task = pendingSessionBeginTasks.removeValue(forKey: gameID) else { + return false + } + task.cancel() + return true + } + + /// Sender-side session-begin push. Peers get "Alice is solving X" once the + /// open has outlasted `sessionBeginGrace` (see `scheduleSessionBeginPush`). + /// Kind is `play` because the user-facing event is "started playing", not + /// the durable share-acceptance fact "joined". func publishSessionBeginPush(gameID: UUID) async { guard let localAuthorID = identity.currentID, !localAuthorID.isEmpty else { syncMonitor.note("push(play): skipped (no authorID)")