diff --git a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift index 50b4c47..6decb92 100644 --- a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift +++ b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift @@ -320,12 +320,48 @@ final class RemoteStore: ObservableObject { } /// Returning to the foreground: the network may have changed while the app was suspended (and - /// path/keepalive callbacks were frozen). Re-read the path and re-dial anything not live so the - /// transport is correct by the time the UI is on screen, rather than after a backoff. + /// path/keepalive callbacks were frozen). Re-read the path, re-dial anything not live, then + /// actively revalidate the connections that still *look* live — a socket the OS killed while we + /// were suspended wakes marked `.connected`, and trusting that would stall the reconnect until + /// the 55s keepalive deadline. `revalidate` pings each such link and re-dials the instant it + /// fails to answer, so the transport is correct (and fast) by the time the UI is on screen. func onForeground() { + endBackgroundHold() guard !demoMode, isPaired else { return } pathMonitor.refresh() + // Ensure a connection exists for every paired host (and drop unpaired); this re-dials the + // ones already known to be offline. reconnect() + // The zombies from suspension still read `.connected` — probe and re-dial the dead ones. + for conn in connections.values where conn.connectivity.isLive { conn.revalidate() } + } + + #if canImport(UIKit) + /// Held while backgrounded so iOS keeps the process (and thus the live sockets + 20s keepalive) + /// running for its short grant, instead of suspending us the moment we background. + private var backgroundTask: UIBackgroundTaskIdentifier = .invalid + #endif + + /// Entering the background: ask iOS to keep us running briefly so a quick app-switch (glance at + /// another app, tap a Live Activity, answer a banner) doesn't tear the connection down and force + /// a cold reconnect on return. The grant is short (~30s) and best-effort; once it lapses the OS + /// suspends us and the socket dies, which the next `onForeground` revalidation reconnects fast. + func onBackground() { + guard !demoMode else { return } + #if canImport(UIKit) + endBackgroundHold() + backgroundTask = UIApplication.shared.beginBackgroundTask(withName: "nucleic.sync.hold") { + [weak self] in self?.endBackgroundHold() + } + #endif + } + + private func endBackgroundHold() { + #if canImport(UIKit) + guard backgroundTask != .invalid else { return } + UIApplication.shared.endBackgroundTask(backgroundTask) + backgroundTask = .invalid + #endif } private func seedDemo() {