Merge nucleic/clever-harbor-quail-2rko into dev
This commit is contained in:
@@ -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() }
|
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
|
if store.isPaired {
|
||||||
// together in the app (no active-host selector). Removing one forgets just that Mac
|
// Read-only diagnostics view of the mesh — the iOS analogue of the Mac's
|
||||||
// and drops its connection; the others keep running.
|
// Covalence Mesh Viewer.
|
||||||
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.
|
|
||||||
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()
|
} header: {
|
||||||
Button(role: .destructive) {
|
Text("Connection")
|
||||||
store.unpair(host.fingerprint)
|
} footer: {
|
||||||
pairedHostsToken = UUID()
|
Text("“Connected” means this device can reach your mesh — a Mac or a cloud runner "
|
||||||
} label: {
|
+ "(an iPhone can send jobs to cloud runners without a Mac connection). Each "
|
||||||
Image(systemName: "minus.circle")
|
+ "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.")
|
||||||
.buttonStyle(.borderless)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.id(pairedHostsToken)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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.
|
||||||
|
|||||||
Reference in New Issue
Block a user