From 7b7e13aa20b3534960f27082aa7cc27b071b11bf Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Sat, 4 Jul 2026 17:40:00 -0700 Subject: [PATCH] Mesh P3: host switcher (switch active Mac) + 2-host demo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../NucleicRemote/Models/RemoteStore.swift | 129 ++++++++++++++++-- .../NucleicRemote/Views/SessionsView.swift | 21 +++ 2 files changed, 139 insertions(+), 11 deletions(-) diff --git a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift index 0c1412b..64b4baf 100644 --- a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift +++ b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift @@ -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] = [] + // 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 = [] diff --git a/NucleicRemote/NucleicRemote/Views/SessionsView.swift b/NucleicRemote/NucleicRemote/Views/SessionsView.swift index e9c9d37..38be72b 100644 --- a/NucleicRemote/NucleicRemote/Views/SessionsView.swift +++ b/NucleicRemote/NucleicRemote/Views/SessionsView.swift @@ -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()