diff --git a/NucleicRemote/NucleicRemote/LiveActivityManager.swift b/NucleicRemote/NucleicRemote/LiveActivityManager.swift index c9bde83..73dddd9 100644 --- a/NucleicRemote/NucleicRemote/LiveActivityManager.swift +++ b/NucleicRemote/NucleicRemote/LiveActivityManager.swift @@ -49,8 +49,20 @@ final class LiveActivityManager { /// is backgrounded and its sync socket is suspended (UX_IOS §5.3). var onPushToken: ((_ token: String, _ activityID: String) -> Void)? var onActivityEnded: ((_ activityID: String) -> Void)? + /// Set by `RemoteStore` to ship this device's **push-to-start** token to the paired Macs. Unlike + /// `onPushToken` (a per-activity update token that exists only once an Activity does), this token + /// is device-scoped and exists before any Activity — it's what lets a Mac *create* the glance + /// over APNs when work starts while the app is closed, so it appears without the user opening the + /// app first (iOS 17.2+, UX_IOS §5.3). + var onPushToStartToken: ((_ token: String) -> Void)? /// Streams the activity's per-activity APNS update token (it can rotate); cancelled on end. private var tokenObservation: Task? + /// Streams this device's push-to-start token (it can rotate). Device-scoped, so — unlike + /// `tokenObservation` — it lives for the whole process and is never cancelled on `end()`. + private var pushToStartObservation: Task? + /// Observes Activities that appear without us creating them — i.e. ones a Mac push-started while + /// the app was closed — so we adopt them and forward their update token. Also process-lived. + private var activityAdoptionObservation: Task? /// Reconcile the Activity with the current session set. func sync(hostName: String, sessions: [WireSessionSummary]) { @@ -177,6 +189,47 @@ final class LiveActivityManager { enqueue(state) } + /// Start the process-lived observers that make push-to-start work: the device's push-to-start + /// token (forwarded to the Macs so they can create the glance over APNs) and adoption of any + /// Activity a Mac push-started while the app was closed. Idempotent — safe to call on every + /// 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 { + let hex = data.map { String(format: "%02x", $0) }.joined() + self?.onPushToStartToken?(hex) + } + } + } + if activityAdoptionObservation == nil { + activityAdoptionObservation = Task { [weak self] in + for await activity in Activity.activityUpdates { + self?.adopt(activity) + } + } + } + } + + /// Adopt an Activity we didn't create locally — almost always one a Mac push-started while the + /// app was closed. Taking ownership wires up its update-token stream (`observePushToken`) so the + /// Macs can keep the glance fresh the normal way, and collapses any strays to one. If we already + /// track an Activity, leave it: the local `Activity.request` path and `endStrays` already keep a + /// single glance, and re-adopting would just churn the token observation. + private func adopt(_ activity: Activity) { + guard self.activity == nil else { return } + self.activity = activity + observePushToken(activity) + endStrays(keeping: activity.id) + } + /// Forward the activity's APNS update token (and its rotations) to `RemoteStore`. private func observePushToken(_ activity: Activity) { tokenObservation?.cancel() diff --git a/NucleicRemote/NucleicRemote/Models/HostConnection.swift b/NucleicRemote/NucleicRemote/Models/HostConnection.swift index 7d8ce76..6021e0a 100644 --- a/NucleicRemote/NucleicRemote/Models/HostConnection.swift +++ b/NucleicRemote/NucleicRemote/Models/HostConnection.swift @@ -117,6 +117,9 @@ final class HostConnection { private var retryTask: Task? private var revalidateTask: Task? private var lanConnectTimeout: Task? + /// In-flight LAN-reachability probe (the tailnet/relay → LAN return leg). Nil unless a probe is + /// running; at most one at a time so repeated path-change events don't stack. + private var lanUpgradeProbe: Task? private var reconnectAttempts = 0 private var seenSeq: Set = [] @@ -686,6 +689,72 @@ final class HostConnection { if !connectivity.isLive, pathMonitor.isSatisfied { reconnectAttempts = 0 reconnect(to: host) + return + } + // Live over a fallback transport (tailnet/relay) and a LAN path just reappeared (rejoined the + // Mac's Wi-Fi) → the return leg of the switch above. The tunnel survives the interface change + // on its own, so nothing else would ever re-plan back down to the direct path — probe LAN and + // swap to it if the Mac actually answers here. + maybeUpgradeToLAN() + } + + /// Probe whether the pinned Mac is reachable over LAN right now and, if so, switch back to the + /// direct connection — the tailnet/relay → LAN return leg. Only meaningful while live over a + /// non-LAN transport with a LAN-capable path available; a bare TCP connect to the pinned LAN + /// endpoint that reaches `.ready` is the "the Mac is on this network" signal. The probe runs + /// entirely off the live connection, so joining a *foreign* Wi-Fi (no Mac here) costs one short, + /// silent connect attempt and leaves the working tunnel untouched. + private func maybeUpgradeToLAN() { + guard let host = pinnedHost, connectivity.isLive, activeTransport != .lan, + pathMonitor.canUseLAN, lanUpgradeProbe == nil, + let endpoint = discovery.endpoint( + forFingerprint: host.fingerprint, lanHost: host.lanHost, lanPort: host.lanPort) + else { return } + lanUpgradeProbe = Task { [weak self] in + let reachable = await Self.probeReachable(endpoint) + guard let self else { return } + self.lanUpgradeProbe = nil + // Re-check the world didn't move under us during the probe (still live, still not on LAN, + // LAN still available) before tearing a working connection down. + guard !Task.isCancelled, reachable, self.connectivity.isLive, + self.activeTransport != .lan, self.pathMonitor.canUseLAN, + let host = self.pinnedHost + else { return } + self.reconnectAttempts = 0 + self.reconnect(to: host) // rebuilds the candidate chain LAN-first + } + } + + /// Bare TCP reachability check for `maybeUpgradeToLAN`: does `endpoint` accept a connection within + /// `timeoutSeconds`? Tears the probe socket down regardless — it only tests reachability, never + /// carries traffic. A short timeout so a foreign Wi-Fi (Mac absent) doesn't stall the check. + private static func probeReachable(_ endpoint: NWEndpoint, timeoutSeconds: Double = 3) async -> Bool { + let params = NWParameters.tcp + params.includePeerToPeer = true + let connection = NWConnection(to: endpoint, using: params) + let queue = DispatchQueue(label: "nucleic.remote.lanprobe") + let once = ProbeOnce() + return await withTaskCancellationHandler { + await withCheckedContinuation { (cont: CheckedContinuation) in + connection.stateUpdateHandler = { state in + switch state { + case .ready: + if once.take() { cont.resume(returning: true) } + connection.cancel() + case .failed, .cancelled: + if once.take() { cont.resume(returning: false) } + default: + break + } + } + queue.asyncAfter(deadline: .now() + timeoutSeconds) { + if once.take() { cont.resume(returning: false) } + connection.cancel() + } + connection.start(queue: queue) + } + } onCancel: { + connection.cancel() } } @@ -704,6 +773,10 @@ final class HostConnection { reconnect(to: host) return } + // Already live — but if we're on a fallback transport and a LAN path exists now, try to move + // back to the direct connection (foregrounding on the Mac's Wi-Fi after being away on cellular, + // where no path-change event fired while suspended). + maybeUpgradeToLAN() revalidateTask?.cancel() revalidateTask = Task { [weak self] in // A generous bound on a pong round-trip (LAN <10ms, relay <200ms) — only paid in full @@ -735,6 +808,7 @@ final class HostConnection { retryTask?.cancel(); retryTask = nil revalidateTask?.cancel(); revalidateTask = nil connectTask?.cancel(); connectTask = nil + lanUpgradeProbe?.cancel(); lanUpgradeProbe = nil connectPlan = nil teardownClient() } @@ -746,3 +820,17 @@ final class HostConnection { client = nil } } + +/// A thread-safe "fire once" latch: the `NWConnection` reachability probe resolves its continuation +/// from a state callback *or* a timeout, on the same queue but from different closures — `take()` +/// returns true to exactly one caller so the continuation is resumed exactly once. +private final class ProbeOnce: @unchecked Sendable { + private let lock = NSLock() + private var done = false + func take() -> Bool { + lock.lock(); defer { lock.unlock() } + if done { return false } + done = true + return true + } +} diff --git a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift index 297536e..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" @@ -558,13 +620,20 @@ final class RemoteStore: ObservableObject { var cb = HostConnection.Callbacks() cb.didUpdate = { [weak self] in guard let self else { return } + // Complete a session opened before its owning Mac was live — a Live Activity / + // notification cold-launch deep link opens the detail while the app is still dialing, so + // `open()` couldn't bind it to a connection that didn't exist yet. Runs before the merge + // so the aggregate picks up the freshly-bound host's connectivity/scope this pass. + self.bindOpenSessionIfNeeded() // Any host's change re-merges the aggregate the flat state binds to (mesh P3). self.rebuildAggregate() self.refreshAggregate() self.flushPendingNotificationDecision() // A host that just connected (or reconnected) needs the current Live Activity token - // so it can push while the phone is away. + // so it can push while the phone is away — and the push-to-start token so it can create + // the glance cold when work starts before the app is opened. self.syncLiveActivityRegistration() + self.syncPushToStartRegistration() } cb.openSnapshot = { [weak self] snap in guard let self, hostID == self.openSessionHostID, snap.summary.sessionID == self.openSessionID else { return } @@ -922,6 +991,26 @@ final class RemoteStore: ObservableObject { connection(owningSession: sessionID)?.fetchFullTranscript(sessionID) } + /// Bind the open session to its owning Mac once that Mac is live — the deferred half of `open()` + /// for a session opened before any connection existed (a Live Activity / notification cold-launch + /// deep link). Because `open()` ran while the app was still dialing, `connection(owningSession:)` + /// found nothing: the host binding stayed nil, no `subscribe` went out, and the transcript + /// callbacks — all gated on `openSessionHostID` — dropped everything the host later sent, leaving + /// the detail stuck on "Disconnected" with an empty transcript and a dead composer. Once a host + /// connects and lists its sessions, `didUpdate` calls this: it finds the owner and finishes the + /// subscription so the transcript loads and connectivity/scope follow the bound host. Idempotent + /// and cheap — a no-op on the common path where `open()` already bound a live connection. + private func bindOpenSessionIfNeeded() { + guard !demoMode, let sessionID = openSessionID, + let conn = connection(owningSession: sessionID), conn.connectivity.isLive else { return } + // Already bound to this live host with its subscription in place — nothing to redo. + guard openSessionHostID != conn.hostID || conn.openSessionID != sessionID else { return } + openSessionHostID = conn.hostID + conn.openSessionID = sessionID + conn.send(.subscribe(Subscribe(sessionID: sessionID, sinceSeq: nil, verbosity: .full))) + conn.fetchFullTranscript(sessionID) + } + /// Ask the host for the open session's full patch (Diff tab). No-op when the host /// doesn't advertise the capability — the view falls back to the diffstat summary. func fetchDiff(_ sessionID: SessionID) { @@ -1184,7 +1273,7 @@ final class RemoteStore: ObservableObject { // `requestPairingCode`/`cancelPairingCode` are sent straight to the chosen host by // `requestPairingCode()`/`cancelPairingCode()`, not through this owner-routing switch. case .hello, .ping, .listPeers, .addressUpdate, .meshRoster, - .registerLiveActivity, .endLiveActivity, + .registerLiveActivity, .endLiveActivity, .registerPushToStartToken, .transferOffer, .transferChunk, .transferCommit, .transferCancel, .fetchTranscript, .requestPairingCode, .cancelPairingCode, .respondMacPair: break @@ -1201,14 +1290,35 @@ final class RemoteStore: ObservableObject { /// Host ids that already have the current token (re-sent to a host that (re)connects, and /// re-sent to everyone when the token rotates). private var liveActivitySentTo: Set = [] + /// This device's push-to-start token (iOS 17.2+), shipped to every capable Mac so it can create + /// the Live Activity over APNs when work starts before the app is opened. Device-scoped, so — + /// unlike `liveActivityReg` — it persists across activities and isn't cleared when one ends. + private var pushToStartToken: String? + /// 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) self.liveActivitySentTo.removeAll() // a fresh token must reach every host again self.syncLiveActivityRegistration() } + LiveActivityManager.shared.onPushToStartToken = { [weak self] token in + guard let self, self.pushToStartToken != token else { return } + self.pushToStartToken = token + self.pushToStartSentTo.removeAll() // a fresh token must reach every host again + self.syncPushToStartRegistration() + } + // Start observing the push-to-start token (and adopting any push-started activity) now — it's + // device-scoped and must be captured even before any Activity exists. + LiveActivityManager.shared.beginPushToStartObservation() LiveActivityManager.shared.onActivityEnded = { [weak self] activityID in guard let self else { return } self.liveActivityReg = nil @@ -1234,6 +1344,23 @@ final class RemoteStore: ObservableObject { } } + /// Send the current push-to-start token to every live, capable host that hasn't got it yet — the + /// mirror of `syncLiveActivityRegistration` for the device-scoped token that lets a host create + /// the glance over APNs when work starts before the app is opened (iOS 17.2+). + private func syncPushToStartRegistration() { + guard let token = pushToStartToken else { return } + // A host that dropped should re-register when it returns. + pushToStartSentTo = pushToStartSentTo.filter { connections[$0]?.connectivity.isLive == true } + for (id, conn) in connections { + // Gate on the dedicated push-to-start bit, not `canPushLiveActivity`: an older host can + // advertise the latter yet throw on the unknown `registerPushToStartToken` tag. + guard conn.connectivity.isLive, conn.capabilities.canPushToStartLiveActivity, + !pushToStartSentTo.contains(id) else { continue } + conn.send(.registerPushToStartToken(token)) + pushToStartSentTo.insert(id) + } + } + // MARK: - Demo simulator (offline, interactive) // // In demo mode there's no host, so writes can't go over the wire. Instead they mutate the @@ -1291,7 +1418,7 @@ final class RemoteStore: ObservableObject { .addressUpdate, .meshRoster, // Live Activity push registration is a real-connection concern (there's no host to // push in demo), so it's inert here. - .registerLiveActivity, .endLiveActivity, + .registerLiveActivity, .endLiveActivity, .registerPushToStartToken, // Session transfer (mesh P5) is a Mac↔Mac flow — the phone never originates these, // and demo has no peer Macs, so they're inert here. .transferOffer, .transferChunk, .transferCommit, .transferCancel, 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.