From 7e46002b20dbfe6af261fddcd31eda636c9c62e6 Mon Sep 17 00:00:00 2001 From: Nucleic Date: Sat, 18 Jul 2026 22:07:15 -0700 Subject: [PATCH] Merge nucleic/lucid-glass-bison-viwq into dev --- .../NucleicRemote/Models/HostConnection.swift | 16 ++++++++++++---- .../NucleicRemote/Net/NetworkPathMonitor.swift | 18 +++++++++++++++++- 2 files changed, 29 insertions(+), 5 deletions(-) diff --git a/NucleicRemote/NucleicRemote/Models/HostConnection.swift b/NucleicRemote/NucleicRemote/Models/HostConnection.swift index 9048257..581d2e9 100644 --- a/NucleicRemote/NucleicRemote/Models/HostConnection.swift +++ b/NucleicRemote/NucleicRemote/Models/HostConnection.swift @@ -1097,10 +1097,18 @@ final class HostConnection { 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. + // Live over a fallback transport (tailnet/relay) and the path just changed. On cellular this + // is a tower roam that may have rebound our IP and silently killed the WebSocket — the tunnel + // does NOT reliably survive an IP rebind, contrary to the interface-swap case. Don't trust the + // stale `.connected`: fast-probe it and re-dial the instant it fails to answer (sub-second), + // rather than waiting out the 25s relay ping / 55s keepalive / URLSession's own detection. + // `revalidate` also runs `maybeUpgradeToLAN`, so the LAN return leg (rejoined the Mac's Wi-Fi) + // is still covered here. + if connectivity.isLive, activeTransport != .lan { + revalidate() + return + } + // Live over LAN (or a path change with nothing to re-probe) → just try the LAN return leg. maybeUpgradeToLAN() } diff --git a/NucleicRemote/NucleicRemote/Net/NetworkPathMonitor.swift b/NucleicRemote/NucleicRemote/Net/NetworkPathMonitor.swift index fef5172..84e7ffb 100644 --- a/NucleicRemote/NucleicRemote/Net/NetworkPathMonitor.swift +++ b/NucleicRemote/NucleicRemote/Net/NetworkPathMonitor.swift @@ -26,6 +26,13 @@ final class NetworkPathMonitor: ObservableObject { private let queue = DispatchQueue(label: "nucleic.remote.path") private var started = false + /// A coarse fingerprint of the last path, so a *same-class* transition (a cellular tower roam + /// that reprovisions the pdp interface, a Wi‑Fi interface swap) fires `onChange` too — not just + /// the LAN/satisfied flips. Without this, a roam that keeps the path `satisfied` on `.cellular` + /// leaves both booleans unchanged and the proactive re‑plan never fires, so a silently‑rebound + /// socket is only caught by the slow reactive keepalive. + private var lastSignature = "" + func start() { guard !started else { return } started = true @@ -43,9 +50,18 @@ final class NetworkPathMonitor: ObservableObject { let lan = path.status == .satisfied && (path.usesInterfaceType(.wifi) || path.usesInterfaceType(.wiredEthernet)) let satisfied = path.status == .satisfied - let changed = lan != canUseLAN || satisfied != isSatisfied + // Also move on a same‑class transition (cellular tower roam, Wi‑Fi interface swap) so a live + // relay/tailnet tunnel gets re‑validated instead of waiting out socket‑death detection. The + // interface identity + expensiveness/constrained flags are what `NWPath` surfaces on a + // handoff; the IP itself isn't exposed, so this is best‑effort — the follow‑on probe is the + // gate, and it's cheap on a still‑healthy link. + let signature = "\(path.status)|" + + path.availableInterfaces.map { "\($0.name)#\($0.index)" }.joined(separator: ",") + + "|\(path.isExpensive)|\(path.isConstrained)" + let changed = lan != canUseLAN || satisfied != isSatisfied || signature != lastSignature canUseLAN = lan isSatisfied = satisfied + lastSignature = signature if changed { onChange?() } } }