diff --git a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift index 3f2419f..21044f5 100644 --- a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift +++ b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift @@ -595,6 +595,53 @@ final class RemoteStore: ObservableObject { } } + /// Handle the host's silent "reconnect" nudge (SYNC_PROTOCOL §3.2): the Mac noticed our LAN link + /// drop and is asking us to re-dial over a non-local transport before the connection silently + /// goes dark. 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. Re-running + /// `reconnect()` walks each host's LAN→tailnet→relay candidate loop — LAN just failed, so it + /// lands on tailnet or relay from wherever the phone now is. Holds a background-task assertion + /// until a host is live again or the window closes. Best-effort: iOS may deny background runtime + /// (budget, force-quit), in which case the reconnect simply happens on the next foreground. + func handleReconnectWake(completion: @escaping () -> Void) { + guard !demoMode, isPaired else { completion(); return } + // A silent wake means we're background (possibly cold-launched with no scene, where + // `isForeground`'s default would read stale-true); record that so the reconnect reports + // background and the host keeps owning the Live Activity via push-to-start (UX_IOS §5.3). + setForegroundState(false) + 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.sync.reconnect") { + finish() // the OS reclaimed the grant before we finished — report what we have + } + #else + let finish = completion + #endif + pathMonitor.refresh() + reconnect() + // Poll until at least one host is live again (the dial loop found a working path), or we run + // out of background 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.connections.values.contains(where: { $0.connectivity.isLive }) + else { break } + try? await Task.sleep(for: .milliseconds(500)) + } + finish() + } + } + private func seedDemo() { connectivity = .connected(.lan) hostName = "Andrew's Mac" diff --git a/NucleicRemote/NucleicRemote/PushRegistrar.swift b/NucleicRemote/NucleicRemote/PushRegistrar.swift index 1e9e16a..7381eb8 100644 --- a/NucleicRemote/NucleicRemote/PushRegistrar.swift +++ b/NucleicRemote/NucleicRemote/PushRegistrar.swift @@ -46,7 +46,7 @@ final class PushAppDelegate: NSObject, UIApplicationDelegate { // Best-effort: no token → the phone still works on LAN, just no remote wake. } - /// Silent pushes (content-available). Two kinds route here: + /// Silent pushes (content-available). Three 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 @@ -54,6 +54,9 @@ final class PushAppDelegate: NSObject, UIApplicationDelegate { /// 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. + /// - `reconnect`: the Mac noticed our LAN link drop and is nudging us to re-dial over a non-local + /// transport (tailnet/relay). Come online long enough to re-run the candidate loop so the + /// connection follows the phone off-LAN without the user opening the app (SYNC_PROTOCOL §3.2). /// Other pushes (e.g. the approval wake tickle, which is an alert) don't route here. func application( _ application: UIApplication, @@ -68,6 +71,14 @@ final class PushAppDelegate: NSObject, UIApplicationDelegate { } return } + if userInfo["reconnect"] != nil { + // The store holds its own background-task assertion while it re-dials; report .newData + // once a link is back up (or the background window closed). + Task { @MainActor in + RemoteStore.shared.handleReconnectWake { 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.