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.
|
/// (UserDefaults). The identity is generated once on first launch and reused thereafter.
|
||||||
enum IdentityStore {
|
enum IdentityStore {
|
||||||
private static let keychainAccount = "xyz.blakeslee.nucleic.remote.identity"
|
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"
|
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"
|
private static let deviceIDKey = "nucleic.deviceID"
|
||||||
|
|
||||||
static func loadOrCreateIdentity() -> DeviceIdentity {
|
static func loadOrCreateIdentity() -> DeviceIdentity {
|
||||||
@@ -61,19 +66,66 @@ enum IdentityStore {
|
|||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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 []
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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? {
|
static func loadPairedHost() -> PairedHost? {
|
||||||
guard let data = UserDefaults.standard.data(forKey: pairedHostKey) else { return nil }
|
pairedHosts().last
|
||||||
return try? JSONDecoder().decode(PairedHost.self, from: data)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Pair (or re-pair) a host and make it active.
|
||||||
static func savePairedHost(_ host: PairedHost) {
|
static func savePairedHost(_ host: PairedHost) {
|
||||||
if let data = try? JSONEncoder().encode(host) {
|
upsertPairedHost(host)
|
||||||
UserDefaults.standard.set(data, forKey: pairedHostKey)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Unpair the active host (the one the single connection currently uses).
|
||||||
static func clearPairedHost() {
|
static func clearPairedHost() {
|
||||||
UserDefaults.standard.removeObject(forKey: pairedHostKey)
|
if let active = pairedHosts().last { removePairedHost(id: active.fingerprint) }
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Keychain
|
// MARK: - Keychain
|
||||||
|
|||||||
@@ -39,6 +39,10 @@ final class RemoteStore: ObservableObject {
|
|||||||
|
|
||||||
@Published private(set) var connectivity: Connectivity = .unpaired
|
@Published private(set) var connectivity: Connectivity = .unpaired
|
||||||
@Published private(set) var hostName: String = ""
|
@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 sessions: [WireSessionSummary] = []
|
||||||
@Published private(set) var capabilities = WireCapabilities(canModifyToolInput: false, allowAlwaysScopes: [])
|
@Published private(set) var capabilities = WireCapabilities(canModifyToolInput: false, allowAlwaysScopes: [])
|
||||||
@Published private(set) var grantedScope: DeviceScope = .approve
|
@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()`.
|
/// In-flight simulated-run tasks (canned streaming), cancelled on `exitDemo()`.
|
||||||
private var demoTasks: [Task<Void, Never>] = []
|
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 isPaired: Bool { demoMode || IdentityStore.loadPairedHost() != nil }
|
||||||
var deviceFingerprint: String { identity.fingerprint }
|
var deviceFingerprint: String { identity.fingerprint }
|
||||||
|
|
||||||
@@ -226,13 +296,6 @@ final class RemoteStore: ObservableObject {
|
|||||||
pendingApprovalCount: approvals, favorite: fav, archived: arch,
|
pendingApprovalCount: approvals, favorite: fav, archived: arch,
|
||||||
updatedAt: Date())
|
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 cal = Calendar.current
|
||||||
let today = cal.startOfDay(for: Date())
|
let today = cal.startOfDay(for: Date())
|
||||||
let activity = (0..<40).map { i -> ActivityDay in
|
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)!,
|
return ActivityDay(day: cal.date(byAdding: .day, value: -i, to: today)!,
|
||||||
count: count, tokens: count * 8_500 + (i * 137) % 4_000)
|
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(
|
counts: DashboardCounts(
|
||||||
projects: 2, chats: 5, activeChats: 2, messages: 142, activeDays: 9, tokens: 1_284_000),
|
projects: 2, chats: 5, activeChats: 2, messages: 142, activeDays: 9, tokens: 1_284_000),
|
||||||
activity: activity,
|
activity: activity,
|
||||||
@@ -272,7 +343,32 @@ final class RemoteStore: ObservableObject {
|
|||||||
]),
|
]),
|
||||||
WireStatusFeed(provider: "xai", providerName: "xAI", incidents: []),
|
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.
|
/// Offline diff fixture (NUCLEIC_DEMO) so the full-patch Diff tab renders without a host.
|
||||||
@@ -338,6 +434,7 @@ final class RemoteStore: ObservableObject {
|
|||||||
teardown()
|
teardown()
|
||||||
connectivity = .connecting
|
connectivity = .connecting
|
||||||
hostName = payload.hostName
|
hostName = payload.hostName
|
||||||
|
activeHostID = payload.hostStaticKey.fingerprintHex
|
||||||
guard let hint = payload.transportHint else {
|
guard let hint = payload.transportHint else {
|
||||||
connectivity = .failed("This pairing code needs a newer version of Nucleic Remote.")
|
connectivity = .failed("This pairing code needs a newer version of Nucleic Remote.")
|
||||||
return
|
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
|
/// 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.
|
/// Wi‑Fi rolls over to the tailnet and rolls back when it returns.
|
||||||
func reconnect() {
|
func reconnect() {
|
||||||
guard let host = IdentityStore.loadPairedHost() else { connectivity = .unpaired; return }
|
guard let host = activeHost() else { connectivity = .unpaired; return }
|
||||||
teardown()
|
teardown()
|
||||||
|
activeHostID = host.fingerprint
|
||||||
connectivity = reconnectAttempts == 0 ? .connecting : .reconnecting
|
connectivity = reconnectAttempts == 0 ? .connecting : .reconnecting
|
||||||
hostName = host.hostName
|
hostName = host.hostName
|
||||||
guard host.transportHint != .relay else {
|
guard host.transportHint != .relay else {
|
||||||
@@ -549,7 +647,14 @@ final class RemoteStore: ObservableObject {
|
|||||||
|
|
||||||
func unpair() {
|
func unpair() {
|
||||||
teardown()
|
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
|
connectivity = .unpaired
|
||||||
sessions = []
|
sessions = []
|
||||||
// Nothing left to dial — spin the embedded Tailscale node down if it was running.
|
// 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)
|
UserDefaults.standard.set(false, forKey: Self.demoModeKey)
|
||||||
demoTasks.forEach { $0.cancel() }
|
demoTasks.forEach { $0.cancel() }
|
||||||
demoTasks.removeAll()
|
demoTasks.removeAll()
|
||||||
|
demoHosts = []
|
||||||
|
activeHostID = nil
|
||||||
openSessionID = nil
|
openSessionID = nil
|
||||||
openEvents = []
|
openEvents = []
|
||||||
openApprovals = []
|
openApprovals = []
|
||||||
@@ -816,9 +923,10 @@ final class RemoteStore: ObservableObject {
|
|||||||
case .setSessionShipBranch(let id, let branch):
|
case .setSessionShipBranch(let id, let branch):
|
||||||
demoUpdateSession(id) { $0.demoCopy(shipBranch: .some(branch)) }
|
demoUpdateSession(id) { $0.demoCopy(shipBranch: .some(branch)) }
|
||||||
case .hello, .listSessions, .listDashboard, .subscribe, .unsubscribe,
|
case .hello, .listSessions, .listDashboard, .subscribe, .unsubscribe,
|
||||||
.ping, .cancelQueuedMessage, .fetchDiff, .listPeers,
|
.ping, .cancelQueuedMessage, .fetchDiff, .listPeers, .addressUpdate,
|
||||||
// Mac↔Mac peer/transfer messages (mesh P4/P5) — the phone never sends these; no-op.
|
// Session transfer (mesh P5) is a Mac↔Mac flow — the phone never originates these,
|
||||||
.addressUpdate, .transferOffer, .transferChunk, .transferCommit, .transferCancel:
|
// 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)
|
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
|
// 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.
|
// picker. Recorded now so multi-host UI can render it; single-host builds ignore it.
|
||||||
meshPeers = peers
|
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):
|
case .wireError(let error):
|
||||||
// A channel mismatch means the host and this remote were built from incompatible
|
// 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
|
// release channels. Surface it as a persistent failure with a clear message rather
|
||||||
@@ -1116,10 +1228,6 @@ final class RemoteStore: ObservableObject {
|
|||||||
}
|
}
|
||||||
connectPlan = nil
|
connectPlan = nil
|
||||||
scheduleRetry()
|
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,
|
auto: auto ?? self.auto, autoShip: autoShip ?? self.autoShip,
|
||||||
shipBranch: shipBranch ?? self.shipBranch,
|
shipBranch: shipBranch ?? self.shipBranch,
|
||||||
contextInputTokens: contextInputTokens,
|
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.
|
// A notification tap while this tab is already up.
|
||||||
.onChange(of: store.pendingRoute) { consumeRoute() }
|
.onChange(of: store.pendingRoute) { consumeRoute() }
|
||||||
.toolbar {
|
.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) {
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
Button {
|
Button {
|
||||||
showArchived.toggle()
|
showArchived.toggle()
|
||||||
@@ -125,7 +146,8 @@ struct SessionRow: View {
|
|||||||
}
|
}
|
||||||
Text(summary.title).font(.body.weight(.medium)).lineLimit(1)
|
Text(summary.title).font(.body.weight(.medium)).lineLimit(1)
|
||||||
// The same trailing markers the Mac sidebar row carries: auto-approval
|
// The same trailing markers the Mac sidebar row carries: auto-approval
|
||||||
// bolt, Orchestra note, autoship box.
|
// bolt, Orchestra note, autoship box. Suppressed on a moved-away tombstone.
|
||||||
|
if summary.movedTo == nil {
|
||||||
if summary.auto {
|
if summary.auto {
|
||||||
Image(systemName: "bolt.fill").font(.caption2).foregroundStyle(Palette.accent)
|
Image(systemName: "bolt.fill").font(.caption2).foregroundStyle(Palette.accent)
|
||||||
}
|
}
|
||||||
@@ -136,7 +158,20 @@ struct SessionRow: View {
|
|||||||
Image(systemName: "shippingbox.fill").font(.caption2).foregroundStyle(Palette.accent)
|
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)
|
Text(summary.projectName).font(.caption).foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ struct SettingsView: View {
|
|||||||
@EnvironmentObject var store: RemoteStore
|
@EnvironmentObject var store: RemoteStore
|
||||||
@State private var showScanner = false
|
@State private var showScanner = false
|
||||||
@State private var showManualPair = 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() ?? ""
|
@State private var tailscaleAuthKey: String = TailnetAuthStore.loadAuthKey() ?? ""
|
||||||
@FocusState private var tailscaleKeyFocused: Bool
|
@FocusState private var tailscaleKeyFocused: Bool
|
||||||
@AppStorage("nucleic.showRawEvents") private var showRaw = false
|
@AppStorage("nucleic.showRawEvents") private var showRaw = false
|
||||||
@@ -66,6 +68,42 @@ struct SettingsView: View {
|
|||||||
.disabled(!store.isPaired)
|
.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 {
|
Section {
|
||||||
if TailnetSupport.isBuiltIn {
|
if TailnetSupport.isBuiltIn {
|
||||||
// Commit on editing end, not per keystroke — a per-change save would
|
// Commit on editing end, not per keystroke — a per-change save would
|
||||||
|
|||||||
Reference in New Issue
Block a user