Merge nucleic/clever-harbor-quail-2rko into dev

This commit is contained in:
2026-07-19 17:38:54 -07:00
parent f1483684c6
commit 540101e7ac
6 changed files with 336 additions and 102 deletions
@@ -256,9 +256,11 @@ final class HostConnection {
_ = tryNextCandidate() _ = tryNextCandidate()
} }
/// Reconnect to the pinned host using IK. Relay admission always establishes the baseline first; /// Reconnect to the pinned host using IK. Candidates are dialed direct-first (LAN, then tailnet)
/// LAN and STUN optimization happen only after that session is usable. `preferLAN` is reserved /// with Relay as the immediate fallback (see `buildCandidates`). `preferRelay` forces the Relay
/// for a LAN endpoint just proven while Relay remained live. /// 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( func reconnect(
to host: PairedHost, preferRelay: Bool = false, preferLAN: Bool = false 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 { if let tailnet, let host = tailnet.host, let port = tailnet.port, TailnetSupport.isBuiltIn {
directCandidates.append(.tailnet(host: host, port: port)) directCandidates.append(.tailnet(host: host, port: port))
} }
// `preferRelay` is retained for existing App-Intent callers, but Relay is now first for all // Ordering (SYNC_PROTOCOL §3.2, revised for LAN latency). Dial the DIRECT candidates
// callers whenever it is configured. A proven LAN promotion gets one LAN-first attempt with // (LAN, then tailnet) FIRST and keep Relay as the immediate fallback, so a co-located Mac
// Relay immediately behind it; all other direct candidates remain failure fallbacks. // connects in well under a second instead of paying a Cloudflare round-trip (token mint +
_ = preferRelay // 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, if preferLAN, let first = directCandidates.first,
case .lan = first, let relayCandidate { case .lan = first, let relayCandidate {
return [first, relayCandidate] + Array(directCandidates.dropFirst()) return [first, relayCandidate] + Array(directCandidates.dropFirst())
} }
if let relayCandidate { return [relayCandidate] + directCandidates } guard let relayCandidate else { return directCandidates }
return directCandidates return directCandidates.isEmpty ? [relayCandidate] : directCandidates + [relayCandidate]
} }
private func tryNextCandidate() -> Bool { private func tryNextCandidate() -> Bool {
@@ -30,7 +30,7 @@ final class RemoteStore: ObservableObject {
case .connecting: "Connecting…" case .connecting: "Connecting…"
case .reconnecting: "Reconnecting…" case .reconnecting: "Reconnecting…"
case .connected(let transport): "Connected · \(transport.label)" case .connected(let transport): "Connected · \(transport.label)"
case .hostOffline: "Mac offline" case .hostOffline: "No devices reachable"
case .failed(let m): m 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<SyncTransportHint>()
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<String>()
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: /// 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 /// 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. /// 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() connections.removeAll()
} }
/// Connect to every paired Mac at once (mesh P3 multiplexer): each `HostConnection` runs its own /// Connect to every mesh host 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 /// IK reconnect (direct-first — LAN/tailnet — with Relay as the immediate fallback, backoff on
/// switcher flips between them instantly. Idempotent — an already-live connection is left alone; /// failure), so all your Macs and cloud runners are live simultaneously and the switcher flips
/// an offline one (re)dials. Connections for since-unpaired Macs are dropped. The launch + /// between them instantly. Idempotent — an already-live connection is left alone; an offline one
/// "Reconnect" path. /// (re)dials. Connections for since-unpaired hosts are dropped. The launch + "Reconnect" path.
/// `preferRelay` remains as a source-compatible hint for App Intent callers; Relay-enabled /// `preferRelay` is a source-compatible hint for App-Intent / quick-approve callers that forces
/// hosts now use that fast baseline for foreground and background reconnects alike. /// the Relay baseline first (see `HostConnection.buildCandidates`).
func reconnect(preferRelay: Bool = false) { func reconnect(preferRelay: Bool = false) {
let hosts = IdentityStore.pairedHosts() let hosts = IdentityStore.pairedHosts()
guard !hosts.isEmpty else { connectivity = .unpaired; return } guard !hosts.isEmpty else { connectivity = .unpaired; return }
@@ -383,7 +383,7 @@ private struct SplitSidebar: View {
Section("Sessions") { Section("Sessions") {
Text(store.connectivity.isLive Text(store.connectivity.isLive
? "Start a session from Home or the Mac to see it here." ? "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) .font(.callout).foregroundStyle(.secondary)
} }
} else { } else {
@@ -16,13 +16,12 @@ import NucleicProtocol
struct MeshInfoView: View { struct MeshInfoView: View {
@EnvironmentObject var store: RemoteStore @EnvironmentObject var store: RemoteStore
private var pairedHosts: [PairedHost] { IdentityStore.pairedHosts() }
private var runnerCards: [(hostID: String, presence: RunnerPresence)] { store.meshRunnerCards } private var runnerCards: [(hostID: String, presence: RunnerPresence)] { store.meshRunnerCards }
var body: some View { var body: some View {
Form { Form {
statusSection statusSection
yourMacsSection devicesSection
runnerCardsSection runnerCardsSection
activitySection activitySection
} }
@@ -35,38 +34,32 @@ struct MeshInfoView: View {
@ViewBuilder @ViewBuilder
private var statusSection: some View { private var statusSection: some View {
Section { Section {
LabeledContent("Connection", value: store.connectivity.label) LabeledContent("Status", value: store.meshStatusHeadline)
LabeledContent("Paired Macs", value: "\(pairedHosts.count)") LabeledContent("Devices", value: "\(store.meshDevices.filter { !$0.isSelf }.count)")
if !runnerCards.isEmpty { if !runnerCards.isEmpty {
LabeledContent("Runner cards", value: "\(runnerCards.count)") LabeledContent("Runner cards", value: "\(runnerCards.count)")
} }
} header: { } header: {
Text("Status") Text("Mesh")
} footer: { } 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 @ViewBuilder
private var yourMacsSection: some View { private var devicesSection: some View {
if !pairedHosts.isEmpty { Section {
Section("Your Macs") { ForEach(store.meshDevices) { device in
ForEach(pairedHosts, id: \.fingerprint) { host in MeshDeviceRow(device: device)
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()
}
}
} }
} 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 { /// One row in the mesh device list: a status dot, the device's kind icon, its name, and how this
Circle() /// phone reaches it — the connection method (LAN / Tailnet / Relay / Direct (STUN)) for a direct
.fill(online ? Color.green : Color.secondary.opacity(0.4)) /// link, or "via <device>" when it's reached by way of another mesh device. Shared by Settings ▸
.frame(width: 8, height: 8) /// 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)
}
} }
} }
@@ -41,7 +41,7 @@ struct SessionsView: View {
"No sessions", systemImage: "square.stack.3d.up", "No sessions", systemImage: "square.stack.3d.up",
description: Text(store.connectivity.isLive description: Text(store.connectivity.isLive
? "Start a session from Home or the Mac to see it here." ? "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 { } else {
List { List {
// Offline: the rows below are the saved history, not a live view — say so. // Offline: the rows below are the saved history, not a live view — say so.
@@ -12,8 +12,6 @@ struct SettingsView: View {
@State private var showRenameDevice = false @State private var showRenameDevice = false
@State private var deviceName = IdentityStore.deviceName() @State private var deviceName = IdentityStore.deviceName()
@State private var deviceNameDraft = "" @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.showRawEvents") private var showRaw = false
@AppStorage("nucleic.showLockEvents") private var showLockEvents = true @AppStorage("nucleic.showLockEvents") private var showLockEvents = true
@AppStorage(HeartbeatSettings.shareAnonymousUsageKey) private var shareAnonymousUsage = true @AppStorage(HeartbeatSettings.shareAnonymousUsageKey) private var shareAnonymousUsage = true
@@ -58,49 +56,57 @@ struct SettingsView: View {
Text("") Text("")
} }
Section("Connection") { Section {
LabeledContent("Status", value: Self.statusLabel(store.connectivity)) // Mesh-wide headline — "Connected" means this phone can reach the mesh (any Mac
// What this pairing can dial — the live transport is in Status above. // OR cloud runner), not one specific Mac. A phone that only talks to cloud runners
if let host = IdentityStore.loadPairedHost() { // over the relay still reads "Connected".
LabeledContent("Transports", value: Self.transportsLabel(host)) 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() } .padding(.vertical, 2)
.disabled(!store.isPaired)
}
// Mesh P3: every Mac this phone is paired with — all connected at once and shown // One row per device on the mesh: a live dot, its kind, and how it's reached —
// together in the app (no active-host selector). Removing one forgets just that Mac // the connection method (LAN / Tailnet / Relay / Direct (STUN)) for a direct link,
// and drops its connection; the others keep running. // or "via <device>" when reached by way of another mesh device.
let pairedHosts = IdentityStore.pairedHosts() ForEach(store.meshDevices) { device in
if !pairedHosts.isEmpty { MeshDeviceRow(device: device)
Section("Mesh") { .swipeActions(edge: .trailing) {
// A read-only diagnostics view of the mesh as this phone sees it — the if device.isPairedHost {
// iOS analogue of the Mac's Covalence Mesh Viewer. 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 { NavigationLink {
MeshInfoView() MeshInfoView()
} label: { } label: {
Label("Mesh info", systemImage: "point.3.connected.trianglepath.dotted") 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 // 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. /// Shown before pairing (UX_IOS §7): explain the flow and launch the scanner.