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:
2026-07-04 17:49:10 -07:00
4 changed files with 270 additions and 36 deletions
@@ -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
}
// 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? {
guard let data = UserDefaults.standard.data(forKey: pairedHostKey) else { return nil }
return try? JSONDecoder().decode(PairedHost.self, from: data)
pairedHosts().last
}
/// Pair (or re-pair) a host and make it active.
static func savePairedHost(_ host: PairedHost) {
if let data = try? JSONEncoder().encode(host) {
UserDefaults.standard.set(data, forKey: pairedHostKey)
}
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,7 +146,8 @@ 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.
// 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)
}
@@ -136,7 +158,20 @@ struct SessionRow: View {
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