From 63a1c9dd9eef13291b30d8de0bbc5af03a5c7deb Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Sat, 4 Jul 2026 23:38:55 -0700 Subject: [PATCH] Network Type Transition Optimization Nucleic-Session: 40F8C53A-F001-423A-8607-EAE181DBE14B Co-authored-by: Nucleic --- .../Net/NetworkPathMonitor.swift | 51 +++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 NucleicRemote/NucleicRemote/Net/NetworkPathMonitor.swift diff --git a/NucleicRemote/NucleicRemote/Net/NetworkPathMonitor.swift b/NucleicRemote/NucleicRemote/Net/NetworkPathMonitor.swift new file mode 100644 index 0000000..fef5172 --- /dev/null +++ b/NucleicRemote/NucleicRemote/Net/NetworkPathMonitor.swift @@ -0,0 +1,51 @@ +import Foundation +import Network + +/// Watches the device's network path (Wi‑Fi ⇄ cellular ⇄ offline) so the app can switch transports +/// the instant the link changes, instead of waiting for a dead LAN socket to time out. This is the +/// missing proactive signal behind the "iOS sticks on LAN after leaving Wi‑Fi" stall: the phone's +/// pinned `lanHost:lanPort` is only reachable over Wi‑Fi/wired, so on cellular the LAN candidate is a +/// guaranteed stall. `HostConnection` skips LAN when `canUseLAN` is false and re‑plans on `onChange`. +@MainActor +final class NetworkPathMonitor: ObservableObject { + /// True when a LAN‑capable path (Wi‑Fi or wired Ethernet) is available — the only paths on which + /// the pinned `lanHost:lanPort` could resolve. Cellular‑only ⇒ false, so the LAN candidate is + /// dropped and we dial tailnet/relay straight away. Defaults optimistic so a not‑yet‑started + /// monitor never spuriously suppresses LAN. + @Published private(set) var canUseLAN = true + /// True when any network path is available at all (used to decide whether an immediate reconnect + /// is even worth attempting). + @Published private(set) var isSatisfied = true + + /// Fired on the main actor whenever a *relevant* aspect of the path changed — LAN availability or + /// overall reachability. The cue for connections to re‑plan their transport immediately rather + /// than waiting out a reconnect backoff. + var onChange: (() -> Void)? + + private let monitor = NWPathMonitor() + private let queue = DispatchQueue(label: "nucleic.remote.path") + private var started = false + + func start() { + guard !started else { return } + started = true + monitor.pathUpdateHandler = { [weak self] path in + Task { @MainActor in self?.apply(path) } + } + monitor.start(queue: queue) + } + + /// Re‑read the current path synchronously — used on foreground, where an update that landed while + /// the app was suspended may not have been redelivered yet. + func refresh() { apply(monitor.currentPath) } + + private func apply(_ path: NWPath) { + let lan = path.status == .satisfied + && (path.usesInterfaceType(.wifi) || path.usesInterfaceType(.wiredEthernet)) + let satisfied = path.status == .satisfied + let changed = lan != canUseLAN || satisfied != isSatisfied + canUseLAN = lan + isSatisfied = satisfied + if changed { onChange?() } + } +}