Merge nucleic/lucid-slate-bison into dev

This commit is contained in:
2026-07-10 16:24:50 -07:00
parent aebd4511b7
commit c9626e1862
2 changed files with 59 additions and 1 deletions
@@ -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"
@@ -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.