diff --git a/NucleicRemote/NucleicRemote/Models/HostConnection.swift b/NucleicRemote/NucleicRemote/Models/HostConnection.swift index 7c75f70..cba7db1 100644 --- a/NucleicRemote/NucleicRemote/Models/HostConnection.swift +++ b/NucleicRemote/NucleicRemote/Models/HostConnection.swift @@ -9,7 +9,7 @@ import UIKit /// One phone→Mac connection and its projected state (mesh P3 multiplexer foundation). /// /// This is the per-host connection engine extracted from `RemoteStore`: it owns the `SyncClient`, -/// the LAN→tailnet candidate chain, reconnect backoff, and the event stream for a single paired +/// the Relay-first candidate chain, reconnect backoff, and the event stream for a single paired /// Mac, and it keeps that Mac's projection (connectivity, sessions, dashboard, capabilities, …). /// `RemoteStore` will own one of these per paired host (`[HostID: HostConnection]`), aggregating /// their state and routing intents to the right one; aggregate concerns (badge, Live Activity, @@ -151,6 +151,10 @@ final class HostConnection { /// In-flight LAN-reachability probe (the tailnet/relay → LAN return leg). Nil unless a probe is /// running; at most one at a time so repeated path-change events don't stack. private var lanUpgradeProbe: Task? + /// A successful reachability probe causes one LAN-first authenticated reconnect. If that full + /// dial falls back to Relay, consume this flag there and proceed to STUN instead of probing the + /// same accepting-but-unusable endpoint forever. + private var skipNextLANOptimization = false private var reconnectAttempts = 0 private var seenSeq: Set = [] @@ -176,8 +180,7 @@ final class HostConnection { private enum TransportAttempt { case lan(NWEndpoint) case tailnet(host: String, port: UInt16) - /// Covalence Relay (mesh P2) — always the last candidate: works from anywhere, - /// but a direct path beats a brokered one when both exist. + /// Covalence Relay (mesh P2) — the immediate baseline whenever admission is available. case relay(base: URL, membershipToken: String) } @@ -234,10 +237,12 @@ final class HostConnection { _ = tryNextCandidate() } - /// Reconnect to the pinned host using IK: LAN when reachable, else the pairing's tailnet hint. - /// `preferRelay` reorders the candidates to try the Nucleic Edge first (a backgrounded/locked - /// App Intent resolving an approval — see `buildCandidates`); the direct paths stay as fallbacks. - func reconnect(to host: PairedHost, preferRelay: Bool = false) { + /// Reconnect to the pinned host using IK. Relay admission always establishes the baseline first; + /// LAN and STUN optimization happen only after that session is usable. `preferLAN` is reserved + /// for a LAN endpoint just proven while Relay remained live. + func reconnect( + to host: PairedHost, preferRelay: Bool = false, preferLAN: Bool = false + ) { teardown() pinnedHost = host connectivity = reconnectAttempts == 0 ? .connecting : .reconnecting @@ -248,7 +253,7 @@ final class HostConnection { lanHost: host.lanHost, lanPort: host.lanPort, tailnet: host.transportHint == .tailnet ? (host.tailnetHost, host.tailnetPort) : nil, relay: (host.relayMembershipToken, host.relayURL), - preferRelay: preferRelay) + preferRelay: preferRelay, preferLAN: preferLAN) guard !candidates.isEmpty else { connectivity = .hostOffline callbacks.didUpdate() @@ -319,32 +324,33 @@ final class HostConnection { fingerprint: String?, lanHost: String?, lanPort: UInt16?, tailnet: (host: String?, port: UInt16?)?, relay: (membershipToken: String?, url: String?)? = nil, - preferRelay: Bool = false + preferRelay: Bool = false, preferLAN: Bool = false ) -> [TransportAttempt] { let relayCandidate: TransportAttempt? = { guard let relay, let token = relay.membershipToken, !token.isEmpty else { return nil } return .relay(base: RelayAPI.baseURL(relay.url), membershipToken: token) }() - var candidates: [TransportAttempt] = [] - // Relay-first: a backgrounded/locked App Intent (a Live Activity approve/deny) can't rely on - // the local network — iOS restricts LAN/Bonjour for a process launched into the background, - // so the LAN attempt just burns its connect timeout. Dial the Nucleic Edge first and keep the - // direct paths as fallbacks. Only when the account opted into cloud approvals (SyncedSettings). - if preferRelay, let relayCandidate { candidates.append(relayCandidate) } + var directCandidates: [TransportAttempt] = [] // Only offer LAN when the device actually has a LAN-capable path (Wi-Fi/wired). On cellular // the pinned `lanHost:lanPort` is unreachable, so including it here would just burn the // connect timeout before falling through — skip it and dial tailnet/relay immediately. if pathMonitor.canUseLAN, let endpoint = discovery.endpoint(forFingerprint: fingerprint, lanHost: lanHost, lanPort: lanPort) { - candidates.append(.lan(endpoint)) + directCandidates.append(.lan(endpoint)) } if let tailnet, let host = tailnet.host, let port = tailnet.port, TailnetSupport.isBuiltIn { - candidates.append(.tailnet(host: host, port: port)) + directCandidates.append(.tailnet(host: host, port: port)) } - // Default order: relay is the last resort — a direct path beats a brokered one when both - // exist. `preferRelay` already placed it first, so don't add it twice. - if !preferRelay, let relayCandidate { candidates.append(relayCandidate) } - return candidates + // `preferRelay` is retained for existing App-Intent callers, but Relay is now first for all + // callers whenever it is configured. A proven LAN promotion gets one LAN-first attempt with + // Relay immediately behind it; all other direct candidates remain failure fallbacks. + _ = preferRelay + if preferLAN, let first = directCandidates.first, + case .lan = first, let relayCandidate { + return [first, relayCandidate] + Array(directCandidates.dropFirst()) + } + if let relayCandidate { return [relayCandidate] + directCandidates } + return directCandidates } private func tryNextCandidate() -> Bool { @@ -580,7 +586,7 @@ final class HostConnection { // that predates the field omits it, and we keep whatever we already had. if let settings = welcome.settings { callbacks.settingsChanged(settings) } if let payload = pairingPayload, let hostKey = await client?.hostKey() { - callbacks.didPair(PairedHost( + let paired = PairedHost( deviceID: IdentityStore.deviceID(), hostName: welcome.host.hostName, hostStaticKey: hostKey, fingerprint: hostKey.fingerprintHex, lanHost: payload.lanHost, lanPort: payload.lanPort, @@ -588,10 +594,16 @@ final class HostConnection { tailnetPort: payload.tailnetPort, relayRoomID: payload.relayRoomID, relayMembershipToken: payload.relayMembershipToken, - relayURL: payload.relayURL)) + relayURL: payload.relayURL) + pinnedHost = paired + callbacks.didPair(paired) } callbacks.didUpdate() - await startDirectUpgradeIfPossible(welcome: welcome) + if activeTransport == .relay, let client { + optimizeRelayConnection(welcome: welcome, client: client) + } else { + skipNextLANOptimization = false + } send(.listSessions) send(.listDashboard) // Mesh casting: subscribe with the merged ledger's cursors (every origin we hold) — @@ -859,6 +871,41 @@ final class HostConnection { } } + /// Keep the authenticated Relay session available while checking the cheapest path. A proven + /// LAN endpoint gets one LAN-first reconnect; otherwise STUN begins. Failure at either stage + /// leaves or returns the connection on Relay. + private func optimizeRelayConnection(welcome: Welcome, client: SyncClient) { + guard self.client === client else { return } + if skipNextLANOptimization { + skipNextLANOptimization = false + Task { [weak self] in await self?.startDirectUpgradeIfPossible(welcome: welcome) } + return + } + guard let host = pinnedHost, pathMonitor.canUseLAN, + let endpoint = discovery.endpoint( + forFingerprint: host.fingerprint, lanHost: host.lanHost, lanPort: host.lanPort) + else { + Task { [weak self] in await self?.startDirectUpgradeIfPossible(welcome: welcome) } + return + } + lanUpgradeProbe?.cancel() + lanUpgradeProbe = Task { [weak self, weak client] in + let reachable = await Self.probeReachable(endpoint) + guard let self else { return } + self.lanUpgradeProbe = nil + guard !Task.isCancelled, let client, self.client === client, + self.connectivity.isLive, self.activeTransport == .relay + else { return } + if reachable, let host = self.pinnedHost { + self.skipNextLANOptimization = true + self.reconnectAttempts = 0 + self.reconnect(to: host, preferLAN: true) + } else { + await self.startDirectUpgradeIfPossible(welcome: welcome) + } + } + } + /// The live data path migrated (direct upgrade / failback). Reflect it in the status chip /// without disturbing the relay-backed session underneath. private func directPathChanged(_ hint: SyncTransportHint) { @@ -979,7 +1026,8 @@ final class HostConnection { let host = self.pinnedHost else { return } self.reconnectAttempts = 0 - self.reconnect(to: host) // rebuilds the candidate chain LAN-first + self.skipNextLANOptimization = true + self.reconnect(to: host, preferLAN: true) } } diff --git a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift index 167480d..dd38a0f 100644 --- a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift +++ b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift @@ -957,8 +957,8 @@ final class RemoteStore: ObservableObject { /// 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 + /// `reconnect()` establishes Relay first when available, so it avoids retrying the LAN link that + /// just failed and becomes usable 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) { @@ -1643,13 +1643,12 @@ final class RemoteStore: ObservableObject { } /// Connect to every paired Mac at once (mesh P3 multiplexer): each `HostConnection` runs its own - /// IK reconnect (LAN→tailnet, with backoff), so all your Macs are live simultaneously and the + /// IK reconnect (Relay-first when configured, with backoff), so all your Macs are live simultaneously and the /// switcher flips between them instantly. Idempotent — an already-live connection is left alone; /// an offline one (re)dials. Connections for since-unpaired Macs are dropped. The launch + /// "Reconnect" path. - /// `preferRelay` reorders each dialing host's candidates to try the Nucleic Edge first — set - /// only from a backgrounded/locked App Intent (a Live Activity approve/deny) where the local - /// network is unreliable. The normal launch/foreground path leaves it false (direct-first). + /// `preferRelay` remains as a source-compatible hint for App Intent callers; Relay-enabled + /// hosts now use that fast baseline for foreground and background reconnects alike. func reconnect(preferRelay: Bool = false) { let hosts = IdentityStore.pairedHosts() guard !hosts.isEmpty else { connectivity = .unpaired; return }