commit 7e228e4a0c81ad512e1707a0c1a4e53efb51db4d
parent 1c0b84461d48121eef5c62aec7fe7c02cfb27274
Author: Michael Camilleri <[email protected]>
Date: Sun, 14 Jun 2026 13:55:06 +0900
Correct the foreground flag from the OS state on push wakes
isAppForeground is the single fact both presence gates depend on:
publishReadCursor refuses to advance an active read lease, and
reconcileEngagement refuses to dial the live socket, unless the app is
foreground. Both were added to stop a background CKSyncEngine wake from
resurrecting a departed peer's presence. But the flag they read is
written in only one place — the @main App's .onChange(of: scenePhase) —
and SwiftUI does not deliver an .onChange for the initial scene phase.
A process launched or woken straight into the background by a
content-available push therefore never observes its opening .background
phase, so isAppForeground keeps its optimistic true default for the
whole background lifetime.
Every gate then reads that lie in unison. On each ~7.5-min background
push wake the device re-mints its own Player.readAt lease (foreground=
true, suppressed=false) and re-dials the engagement socket. A
collaborator fetches the future-dated lease and renders the absent user
as present — a ghost peer that the user, looking at a backgrounded or
unopened app, never authorised — while the .default URLSession WebSocket
aborts on suspension and storms connect/abort/reconnect until the next
push. Both the read-lease gate (build 448) and the socket gate (build
458) sit downstream of the poisoned flag, so neither ever closed this.
handleRemoteNotification already receives the authoritative state the
AppDelegate read at delivery (isBackground = applicationState !=
.active). This commit feeds that back into the flag, before any await
so the correction lands ahead of every fetch callback that could mint or
reconcile. The write only ever downgrades: a content-available push is
by definition not the user looking at the app, and a genuine foreground
is still restored by the real .background -> .active transition, which
SwiftUI does deliver. A push arriving during a transient .inactive
briefly reads as background; that self-heals on the next .active
republish and at worst delays one floor-gated (~5 min) lease renewal.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Diffstat:
1 file changed, 14 insertions(+), 0 deletions(-)
diff --git a/Crossmate/Services/AppServices.swift b/Crossmate/Services/AppServices.swift
@@ -1240,6 +1240,20 @@ final class AppServices {
readAt: Date?,
isBackground: Bool
) async {
+ // Authoritative foreground correction. A content-available push is, by
+ // definition, not the user looking at this app, so when the OS reports
+ // we're backgrounded, pull the cached `isAppForeground` flag false now.
+ // `scenePhase`'s `.onChange` — the flag's only other writer — never
+ // fires for the *initial* phase of a process launched or woken straight
+ // into the background, so the flag otherwise keeps its optimistic `true`
+ // default and every background wake slips past the presence and
+ // engagement foreground gates: re-arming a departed peer's read lease
+ // (the ghost) and re-dialling the live socket it can't sustain. Only
+ // ever downgrade here — a genuine foreground is restored by the
+ // `.active` scenePhase transition, never by a push.
+ if isBackground {
+ noteAppForeground(false)
+ }
guard preferences.isICloudSyncEnabled else {
syncMonitor.note("remote notification ignored while iCloud sync is disabled")
return