Merge nucleic/clever-harbor-quail-2rko into dev
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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<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:
|
||||
/// 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 }
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 <device>" 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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() }
|
||||
.disabled(!store.isPaired)
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.small)
|
||||
.disabled(!store.isPaired || store.demoMode)
|
||||
}
|
||||
.padding(.vertical, 2)
|
||||
|
||||
// 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 <device>" 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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
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.
|
||||
|
||||
Reference in New Issue
Block a user