Merge claude/keen-shamir-122122 into dev (mesh P5 finish + P3 host switcher)
Mesh P5 feature-complete except the two-Mac memory-carry spike: moved/arrived-session visibility, relaunch recovery, bulk hand-off, stranded-arrival activate-anyway. Plus the iOS build fix (equivalent to dev's f021ef8) and P3 foundation: paired-host registry + migration and a host switcher (switch active Mac; 2-host demo, simulator-verified). Co-Authored-By: Claude Opus 4.8 <[email protected]> # Conflicts: # ios/NucleicRemote/NucleicRemote/Models/RemoteStore.swift
This commit is contained in:
@@ -29,7 +29,12 @@ struct PairedHost: Codable, Equatable {
|
||||
/// (UserDefaults). The identity is generated once on first launch and reused thereafter.
|
||||
enum IdentityStore {
|
||||
private static let keychainAccount = "xyz.blakeslee.nucleic.remote.identity"
|
||||
/// Legacy single-host slot (pre-mesh P3). Migrated into `pairedHostsKey` on first registry read.
|
||||
private static let pairedHostKey = "nucleic.pairedHost"
|
||||
/// The multi-host registry (mesh P3): an ordered `[PairedHost]`, most-recently-paired last.
|
||||
/// The last entry is the "active" host the single connection uses today; the multiplexer will
|
||||
/// connect to all of them.
|
||||
private static let pairedHostsKey = "nucleic.pairedHosts"
|
||||
private static let deviceIDKey = "nucleic.deviceID"
|
||||
|
||||
static func loadOrCreateIdentity() -> DeviceIdentity {
|
||||
@@ -61,19 +66,66 @@ enum IdentityStore {
|
||||
#endif
|
||||
}
|
||||
|
||||
static func loadPairedHost() -> PairedHost? {
|
||||
guard let data = UserDefaults.standard.data(forKey: pairedHostKey) else { return nil }
|
||||
return try? JSONDecoder().decode(PairedHost.self, from: data)
|
||||
// MARK: - Paired-host registry (mesh P3)
|
||||
|
||||
/// Every Mac this phone is paired with, most-recently-paired last. Migrates the legacy
|
||||
/// single-host slot into the registry on first read (then removes it), so an upgrade keeps its
|
||||
/// Mac. Ordered so the last is the "active" host today; a future multiplexer connects to all.
|
||||
static func pairedHosts() -> [PairedHost] {
|
||||
let defaults = UserDefaults.standard
|
||||
if let data = defaults.data(forKey: pairedHostsKey),
|
||||
let hosts = try? JSONDecoder().decode([PairedHost].self, from: data) {
|
||||
return hosts
|
||||
}
|
||||
// One-time migration from the pre-mesh single slot.
|
||||
if let legacy = defaults.data(forKey: pairedHostKey),
|
||||
let host = try? JSONDecoder().decode(PairedHost.self, from: legacy) {
|
||||
savePairedHosts([host])
|
||||
defaults.removeObject(forKey: pairedHostKey)
|
||||
return [host]
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
static func savePairedHost(_ host: PairedHost) {
|
||||
if let data = try? JSONEncoder().encode(host) {
|
||||
UserDefaults.standard.set(data, forKey: pairedHostKey)
|
||||
/// The one paired Mac with `hostID` (its `fingerprint`), if any.
|
||||
static func pairedHost(id hostID: String) -> PairedHost? {
|
||||
pairedHosts().first { $0.fingerprint == hostID }
|
||||
}
|
||||
|
||||
/// Add or update a host by fingerprint, moving it to the end (making it the active host). Used
|
||||
/// on a successful pairing handshake and on any address refresh.
|
||||
static func upsertPairedHost(_ host: PairedHost) {
|
||||
var hosts = pairedHosts().filter { $0.fingerprint != host.fingerprint }
|
||||
hosts.append(host)
|
||||
savePairedHosts(hosts)
|
||||
}
|
||||
|
||||
/// Forget one paired Mac by fingerprint (per-host unpair).
|
||||
static func removePairedHost(id hostID: String) {
|
||||
savePairedHosts(pairedHosts().filter { $0.fingerprint != hostID })
|
||||
}
|
||||
|
||||
private static func savePairedHosts(_ hosts: [PairedHost]) {
|
||||
if let data = try? JSONEncoder().encode(hosts) {
|
||||
UserDefaults.standard.set(data, forKey: pairedHostsKey)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Single-host bridge (until the RemoteStore multiplexer lands)
|
||||
|
||||
/// The active host the single connection uses — the most-recently-paired. `nil` if unpaired.
|
||||
static func loadPairedHost() -> PairedHost? {
|
||||
pairedHosts().last
|
||||
}
|
||||
|
||||
/// Pair (or re-pair) a host and make it active.
|
||||
static func savePairedHost(_ host: PairedHost) {
|
||||
upsertPairedHost(host)
|
||||
}
|
||||
|
||||
/// Unpair the active host (the one the single connection currently uses).
|
||||
static func clearPairedHost() {
|
||||
UserDefaults.standard.removeObject(forKey: pairedHostKey)
|
||||
if let active = pairedHosts().last { removePairedHost(id: active.fingerprint) }
|
||||
}
|
||||
|
||||
// MARK: - Keychain
|
||||
|
||||
@@ -39,6 +39,10 @@ final class RemoteStore: ObservableObject {
|
||||
|
||||
@Published private(set) var connectivity: Connectivity = .unpaired
|
||||
@Published private(set) var hostName: String = ""
|
||||
/// The paired Mac this connection currently reflects (mesh P3), by its `HostID` (fingerprint).
|
||||
/// `hostName`/`sessions`/etc. above are that host's projection; switching hosts re-points them
|
||||
/// (live: reconnect to a different paired Mac; demo: swap the mock host). `nil` until connected.
|
||||
@Published private(set) var activeHostID: String?
|
||||
@Published private(set) var sessions: [WireSessionSummary] = []
|
||||
@Published private(set) var capabilities = WireCapabilities(canModifyToolInput: false, allowAlwaysScopes: [])
|
||||
@Published private(set) var grantedScope: DeviceScope = .approve
|
||||
@@ -179,6 +183,72 @@ final class RemoteStore: ObservableObject {
|
||||
/// In-flight simulated-run tasks (canned streaming), cancelled on `exitDemo()`.
|
||||
private var demoTasks: [Task<Void, Never>] = []
|
||||
|
||||
// MARK: - Multi-host switching (mesh P3)
|
||||
|
||||
/// One selectable Mac for the host switcher — the paired Macs (live) or the mock hosts (demo).
|
||||
struct HostChoice: Identifiable, Equatable {
|
||||
let id: String // HostID (fingerprint), or a demo id
|
||||
let name: String
|
||||
let isActive: Bool
|
||||
}
|
||||
|
||||
/// The Macs this phone can switch between. Live: the paired-host registry. Demo: the mock hosts.
|
||||
/// One entry ⇒ the switcher hides itself and the app reads single-host. Today the phone holds one
|
||||
/// *live* connection at a time (switching reconnects); simultaneous connections are the deferred
|
||||
/// multiplexer step.
|
||||
var hostChoices: [HostChoice] {
|
||||
if demoMode {
|
||||
return demoHosts.map { HostChoice(id: $0.id, name: $0.name, isActive: $0.id == activeHostID) }
|
||||
}
|
||||
return IdentityStore.pairedHosts().map {
|
||||
HostChoice(id: $0.fingerprint, name: $0.hostName, isActive: $0.fingerprint == activeHostID)
|
||||
}
|
||||
}
|
||||
|
||||
/// Switch which paired Mac is active. Live: re-point the (single) connection via the existing
|
||||
/// reconnect machinery. Demo: swap the mock host's projection, preserving in-demo edits.
|
||||
func switchHost(to id: String) {
|
||||
guard id != activeHostID else { return }
|
||||
if demoMode {
|
||||
if let i = demoHosts.firstIndex(where: { $0.id == activeHostID }) {
|
||||
demoHosts[i].sessions = sessions // keep any in-demo mutations
|
||||
demoHosts[i].dashboard = dashboard
|
||||
}
|
||||
guard let next = demoHosts.first(where: { $0.id == id }) else { return }
|
||||
applyDemoHost(next)
|
||||
} else {
|
||||
activeHostID = id
|
||||
reconnect()
|
||||
}
|
||||
}
|
||||
|
||||
/// The paired Mac the live connection targets — the explicitly-active one, else the most-recent.
|
||||
private func activeHost() -> PairedHost? {
|
||||
if let id = activeHostID, let host = IdentityStore.pairedHost(id: id) { return host }
|
||||
return IdentityStore.pairedHosts().last
|
||||
}
|
||||
|
||||
/// Mock hosts for the multi-host demo. Each carries its own name + sessions + dashboard;
|
||||
/// switching swaps the flat active-host projection. Empty outside demo.
|
||||
private struct DemoHost {
|
||||
let id: String
|
||||
let name: String
|
||||
var sessions: [WireSessionSummary]
|
||||
var dashboard: DashboardSnapshot
|
||||
}
|
||||
private var demoHosts: [DemoHost] = []
|
||||
|
||||
/// Project a mock host onto the flat active-host state (demo host switch).
|
||||
private func applyDemoHost(_ host: DemoHost) {
|
||||
activeHostID = host.id
|
||||
hostName = host.name
|
||||
sessions = host.sessions
|
||||
dashboard = host.dashboard
|
||||
openSessionID = nil; openEvents = []; openApprovals = []; openDiff = nil
|
||||
LiveActivityManager.shared.sync(hostName: hostName, sessions: sessions)
|
||||
NotificationRouter.shared.updateBadge(needsYouCount)
|
||||
}
|
||||
|
||||
var isPaired: Bool { demoMode || IdentityStore.loadPairedHost() != nil }
|
||||
var deviceFingerprint: String { identity.fingerprint }
|
||||
|
||||
@@ -226,13 +296,6 @@ final class RemoteStore: ObservableObject {
|
||||
pendingApprovalCount: approvals, favorite: fav, archived: arch,
|
||||
updatedAt: Date())
|
||||
}
|
||||
sessions = [
|
||||
sum("a1", p1, "nucleic", "auth-refactor", .awaitingApproval, approvals: 1, add: 312, rem: 40),
|
||||
sum("a2", p1, "nucleic", "flaky-tests", .running, add: 88, rem: 12),
|
||||
sum("a3", p2, "website", "graphql-migration", .awaitingInput, .awaitingInput, fav: true),
|
||||
sum("a4", p2, "website", "docs-pass", .awaitingInput, .completed, add: 20, rem: 4),
|
||||
sum("a5", p1, "nucleic", "old-experiment", .finished, arch: true),
|
||||
]
|
||||
let cal = Calendar.current
|
||||
let today = cal.startOfDay(for: Date())
|
||||
let activity = (0..<40).map { i -> ActivityDay in
|
||||
@@ -241,7 +304,15 @@ final class RemoteStore: ObservableObject {
|
||||
return ActivityDay(day: cal.date(byAdding: .day, value: -i, to: today)!,
|
||||
count: count, tokens: count * 8_500 + (i * 137) % 4_000)
|
||||
}
|
||||
dashboard = DashboardSnapshot(
|
||||
// Host 1 — "Andrew's Mac".
|
||||
let host1Sessions = [
|
||||
sum("a1", p1, "nucleic", "auth-refactor", .awaitingApproval, approvals: 1, add: 312, rem: 40),
|
||||
sum("a2", p1, "nucleic", "flaky-tests", .running, add: 88, rem: 12),
|
||||
sum("a3", p2, "website", "graphql-migration", .awaitingInput, .awaitingInput, fav: true),
|
||||
sum("a4", p2, "website", "docs-pass", .awaitingInput, .completed, add: 20, rem: 4),
|
||||
sum("a5", p1, "nucleic", "old-experiment", .finished, arch: true),
|
||||
]
|
||||
let host1Dashboard = DashboardSnapshot(
|
||||
counts: DashboardCounts(
|
||||
projects: 2, chats: 5, activeChats: 2, messages: 142, activeDays: 9, tokens: 1_284_000),
|
||||
activity: activity,
|
||||
@@ -272,7 +343,32 @@ final class RemoteStore: ObservableObject {
|
||||
]),
|
||||
WireStatusFeed(provider: "xai", providerName: "xAI", incidents: []),
|
||||
])
|
||||
LiveActivityManager.shared.sync(hostName: hostName, sessions: sessions)
|
||||
// Host 2 — "Studio Mac" (mesh P3: a second paired Mac, for the host switcher).
|
||||
let p3 = ProjectID(rawValue: "p3")
|
||||
let host2Sessions = [
|
||||
sum("b1", p3, "renderer", "shadow-mapping", .running, add: 140, rem: 22),
|
||||
sum("b2", p3, "renderer", "gpu-profiling", .awaitingApproval, approvals: 1),
|
||||
sum("b3", p1, "nucleic", "cloud-runtime", .awaitingInput, .completed, add: 60, rem: 8),
|
||||
]
|
||||
let host2Dashboard = DashboardSnapshot(
|
||||
counts: DashboardCounts(
|
||||
projects: 1, chats: 3, activeChats: 2, messages: 61, activeDays: 5, tokens: 540_000),
|
||||
activity: activity,
|
||||
projects: [WireProject(id: p3, name: "renderer", defaultBranch: "main", sessionCount: 2, activeCount: 2)],
|
||||
todos: [
|
||||
WireTodo(id: TodoID(rawValue: "t4"), text: "Bake the light probes overnight", summary: "Bake light probes",
|
||||
projectID: p3, projectName: "renderer", status: .open, dispatchedSessionID: nil,
|
||||
triage: "medium", updatedAt: Date()),
|
||||
],
|
||||
usage: WireSubscriptionUsage(
|
||||
fiveHour: WireUsageWindow(utilization: 18, resetsAt: Date().addingTimeInterval(2 * 3600)),
|
||||
sevenDay: WireUsageWindow(utilization: 40, resetsAt: nil)),
|
||||
statusFeeds: [])
|
||||
demoHosts = [
|
||||
DemoHost(id: "demo-1", name: "Andrew's Mac", sessions: host1Sessions, dashboard: host1Dashboard),
|
||||
DemoHost(id: "demo-2", name: "Studio Mac", sessions: host2Sessions, dashboard: host2Dashboard),
|
||||
]
|
||||
applyDemoHost(demoHosts[0])
|
||||
}
|
||||
|
||||
/// Offline diff fixture (NUCLEIC_DEMO) so the full-patch Diff tab renders without a host.
|
||||
@@ -338,6 +434,7 @@ final class RemoteStore: ObservableObject {
|
||||
teardown()
|
||||
connectivity = .connecting
|
||||
hostName = payload.hostName
|
||||
activeHostID = payload.hostStaticKey.fingerprintHex
|
||||
guard let hint = payload.transportHint else {
|
||||
connectivity = .failed("This pairing code needs a newer version of Nucleic Remote.")
|
||||
return
|
||||
@@ -367,8 +464,9 @@ final class RemoteStore: ObservableObject {
|
||||
/// when reachable, else the pairing's tailnet hint — so a phone that leaves the Mac's
|
||||
/// Wi‑Fi rolls over to the tailnet and rolls back when it returns.
|
||||
func reconnect() {
|
||||
guard let host = IdentityStore.loadPairedHost() else { connectivity = .unpaired; return }
|
||||
guard let host = activeHost() else { connectivity = .unpaired; return }
|
||||
teardown()
|
||||
activeHostID = host.fingerprint
|
||||
connectivity = reconnectAttempts == 0 ? .connecting : .reconnecting
|
||||
hostName = host.hostName
|
||||
guard host.transportHint != .relay else {
|
||||
@@ -549,7 +647,14 @@ final class RemoteStore: ObservableObject {
|
||||
|
||||
func unpair() {
|
||||
teardown()
|
||||
IdentityStore.clearPairedHost()
|
||||
// Forget the *active* Mac specifically (the switcher may have made it not the last).
|
||||
if let id = activeHostID { IdentityStore.removePairedHost(id: id) } else { IdentityStore.clearPairedHost() }
|
||||
activeHostID = nil
|
||||
// Mesh P3: if other Macs remain paired, switch to one rather than going fully unpaired.
|
||||
if IdentityStore.pairedHosts().last != nil {
|
||||
reconnect()
|
||||
return
|
||||
}
|
||||
connectivity = .unpaired
|
||||
sessions = []
|
||||
// Nothing left to dial — spin the embedded Tailscale node down if it was running.
|
||||
@@ -577,6 +682,8 @@ final class RemoteStore: ObservableObject {
|
||||
UserDefaults.standard.set(false, forKey: Self.demoModeKey)
|
||||
demoTasks.forEach { $0.cancel() }
|
||||
demoTasks.removeAll()
|
||||
demoHosts = []
|
||||
activeHostID = nil
|
||||
openSessionID = nil
|
||||
openEvents = []
|
||||
openApprovals = []
|
||||
@@ -816,9 +923,10 @@ final class RemoteStore: ObservableObject {
|
||||
case .setSessionShipBranch(let id, let branch):
|
||||
demoUpdateSession(id) { $0.demoCopy(shipBranch: .some(branch)) }
|
||||
case .hello, .listSessions, .listDashboard, .subscribe, .unsubscribe,
|
||||
.ping, .cancelQueuedMessage, .fetchDiff, .listPeers,
|
||||
// Mac↔Mac peer/transfer messages (mesh P4/P5) — the phone never sends these; no-op.
|
||||
.addressUpdate, .transferOffer, .transferChunk, .transferCommit, .transferCancel:
|
||||
.ping, .cancelQueuedMessage, .fetchDiff, .listPeers, .addressUpdate,
|
||||
// Session transfer (mesh P5) is a Mac↔Mac flow — the phone never originates these,
|
||||
// and demo has no peer Macs, so they're inert here.
|
||||
.transferOffer, .transferChunk, .transferCommit, .transferCancel:
|
||||
break // passive / already handled by the seeded fixtures (demo has no mesh peers)
|
||||
}
|
||||
}
|
||||
@@ -1074,6 +1182,10 @@ final class RemoteStore: ObservableObject {
|
||||
// Mesh P4: the host's known peers, for the (future) mesh device list and transfer
|
||||
// picker. Recorded now so multi-host UI can render it; single-host builds ignore it.
|
||||
meshPeers = peers
|
||||
case .transferAccept, .transferReject, .transferReady, .transferCommitted, .transferChunkAck:
|
||||
// Session-transfer replies (mesh P5) only reach a *source* Mac driving a transfer; a
|
||||
// phone is never a transfer source, so these are inert here.
|
||||
break
|
||||
case .wireError(let error):
|
||||
// A channel mismatch means the host and this remote were built from incompatible
|
||||
// release channels. Surface it as a persistent failure with a clear message rather
|
||||
@@ -1116,10 +1228,6 @@ final class RemoteStore: ObservableObject {
|
||||
}
|
||||
connectPlan = nil
|
||||
scheduleRetry()
|
||||
case .transferAccept, .transferReject, .transferReady, .transferCommitted, .transferChunkAck:
|
||||
// Mac↔Mac session transfer (mesh P5) events. The phone isn't a transfer participant,
|
||||
// so these are informational-only here — ignore them.
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1216,6 +1324,7 @@ extension WireSessionSummary {
|
||||
auto: auto ?? self.auto, autoShip: autoShip ?? self.autoShip,
|
||||
shipBranch: shipBranch ?? self.shipBranch,
|
||||
contextInputTokens: contextInputTokens,
|
||||
updatedAt: updatedAt ?? Date())
|
||||
updatedAt: updatedAt ?? Date(),
|
||||
movedTo: movedTo, arrivedFrom: arrivedFrom)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,6 +79,27 @@ struct SessionsView: View {
|
||||
// A notification tap while this tab is already up.
|
||||
.onChange(of: store.pendingRoute) { consumeRoute() }
|
||||
.toolbar {
|
||||
// Host switcher (mesh P3): pick which paired Mac to view. Hidden with a single Mac.
|
||||
ToolbarItem(placement: .topBarLeading) {
|
||||
if store.hostChoices.count > 1 {
|
||||
Menu {
|
||||
ForEach(store.hostChoices) { choice in
|
||||
Button {
|
||||
store.switchHost(to: choice.id)
|
||||
} label: {
|
||||
Label(choice.name,
|
||||
systemImage: choice.isActive ? "checkmark" : "desktopcomputer")
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "desktopcomputer")
|
||||
Text(store.hostName).lineLimit(1)
|
||||
Image(systemName: "chevron.down").font(.caption2)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button {
|
||||
showArchived.toggle()
|
||||
@@ -125,18 +146,32 @@ struct SessionRow: View {
|
||||
}
|
||||
Text(summary.title).font(.body.weight(.medium)).lineLimit(1)
|
||||
// The same trailing markers the Mac sidebar row carries: auto-approval
|
||||
// bolt, Orchestra note, autoship box.
|
||||
if summary.auto {
|
||||
Image(systemName: "bolt.fill").font(.caption2).foregroundStyle(Palette.accent)
|
||||
}
|
||||
if orchestra {
|
||||
Image(systemName: "music.note.list").font(.caption2).foregroundStyle(Palette.orchestra)
|
||||
}
|
||||
if summary.autoShip {
|
||||
Image(systemName: "shippingbox.fill").font(.caption2).foregroundStyle(Palette.accent)
|
||||
// bolt, Orchestra note, autoship box. Suppressed on a moved-away tombstone.
|
||||
if summary.movedTo == nil {
|
||||
if summary.auto {
|
||||
Image(systemName: "bolt.fill").font(.caption2).foregroundStyle(Palette.accent)
|
||||
}
|
||||
if orchestra {
|
||||
Image(systemName: "music.note.list").font(.caption2).foregroundStyle(Palette.orchestra)
|
||||
}
|
||||
if summary.autoShip {
|
||||
Image(systemName: "shippingbox.fill").font(.caption2).foregroundStyle(Palette.accent)
|
||||
}
|
||||
}
|
||||
}
|
||||
if showProjectName {
|
||||
if let moved = summary.movedTo {
|
||||
// Moved to another Mac (mesh P5): the name is baked by the host, so the phone
|
||||
// renders it directly without needing to know that Mac itself.
|
||||
Label("Moved to \(moved.deviceName)", systemImage: "arrow.up.forward")
|
||||
.font(.caption).foregroundStyle(.secondary).lineLimit(1)
|
||||
} else if let arrived = summary.arrivedFrom {
|
||||
// Arrived from another Mac (mesh P5) — subtle provenance, with the project name
|
||||
// folded in when it would otherwise show.
|
||||
Label(showProjectName ? "\(summary.projectName) · from \(arrived.deviceName)"
|
||||
: "from \(arrived.deviceName)",
|
||||
systemImage: "arrow.down.forward")
|
||||
.font(.caption).foregroundStyle(.secondary).lineLimit(1)
|
||||
} else if showProjectName {
|
||||
Text(summary.projectName).font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@ struct SettingsView: View {
|
||||
@EnvironmentObject var store: RemoteStore
|
||||
@State private var showScanner = false
|
||||
@State private var showManualPair = false
|
||||
/// Bumped after removing a paired Mac so the "Paired Macs" list re-reads the registry (mesh P3).
|
||||
@State private var pairedHostsToken = UUID()
|
||||
@State private var tailscaleAuthKey: String = TailnetAuthStore.loadAuthKey() ?? ""
|
||||
@FocusState private var tailscaleKeyFocused: Bool
|
||||
@AppStorage("nucleic.showRawEvents") private var showRaw = false
|
||||
@@ -66,6 +68,42 @@ struct SettingsView: View {
|
||||
.disabled(!store.isPaired)
|
||||
}
|
||||
|
||||
// Mesh P3: every Mac this phone is paired with. Today the connection uses the
|
||||
// "Active" one (most-recently paired); the multiplexer will connect to all. Removing
|
||||
// the active Mac unpairs the live connection; removing another just forgets it.
|
||||
let pairedHosts = IdentityStore.pairedHosts()
|
||||
if !pairedHosts.isEmpty {
|
||||
Section("Paired Macs") {
|
||||
ForEach(pairedHosts, id: \.fingerprint) { host in
|
||||
let isActive = host.fingerprint == pairedHosts.last?.fingerprint
|
||||
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()
|
||||
if isActive {
|
||||
Text("Active").font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
Button(role: .destructive) {
|
||||
if isActive {
|
||||
store.unpair()
|
||||
} else {
|
||||
IdentityStore.removePairedHost(id: host.fingerprint)
|
||||
}
|
||||
pairedHostsToken = UUID()
|
||||
} label: {
|
||||
Image(systemName: "minus.circle")
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
}
|
||||
}
|
||||
}
|
||||
.id(pairedHostsToken)
|
||||
}
|
||||
|
||||
Section {
|
||||
if TailnetSupport.isBuiltIn {
|
||||
// Commit on editing end, not per keystroke — a per-change save would
|
||||
|
||||
Reference in New Issue
Block a user