Mesh P3: host switcher (switch active Mac) + 2-host demo
The safe, verifiable slice of iOS multi-host: switch which paired Mac the phone views, reusing the
existing (proven) single-connection reconnect — no rewrite of the connection state machine. The
simultaneous [HostID: HostConnection] multiplexer stays deferred (it needs two real Macs to verify).
RemoteStore: an `activeHostID` (the paired Mac the flat projection reflects); `hostChoices` (paired
registry live, mock hosts in demo); `switchHost(to:)` — live re-points via reconnect(), demo swaps
the mock host preserving in-demo edits; `reconnect()`/`pair()` set the active host; `unpair()` now
forgets the *active* Mac and switches to a remaining one if any (identical for a single host). Demo
seeds two mock Macs ("Andrew's Mac", "Studio Mac") with distinct sessions/dashboards.
SessionsView: a host-switcher menu in the toolbar, shown only when >1 Mac.
Verified in the iOS Simulator (demo mode): the switcher lists both Macs, and switching swaps the
whole session projection + the tab badge (screenshots taken). Single-host behavior is unchanged
(one host ⇒ switcher hidden ⇒ flat state exactly as before).
Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
@@ -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 = []
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user