From 540101e7ac208e86dc06128142aa9325d2e76074 Mon Sep 17 00:00:00 2001 From: Nucleic Date: Sun, 19 Jul 2026 17:38:54 -0700 Subject: [PATCH] Merge nucleic/clever-harbor-quail-2rko into dev --- .../NucleicRemote/Models/HostConnection.swift | 38 +++- .../NucleicRemote/Models/RemoteStore.swift | 212 +++++++++++++++++- .../Views/AdaptiveRootView.swift | 2 +- .../NucleicRemote/Views/MeshInfoView.swift | 86 ++++--- .../NucleicRemote/Views/SessionsView.swift | 2 +- .../NucleicRemote/Views/SettingsView.swift | 98 ++++---- 6 files changed, 336 insertions(+), 102 deletions(-) diff --git a/NucleicRemote/NucleicRemote/Models/HostConnection.swift b/NucleicRemote/NucleicRemote/Models/HostConnection.swift index 581d2e9..ff4a60a 100644 --- a/NucleicRemote/NucleicRemote/Models/HostConnection.swift +++ b/NucleicRemote/NucleicRemote/Models/HostConnection.swift @@ -256,9 +256,11 @@ final class HostConnection { _ = tryNextCandidate() } - /// 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. + /// Reconnect to the pinned host using IK. Candidates are dialed direct-first (LAN, then tailnet) + /// with Relay as the immediate fallback (see `buildCandidates`). `preferRelay` forces the Relay + /// baseline first for App-Intent / quick-approve callers; a session that lands on Relay still + /// upgrades to LAN/STUN in the background once proven. `preferLAN` leads with a LAN endpoint just + /// proven while a relay session stayed live. func reconnect( to host: PairedHost, preferRelay: Bool = false, preferLAN: Bool = false ) { @@ -360,16 +362,34 @@ final class HostConnection { if let tailnet, let host = tailnet.host, let port = tailnet.port, TailnetSupport.isBuiltIn { directCandidates.append(.tailnet(host: host, port: port)) } - // `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 + // Ordering (SYNC_PROTOCOL §3.2, revised for LAN latency). Dial the DIRECT candidates + // (LAN, then tailnet) FIRST and keep Relay as the immediate fallback, so a co-located Mac + // connects in well under a second instead of paying a Cloudflare round-trip (token mint + + // WebSocket + a Noise handshake *through* the relay) before LAN is even attempted. Placing + // Relay first — the previous behavior — is what made LAN connects slow: dialing is serial + // (`tryNextCandidate`), so a relay attempt that stalls (up to the 10s handshake watchdog when + // the host isn't yet in the room, or ~25s of token-mint + WS timeouts if the relay is slow) + // blocked LAN entirely even though the Mac was one hop away. An unreachable pinned LAN + // address does NOT re-introduce a stall here: `NWFrameChannel` fast-fails a `.waiting` socket + // (see NWFrameChannel), so the chain drops straight to Relay and the off-network case stays + // quick too. + // + // Two cases still lead with Relay: + // • `preferRelay` — App-Intent / Live-Activity quick-approve, where a dependable relay + // baseline beats shaving the LAN round-trip (no user is watching a spinner). + // • no direct candidate at all (cellular) — Relay is the only option regardless. + // `preferLAN` (a LAN endpoint just proven while a relay session stayed live) still leads + // with LAN, Relay immediately behind it. A session that lands on Relay upgrades to LAN/STUN + // in the background via `optimizeRelayConnection` once proven. + if preferRelay, let relayCandidate { + return [relayCandidate] + directCandidates + } 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 + guard let relayCandidate else { return directCandidates } + return directCandidates.isEmpty ? [relayCandidate] : directCandidates + [relayCandidate] } private func tryNextCandidate() -> Bool { diff --git a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift index bbec275..e849115 100644 --- a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift +++ b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift @@ -30,7 +30,7 @@ final class RemoteStore: ObservableObject { case .connecting: "Connecting…" case .reconnecting: "Reconnecting…" case .connected(let transport): "Connected · \(transport.label)" - case .hostOffline: "Mac offline" + case .hostOffline: "No devices reachable" case .failed(let m): m } } @@ -111,6 +111,202 @@ final class RemoteStore: ObservableObject { } } + // MARK: Mesh device roster (per-device connection indicators) + + /// How this phone reaches one device on the mesh — the per-device indicator's trailing state. + enum MeshReachability: Equatable { + /// A live direct link *from this phone*; the transport is the connection method. + case connected(SyncTransportHint) + /// Not reached directly, but a device we ARE connected to reports it online — so we reach it + /// by way of that device (its name). + case viaPeer(String) + /// A direct link is being (re)established right now. + case connecting + /// Known to the mesh but not reachable from here right now. + case offline + + /// Counts as "on the mesh right now" — drives the green dot and the online tally. + var isOnline: Bool { + switch self { + case .connected, .viaPeer: true + case .connecting, .offline: false + } + } + /// The trailing label: the method for a direct link ("LAN" / "Relay" / "Direct (STUN)"), the + /// bridging device for an indirect one ("via MacBook Pro"), or the pending / offline state. + var detail: String { + switch self { + case .connected(.direct): "Direct (STUN)" + case .connected(let t): t.label + case .viaPeer(let name): "via \(name)" + case .connecting: "Connecting…" + case .offline: "Offline" + } + } + } + + /// One device on the mesh as this phone sees it — a Mac or cloud runner it dials directly, a + /// device it only learns of through another connected host, or this device itself. Powers the + /// per-device connection indicators in Settings ▸ Connection and Mesh Info. + struct MeshDevice: Identifiable, Equatable { + let id: String // paired host: fingerprint; peer-only: full deviceID; self: "self" + let name: String + let kind: PeerKind + let reachability: MeshReachability + /// This iPhone/iPad itself — rendered as "This device", never removable. + var isSelf = false + /// A directly-paired host this phone dials — offers per-row unpair. + var isPairedHost = false + } + + /// Whether two device identifiers name the same device. `PairedHost.fingerprint` is the first + /// 16 hex of a host's full `deviceID` (sha-256), while peer / runner-card records carry the full + /// id — so a match is "one is a prefix of the other" (≥16 chars, the fingerprint length). + private static func sameDevice(_ a: String, _ b: String) -> Bool { + if a == b { return true } + guard min(a.count, b.count) >= 16 else { return false } + return a.hasPrefix(b) || b.hasPrefix(a) + } + + /// The transports this phone currently holds a live link over (deduped, across all hosts) — for + /// the mesh status detail line. + private var liveTransports: [SyncTransportHint] { + var seen = Set() + var result: [SyncTransportHint] = [] + for conn in connections.values { + if let t = conn.connectivity.transport, seen.insert(t).inserted { result.append(t) } + } + return result + } + + /// Whether this phone can reach the mesh at all — live if ANY host (a Mac OR a cloud runner) is + /// connected, so a phone that only talks to cloud runners over the relay still counts. Demo is + /// always "connected". This is the mesh-wide truth behind the redesigned Connection headline. + var isMeshConnected: Bool { demoMode || hasLiveConnection } + + /// Every device on the mesh with this phone's reachability to it — self first, then online + /// devices (directly connected before reached-via-another), then connecting, then offline; + /// alphabetical within each group. Built entirely from state the phone already holds (the paired + /// registry + each live host's peer list), so it needs no wire change. + var meshDevices: [MeshDevice] { + let selfDevice = MeshDevice( + id: "self", name: deviceName, kind: .iphone, + reachability: isMeshConnected ? .connected(connectivity.transport ?? .lan) : .offline, + isSelf: true) + + if demoMode { + let mac = MeshDevice( + id: "demo-mac", name: hostName.isEmpty ? "Demo Mac" : hostName, kind: .mac, + reachability: .connected(.lan), isPairedHost: true) + return [selfDevice, mac] + } + + // Kind lookup by full deviceID, gathered from runner-presence cards + every host's roster. + var kindByID: [String: PeerKind] = [:] + for (hostID, presence) in runnerPresenceByHost { kindByID[hostID] = presence.kind } + for conn in connections.values { + for p in conn.meshPeers { kindByID[p.deviceID] = p.kind } + } + func kind(forFingerprint fp: String) -> PeerKind { + kindByID.first { Self.sameDevice($0.key, fp) }?.value ?? .mac + } + + // 1. Every host this phone dials directly (Macs + cloud runners), from the paired registry. + var devices: [MeshDevice] = [] + var pairedFingerprints: [String] = [] + for host in IdentityStore.pairedHosts() { + let fp = host.fingerprint + pairedFingerprints.append(fp) + let reach: MeshReachability + switch connections[fp]?.connectivity { + case .connected(let t)?: reach = .connected(t) + case .connecting?, .reconnecting?: reach = .connecting + default: + // Not reachable directly — but maybe a host we ARE connected to sees it (a Mac behind + // a NAT the phone can't punch, reachable through another Mac): show that route. + reach = viaHostName(forFingerprint: fp).map(MeshReachability.viaPeer) ?? .offline + } + devices.append(MeshDevice( + id: fp, name: host.hostName.isEmpty ? "Mac" : host.hostName, + kind: kind(forFingerprint: fp), reachability: reach, isPairedHost: true)) + } + + // 2. Devices we only learn of through a connected host (the "via" rows): online peers in some + // live host's roster that we don't dial ourselves, and that aren't this phone. + let myDeviceID = IdentityStore.deviceID() + var addedPeerIDs = Set() + for conn in connections.values where conn.connectivity.isLive { + for p in conn.meshPeers where p.online { + guard p.deviceID != myDeviceID, + !pairedFingerprints.contains(where: { Self.sameDevice($0, p.deviceID) }), + addedPeerIDs.insert(p.deviceID).inserted + else { continue } + devices.append(MeshDevice( + id: p.deviceID, name: p.label.isEmpty ? p.kind.displayName : p.label, + kind: p.kind, reachability: .viaPeer(conn.hostName))) + } + } + + func rank(_ r: MeshReachability) -> Int { + switch r { + case .connected: 0 + case .viaPeer: 1 + case .connecting: 2 + case .offline: 3 + } + } + let others = devices.sorted { + let (ra, rb) = (rank($0.reachability), rank($1.reachability)) + if ra != rb { return ra < rb } + return $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending + } + return [selfDevice] + others + } + + /// The name of a connected host whose roster reports the given (currently-undialed) host online — + /// i.e. we reach that host "by way of" this one. Nil when no connected host sees it. + private func viaHostName(forFingerprint fp: String) -> String? { + for conn in connections.values where conn.connectivity.isLive { + if conn.meshPeers.contains(where: { $0.online && Self.sameDevice($0.deviceID, fp) }) { + return conn.hostName + } + } + return nil + } + + // MARK: Mesh status headline (mesh-wide "Connected") + + /// The one-line mesh connection state for the Settings headline — "connected to the mesh in + /// general", not to any one Mac. Live if this phone reaches ANY mesh host (a Mac or a cloud + /// runner), so a phone that only talks to cloud runners over the relay still reads "Connected". + var meshStatusHeadline: String { + if isMeshConnected { return "Connected" } + if !isPaired { return "Not paired" } + switch connectivity { + case .connecting: return "Connecting…" + case .reconnecting: return "Reconnecting…" + default: return "Not connected" + } + } + + /// The mesh headline's supporting detail: how many devices are reachable and over what transport, + /// or why nothing is. + var meshStatusDetail: String { + if demoMode { return "Demo mesh" } + if !isPaired { return "Scan a Mac's QR code to join a mesh" } + if isMeshConnected { + let others = meshDevices.filter { !$0.isSelf } + let online = others.filter { $0.reachability.isOnline }.count + let count = "\(online) of \(others.count) device\(others.count == 1 ? "" : "s") online" + let transports = liveTransports.map(\.label).sorted().joined(separator: ", ") + return transports.isEmpty ? count : "\(count) · \(transports)" + } + switch connectivity { + case .connecting, .reconnecting: return "Reaching your mesh…" + default: return "No mesh devices reachable" + } + } + /// Whether the composer should offer "Auto (Mesh)" for the project the phone has selected: /// some connected host advertising `canAcknowledgeDispatch` (other than the project's own /// owner) holds a matching repo. The descriptor comes from the owning host's presence card. @@ -1733,13 +1929,13 @@ final class RemoteStore: ObservableObject { connections.removeAll() } - /// Connect to every paired Mac at once (mesh P3 multiplexer): each `HostConnection` runs its own - /// 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` remains as a source-compatible hint for App Intent callers; Relay-enabled - /// hosts now use that fast baseline for foreground and background reconnects alike. + /// Connect to every mesh host at once (mesh P3 multiplexer): each `HostConnection` runs its own + /// IK reconnect (direct-first — LAN/tailnet — with Relay as the immediate fallback, backoff on + /// failure), so all your Macs and cloud runners 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 hosts are dropped. The launch + "Reconnect" path. + /// `preferRelay` is a source-compatible hint for App-Intent / quick-approve callers that forces + /// the Relay baseline first (see `HostConnection.buildCandidates`). func reconnect(preferRelay: Bool = false) { let hosts = IdentityStore.pairedHosts() guard !hosts.isEmpty else { connectivity = .unpaired; return } diff --git a/NucleicRemote/NucleicRemote/Views/AdaptiveRootView.swift b/NucleicRemote/NucleicRemote/Views/AdaptiveRootView.swift index 7f04f25..a40699c 100644 --- a/NucleicRemote/NucleicRemote/Views/AdaptiveRootView.swift +++ b/NucleicRemote/NucleicRemote/Views/AdaptiveRootView.swift @@ -383,7 +383,7 @@ private struct SplitSidebar: View { Section("Sessions") { Text(store.connectivity.isLive ? "Start a session from Home or the Mac to see it here." - : "Waiting to connect to your Mac…") + : "Waiting to connect to your mesh…") .font(.callout).foregroundStyle(.secondary) } } else { diff --git a/NucleicRemote/NucleicRemote/Views/MeshInfoView.swift b/NucleicRemote/NucleicRemote/Views/MeshInfoView.swift index ef38a1a..ab8f654 100644 --- a/NucleicRemote/NucleicRemote/Views/MeshInfoView.swift +++ b/NucleicRemote/NucleicRemote/Views/MeshInfoView.swift @@ -16,13 +16,12 @@ import NucleicProtocol struct MeshInfoView: View { @EnvironmentObject var store: RemoteStore - private var pairedHosts: [PairedHost] { IdentityStore.pairedHosts() } private var runnerCards: [(hostID: String, presence: RunnerPresence)] { store.meshRunnerCards } var body: some View { Form { statusSection - yourMacsSection + devicesSection runnerCardsSection activitySection } @@ -35,38 +34,32 @@ struct MeshInfoView: View { @ViewBuilder private var statusSection: some View { Section { - LabeledContent("Connection", value: store.connectivity.label) - LabeledContent("Paired Macs", value: "\(pairedHosts.count)") + LabeledContent("Status", value: store.meshStatusHeadline) + LabeledContent("Devices", value: "\(store.meshDevices.filter { !$0.isSelf }.count)") if !runnerCards.isEmpty { LabeledContent("Runner cards", value: "\(runnerCards.count)") } } header: { - Text("Status") + Text("Mesh") } footer: { - Text("The mesh as this device sees it. Read-only — structural metadata only.") + Text("The mesh as this device sees it — “connected” means it can reach any device on the " + + "mesh, a Mac or a cloud runner. Read-only; structural metadata only.") } } - // MARK: Your Macs + // MARK: Devices @ViewBuilder - private var yourMacsSection: some View { - if !pairedHosts.isEmpty { - Section("Your Macs") { - ForEach(pairedHosts, id: \.fingerprint) { host in - let connectivity = store.connectivity(forPairedHost: host.fingerprint) - HStack(spacing: 10) { - statusDot(online: connectivity?.isLive == true) - Image(systemName: "desktopcomputer").foregroundStyle(.secondary) - VStack(alignment: .leading, spacing: 2) { - Text(host.hostName.isEmpty ? "Mac" : host.hostName) - Text(connectivity?.label ?? "Not connected") - .font(.footnote).foregroundStyle(.secondary) - } - Spacer() - } - } + private var devicesSection: some View { + Section { + ForEach(store.meshDevices) { device in + MeshDeviceRow(device: device) } + } header: { + Text("Devices") + } footer: { + Text("Each device on your mesh and how this device reaches it — the connection method " + + "(LAN / Tailnet / Relay / Direct), or the device it's reached by way of.") } } @@ -105,12 +98,49 @@ struct MeshInfoView: View { } } - // MARK: Bits +} - private func statusDot(online: Bool) -> some View { - Circle() - .fill(online ? Color.green : Color.secondary.opacity(0.4)) - .frame(width: 8, height: 8) +/// One row in the mesh device list: a status dot, the device's kind icon, its name, and how this +/// phone reaches it — the connection method (LAN / Tailnet / Relay / Direct (STUN)) for a direct +/// link, or "via " when it's reached by way of another mesh device. Shared by Settings ▸ +/// Connection and the Mesh Info screen. +struct MeshDeviceRow: View { + let device: RemoteStore.MeshDevice + + private var dotColor: Color { + switch device.reachability { + case .connected: .green + case .viaPeer: .blue + case .connecting: .orange + case .offline: Color.secondary.opacity(0.4) + } + } + + private var icon: String { + switch device.kind { + case .iphone: "iphone" + case .cloud: "cloud" + default: "desktopcomputer" + } + } + + /// Trailing text: "This device" for self, else the reachability detail (method or "via …"). + private var detail: String { + device.isSelf ? "This device" : device.reachability.detail + } + + var body: some View { + HStack(spacing: 10) { + Circle().fill(dotColor).frame(width: 8, height: 8) + Image(systemName: icon) + .foregroundStyle(.secondary) + .frame(width: 22) + VStack(alignment: .leading, spacing: 2) { + Text(device.name).lineLimit(1) + Text(detail).font(.footnote).foregroundStyle(.secondary) + } + Spacer(minLength: 8) + } } } diff --git a/NucleicRemote/NucleicRemote/Views/SessionsView.swift b/NucleicRemote/NucleicRemote/Views/SessionsView.swift index ad9694a..1fe677a 100644 --- a/NucleicRemote/NucleicRemote/Views/SessionsView.swift +++ b/NucleicRemote/NucleicRemote/Views/SessionsView.swift @@ -41,7 +41,7 @@ struct SessionsView: View { "No sessions", systemImage: "square.stack.3d.up", description: Text(store.connectivity.isLive ? "Start a session from Home or the Mac to see it here." - : "Waiting to connect to your Mac…")) + : "Waiting to connect to your mesh…")) } else { List { // Offline: the rows below are the saved history, not a live view — say so. diff --git a/NucleicRemote/NucleicRemote/Views/SettingsView.swift b/NucleicRemote/NucleicRemote/Views/SettingsView.swift index 43966c7..6364efc 100644 --- a/NucleicRemote/NucleicRemote/Views/SettingsView.swift +++ b/NucleicRemote/NucleicRemote/Views/SettingsView.swift @@ -12,8 +12,6 @@ struct SettingsView: View { @State private var showRenameDevice = false @State private var deviceName = IdentityStore.deviceName() @State private var deviceNameDraft = "" - /// Bumped after removing a paired Mac so the "Paired Macs" list re-reads the registry (mesh P3). - @State private var pairedHostsToken = UUID() @AppStorage("nucleic.showRawEvents") private var showRaw = false @AppStorage("nucleic.showLockEvents") private var showLockEvents = true @AppStorage(HeartbeatSettings.shareAnonymousUsageKey) private var shareAnonymousUsage = true @@ -58,49 +56,57 @@ struct SettingsView: View { Text("") } - Section("Connection") { - LabeledContent("Status", value: Self.statusLabel(store.connectivity)) - // What this pairing can dial — the live transport is in Status above. - if let host = IdentityStore.loadPairedHost() { - LabeledContent("Transports", value: Self.transportsLabel(host)) + Section { + // Mesh-wide headline — "Connected" means this phone can reach the mesh (any Mac + // OR cloud runner), not one specific Mac. A phone that only talks to cloud runners + // over the relay still reads "Connected". + HStack(spacing: 12) { + Circle() + .fill(store.isMeshConnected ? Color.green + : (store.isPaired ? Color.orange : Color.secondary.opacity(0.4))) + .frame(width: 10, height: 10) + VStack(alignment: .leading, spacing: 2) { + Text(store.meshStatusHeadline).font(.body.weight(.medium)) + Text(store.meshStatusDetail).font(.footnote).foregroundStyle(.secondary) + } + Spacer() + Button("Reconnect") { store.reconnect() } + .buttonStyle(.bordered) + .controlSize(.small) + .disabled(!store.isPaired || store.demoMode) } - Button("Reconnect") { store.reconnect() } - .disabled(!store.isPaired) - } + .padding(.vertical, 2) - // Mesh P3: every Mac this phone is paired with — all connected at once and shown - // together in the app (no active-host selector). Removing one forgets just that Mac - // and drops its connection; the others keep running. - let pairedHosts = IdentityStore.pairedHosts() - if !pairedHosts.isEmpty { - Section("Mesh") { - // A read-only diagnostics view of the mesh as this phone sees it — the - // iOS analogue of the Mac's Covalence Mesh Viewer. + // One row per device on the mesh: a live dot, its kind, and how it's reached — + // the connection method (LAN / Tailnet / Relay / Direct (STUN)) for a direct link, + // or "via " when reached by way of another mesh device. + ForEach(store.meshDevices) { device in + MeshDeviceRow(device: device) + .swipeActions(edge: .trailing) { + if device.isPairedHost { + Button(role: .destructive) { store.unpair(device.id) } label: { + Label("Remove", systemImage: "minus.circle") + } + } + } + } + + if store.isPaired { + // Read-only diagnostics view of the mesh — the iOS analogue of the Mac's + // Covalence Mesh Viewer. NavigationLink { MeshInfoView() } label: { Label("Mesh info", systemImage: "point.3.connected.trianglepath.dotted") } - ForEach(pairedHosts, id: \.fingerprint) { host in - HStack(spacing: 10) { - Image(systemName: "desktopcomputer").foregroundStyle(.secondary) - VStack(alignment: .leading, spacing: 2) { - Text(host.hostName.isEmpty ? "Mac" : host.hostName) - Text(host.fingerprint.prefix(16) + "…") - .font(.footnote.monospaced()).foregroundStyle(.secondary) - } - Spacer() - Button(role: .destructive) { - store.unpair(host.fingerprint) - pairedHostsToken = UUID() - } label: { - Image(systemName: "minus.circle") - } - .buttonStyle(.borderless) - } - } } - .id(pairedHostsToken) + } header: { + Text("Connection") + } footer: { + Text("“Connected” means this device can reach your mesh — a Mac or a cloud runner " + + "(an iPhone can send jobs to cloud runners without a Mac connection). Each " + + "device shows how it's reached: on your network (LAN), your tailnet, the " + + "relay, a direct link, or by way of another device. Swipe a Mac to remove it.") } // Remote agent sign-in (docs/REMOTE_AGENT_LOGIN.md): each connected host's @@ -212,24 +218,6 @@ struct SettingsView: View { } } } - - /// The live-connection status shown in the Connection section. Matches the generic - /// `Connectivity.label` except that a direct (hole-punched) path is named "Direct (STUN)" - /// here to surface *how* the peer-to-peer link was established — `.direct` is produced only - /// by the STUN-based upgrade. The chip and other surfaces keep the shorter "Direct". - private static func statusLabel(_ connectivity: RemoteStore.Connectivity) -> String { - if case .connected(.direct) = connectivity { return "Connected · Direct (STUN)" } - return connectivity.label - } - - /// Every path this pairing can dial: LAN always, plus what the host advertised — a - /// tailnet hint from the QR and/or a relay membership (QR or the host's later push). - private static func transportsLabel(_ host: PairedHost) -> String { - var parts = ["LAN"] - if host.tailnetHost != nil { parts.append("Tailnet") } - if host.relayMembershipToken?.isEmpty == false { parts.append("Relay") } - return parts.joined(separator: " + ") - } } /// Shown before pairing (UX_IOS §7): explain the flow and launch the scanner.