From 43a9784aa1958575e9285b67c3142a109d3c7359 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Mon, 6 Jul 2026 14:07:58 -0700 Subject: [PATCH] nvrsion: Add functionality to handle Launch Activity Logic in Sync, PushRelayClient, SyncHost, and related iOS/Cloud files. Nucleic-Promote: 1 Co-authored-by: Nucleic --- .../NucleicRemote/LiveActivityManager.swift | 6 ++ .../NucleicRemote/Models/RemoteStore.swift | 78 +++++++++++++++++-- .../NucleicRemote/NucleicRemoteApp.swift | 4 +- .../NucleicRemote/PushRegistrar.swift | 21 ++++- 4 files changed, 99 insertions(+), 10 deletions(-) diff --git a/NucleicRemote/NucleicRemote/LiveActivityManager.swift b/NucleicRemote/NucleicRemote/LiveActivityManager.swift index 14b2c9b..73dddd9 100644 --- a/NucleicRemote/NucleicRemote/LiveActivityManager.swift +++ b/NucleicRemote/NucleicRemote/LiveActivityManager.swift @@ -195,6 +195,12 @@ final class LiveActivityManager { /// bridge setup; the guards keep a single observer each. func beginPushToStartObservation() { guard ActivityAuthorizationInfo().areActivitiesEnabled else { return } + // Grab an activity that already exists when we start observing — e.g. one a Mac push-started + // while the app was closed, which won't re-emit through `activityUpdates` for a late observer. + // This is what lets a background "adopt" wake harvest and forward its update token. + if activity == nil, let existing = Activity.activities.first { + adopt(existing) + } if pushToStartObservation == nil { pushToStartObservation = Task { [weak self] in for await data in Activity.pushToStartTokenUpdates { diff --git a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift index 16f7556..f7fe423 100644 --- a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift +++ b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift @@ -230,6 +230,12 @@ final class RemoteStore: ObservableObject { var canControl: Bool { grantedScope >= .control } + /// The process-wide store. The SwiftUI `App` binds its `@StateObject` to this instance so the + /// UI and any headless entry point share one store — in particular the background push handler + /// (`PushAppDelegate`), which on a silent launch has no mounted scene (`onAppear` never fires) + /// and so must reach the store directly to bring it online and adopt a push-started Live Activity. + static let shared = RemoteStore() + private let identity = IdentityStore.loadOrCreateIdentity() let discovery = LANDiscovery() /// Proactive network-path awareness (shared across every host connection): flips transports the @@ -314,11 +320,7 @@ final class RemoteStore: ObservableObject { func onAppear() { setupLiveActivityBridge() if demoMode { seedDemo(); return } - // Re-plan every connection the moment the network path changes (left Wi-Fi, joined a - // network, cellular⇄Wi-Fi) — the proactive half of "immediate switchover". - pathMonitor.onChange = { [weak self] in self?.handlePathChange() } - pathMonitor.start() - discovery.start() + startNetworkingIfNeeded() if isPaired { // Show the saved chat history immediately, before any host connects. if sessions.isEmpty { sessions = cachedSummaries } @@ -326,6 +328,22 @@ final class RemoteStore: ObservableObject { } } + /// Whether `startNetworkingIfNeeded` has already wired up path monitoring + LAN discovery. + private var networkingStarted = false + + /// Start the shared network-path monitor and LAN discovery once. Factored out of `onAppear` so + /// the background push handler — which runs when `onAppear` never fired (silent launch, no scene) + /// — can bring the same machinery up before it dials. + private func startNetworkingIfNeeded() { + guard !networkingStarted else { return } + networkingStarted = true + // Re-plan every connection the moment the network path changes (left Wi-Fi, joined a + // network, cellular⇄Wi-Fi) — the proactive half of "immediate switchover". + pathMonitor.onChange = { [weak self] in self?.handlePathChange() } + pathMonitor.start() + discovery.start() + } + /// A network-path change: tell each host connection to re-plan its transport now. private func handlePathChange() { guard !demoMode else { return } @@ -377,6 +395,50 @@ final class RemoteStore: ObservableObject { #endif } + /// Handle the host's silent "adopt" wake (UX_IOS §5.3): a session started while the app was + /// closed, the host push-*started* the Live Activity, and now the phone must come online long + /// enough to harvest that activity's per-activity update token and register it — so live updates + /// (and a clean end) resume without the user ever opening the app. Called from `PushAppDelegate` + /// on a `content-available` background push, which may relaunch the app with no scene, so this + /// does the setup `onAppear` normally would. Holds a background-task assertion until the token is + /// registered with a host or the window closes, then calls `completion`. Best-effort: iOS may + /// deny background runtime (budget, force-quit), in which case the glance keeps its start-time + /// state until the app next runs. + func handleLiveActivityAdoptWake(completion: @escaping () -> Void) { + guard !demoMode, isPaired else { completion(); return } + setupLiveActivityBridge() // starts the push-to-start + adoption observers if not already + startNetworkingIfNeeded() + #if canImport(UIKit) + var bgTask: UIBackgroundTaskIdentifier = .invalid + var finished = false + let finish = { + guard !finished else { return } + finished = true + if bgTask != .invalid { + UIApplication.shared.endBackgroundTask(bgTask) + bgTask = .invalid + } + completion() + } + bgTask = UIApplication.shared.beginBackgroundTask(withName: "nucleic.liveactivity.adopt") { + finish() // the OS reclaimed the grant before we finished — report what we have + } + #else + let finish = completion + #endif + pathMonitor.refresh() + reconnect() + // Poll until the harvested update token has reached a host (`liveActivitySentTo` fills in via + // the adopt → onPushToken → syncLiveActivityRegistration chain), or we run out of runtime. + Task { @MainActor [weak self] in + for _ in 0..<50 { // ~25s at a 0.5s cadence, inside iOS's ~30s background grant + guard let self, self.liveActivitySentTo.isEmpty else { break } + try? await Task.sleep(for: .milliseconds(500)) + } + finish() + } + } + private func seedDemo() { connectivity = .connected(.lan) hostName = "Andrew's Mac" @@ -1235,7 +1297,13 @@ final class RemoteStore: ObservableObject { /// Host ids that already have the current push-to-start token (mirrors `liveActivitySentTo`). private var pushToStartSentTo: Set = [] + /// Guards `setupLiveActivityBridge` — it's called from both `onAppear` and the background adopt + /// wake, and installing the callbacks / observers once is enough. + private var liveActivityBridgeSetup = false + private func setupLiveActivityBridge() { + guard !liveActivityBridgeSetup else { return } + liveActivityBridgeSetup = true LiveActivityManager.shared.onPushToken = { [weak self] token, activityID in guard let self, self.liveActivityReg?.token != token else { return } self.liveActivityReg = (token, activityID) diff --git a/NucleicRemote/NucleicRemote/NucleicRemoteApp.swift b/NucleicRemote/NucleicRemote/NucleicRemoteApp.swift index 0337176..7209464 100644 --- a/NucleicRemote/NucleicRemote/NucleicRemoteApp.swift +++ b/NucleicRemote/NucleicRemote/NucleicRemoteApp.swift @@ -2,7 +2,9 @@ import SwiftUI @main struct NucleicRemoteApp: App { - @StateObject private var store = RemoteStore() + // Bind to the shared instance so the background push handler (which runs with no mounted scene) + // and the UI drive the same store. + @StateObject private var store = RemoteStore.shared @Environment(\.scenePhase) private var scenePhase @UIApplicationDelegateAdaptor(PushAppDelegate.self) private var pushDelegate diff --git a/NucleicRemote/NucleicRemote/PushRegistrar.swift b/NucleicRemote/NucleicRemote/PushRegistrar.swift index 0f0b7b5..1e9e16a 100644 --- a/NucleicRemote/NucleicRemote/PushRegistrar.swift +++ b/NucleicRemote/NucleicRemote/PushRegistrar.swift @@ -46,15 +46,28 @@ final class PushAppDelegate: NSObject, UIApplicationDelegate { // Best-effort: no token → the phone still works on LAN, just no remote wake. } - /// Silent pushes. The relay sends a content-free `clear` (content-available) to recall the - /// "waiting for your approval" tickle once the approval is resolved on another device — we - /// wake in the background just long enough to withdraw the stale notification. Other silent - /// pushes (e.g. the wake tickle itself, which is an alert) don't route here. + /// Silent pushes (content-available). Two kinds route here: + /// - `clear`: recall the "waiting for your approval" tickle once the approval is resolved on + /// another device — wake briefly to withdraw the stale notification. + /// - `adopt`: a Mac push-started a Live Activity while the app was closed; come online long + /// enough to harvest and register that activity's update token so live updates + a clean end + /// resume without the user opening the app (UX_IOS §5.3). This one holds the completion handler + /// until the token is registered (or the background window closes), so the process isn't + /// suspended mid-connect. + /// Other pushes (e.g. the approval wake tickle, which is an alert) don't route here. func application( _ application: UIApplication, didReceiveRemoteNotification userInfo: [AnyHashable: Any], fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void ) { + if userInfo["adopt"] != nil { + // The store manages its own background-task assertion for this; report .newData once it + // has brought the connection up and registered the token (or given up). + Task { @MainActor in + RemoteStore.shared.handleLiveActivityAdoptWake { completionHandler(.newData) } + } + return + } if userInfo["clear"] != nil { // Fire-and-forget on the main actor (NotificationRouter is @MainActor); the removal is a // quick UNUserNotificationCenter call, so we can report .noData right away.