crossmate

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

commit e67cd76ae4688b8c2386447774baad2599fc6df8
parent c2291d7736b64aa21bc86c7994643154b023e364
Author: Michael Camilleri <[email protected]>
Date:   Sun, 19 Jul 2026 10:24:00 +0900

Drive startup from a background push wake

Buffering pre-start remote notifications left one gap: a
content-available push that relaunches the terminated app in the
background gets no scene, so the root view's startup task — the only
caller of AppServices.start — never runs. The buffered wake sat in the
queue until the process died, the intended background fetch never
happened, and iOS was still told the wake produced new data, feeding its
delivery-throttling heuristics with false results.

This commit lets the wake drive startup itself. CrossmateApp.init now
installs the process's services instance as AppServices.current, and a
push that arrives before the handler is installed buffers as before,
then calls start through that reference. start is idempotent, so racing
the root view's task is safe — when startup is already under way the
call returns promptly and the drain runs on handler installation,
exactly as in a foreground cold launch. When the push is the sole
driver, the delegate awaits the drain before returning its fetch result,
so the completion reports after the buffered work actually ran, inside
the background execution budget. The drain is now a chained task to make
that await possible without letting replays interleave.

Only the delegate the system installed may start services: a delegate
constructed elsewhere (unit tests build their own) is refused, so it
cannot capture the real app's handlers.

Co-Authored-By: Claude Fable 5 <[email protected]>

Diffstat:
MCrossmate/CrossmateApp.swift | 42+++++++++++++++++++++++++++++++-----------
MCrossmate/Services/AppServices.swift | 8++++++++
2 files changed, 39 insertions(+), 11 deletions(-)

diff --git a/Crossmate/CrossmateApp.swift b/Crossmate/CrossmateApp.swift @@ -10,7 +10,9 @@ struct CrossmateApp: App { init() { AppDefaultsMigrator.run() - self._services = State(initialValue: AppServices()) + let services = AppServices() + self._services = State(initialValue: services) + AppServices.current = services } var body: some Scene { @@ -167,12 +169,18 @@ final class AppDelegate: UIResponder, UIApplicationDelegate, @preconcurrency UNU } } + /// The in-flight drain, kept so a background wake that drove startup can + /// await the buffered work before completing its fetch handler. Chained: + /// a new drain awaits its predecessor, so replays never interleave. + private var remoteNotificationDrain: Task<Void, Never>? + private func drainBufferedRemoteNotifications() { guard onRemoteNotification != nil, !bufferedRemoteNotifications.isEmpty else { return } - let drained = bufferedRemoteNotifications - bufferedRemoteNotifications = [] - Task { @MainActor in - for push in drained { + let previous = remoteNotificationDrain + remoteNotificationDrain = Task { @MainActor in + await previous?.value + while !bufferedRemoteNotifications.isEmpty { + let push = bufferedRemoteNotifications.removeFirst() await onRemoteNotification?( push.summary, push.scope, @@ -329,12 +337,20 @@ final class AppDelegate: UIResponder, UIApplicationDelegate, @preconcurrency UNU let presenceUntil = Self.date(from: userInfo["presenceUntil"] as? String) let isBackground = application.applicationState != .active guard let onRemoteNotification else { - // Pre-start arrival: buffer the wake and complete promptly — the - // work runs once startup installs the handler, and holding the - // system completion open for that would risk the background - // budget. In a background-only launch no scene ever activates, so - // the drain may never run this process; that leaves such wakes a - // no-op, exactly as they were before buffering existed. + // Pre-start arrival: buffer the wake, then drive startup — this + // push may be the only driver the process gets, because on a + // background-only launch no scene activates and the root view's + // startup task never runs. `start` is idempotent, so racing the + // root task is safe: if startup is already under way this returns + // promptly and the drain runs when the handler installs, exactly + // as in a foreground cold launch. When this call *is* the one + // that starts services, awaiting the drain keeps the fetch + // completion honest — it reports after the buffered work ran, + // inside the background execution budget. + // + // Only the installed delegate may do this: a delegate the system + // is not using (unit tests construct their own) must not capture + // the real handlers by starting services itself. bufferRemoteNotification(BufferedRemoteNotification( summary: summary, scope: scope, @@ -345,6 +361,10 @@ final class AppDelegate: UIResponder, UIApplicationDelegate, @preconcurrency UNU presenceUntil: presenceUntil, isBackground: isBackground )) + if application.delegate === self, let services = AppServices.current { + await services.start(appDelegate: self) + await remoteNotificationDrain?.value + } return .newData } await onRemoteNotification( diff --git a/Crossmate/Services/AppServices.swift b/Crossmate/Services/AppServices.swift @@ -252,6 +252,14 @@ enum DemoSeed { @MainActor final class AppServices { + /// The process's one services instance, installed by `CrossmateApp.init` + /// and owned by the App's `@State`. Exists for the app delegate's push + /// path: on a background-only launch no scene ever activates, so the root + /// view's startup task never calls `start` — the delegate drives startup + /// through this reference instead. Weak because the App owns the + /// instance's lifetime; this is a lookup, not a retain. + static weak var current: AppServices? + enum ReadCursorPublishMode { case activeLease case currentTime