diff --git a/NucleicRemote/NucleicRemote/Models/IdentityStore.swift b/NucleicRemote/NucleicRemote/Models/IdentityStore.swift index 4203cd1..d7493fe 100644 --- a/NucleicRemote/NucleicRemote/Models/IdentityStore.swift +++ b/NucleicRemote/NucleicRemote/Models/IdentityStore.swift @@ -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 diff --git a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift index d965b8d..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 = [] @@ -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) } } diff --git a/NucleicRemote/NucleicRemote/Views/SessionsView.swift b/NucleicRemote/NucleicRemote/Views/SessionsView.swift index 277639e..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() @@ -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) } } diff --git a/NucleicRemote/NucleicRemote/Views/SettingsView.swift b/NucleicRemote/NucleicRemote/Views/SettingsView.swift index cfb2fbd..6c4224d 100644 --- a/NucleicRemote/NucleicRemote/Views/SettingsView.swift +++ b/NucleicRemote/NucleicRemote/Views/SettingsView.swift @@ -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