From 630380085ec58234265e00e0e811d9f53a907f31 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Sat, 4 Jul 2026 17:14:30 -0700 Subject: [PATCH] Mesh P3 (foundation): iOS paired-host registry + migration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First step of Phase 3 (iOS multi-host), per docs/MESH_TRANSFER.md. Converts IdentityStore from a single `nucleic.pairedHost` slot to a `nucleic.pairedHosts` registry (ordered [PairedHost], keyed by fingerprint = hostID), with a one-time migration of the legacy single value on first read (then the old key is removed) so an upgrade keeps its Mac. New registry API — pairedHosts(), pairedHost(id:), upsertPairedHost(_:), removePairedHost(id:) — for the coming RemoteStore multiplexer. A single-host bridge keeps every current caller unchanged and behavior identical: loadPairedHost() returns the active (most-recently-paired) host, savePairedHost upserts + makes active, clearPairedHost removes the active. RemoteStore is untouched and still holds one connection. Settings gains a "Paired Macs" list (the visible artifact): each registered Mac with its fingerprint, an "Active" marker, and a per-host remove (removing the active one unpairs the live connection; removing another just forgets it) — this also gives a removal path now that pairing a new Mac keeps the old one in the registry instead of overwriting it. Verified with an iOS Simulator build (BUILD SUCCEEDED). Remaining Phase 3: the RemoteStore [HostID: HostConnection] multiplexer (simultaneous connections, (hostID, sessionID) keying), host-qualified notifications/Live Activity, host switcher UI, demo N-hosts, and the two-Mac tailnet spike. Co-Authored-By: Claude Opus 4.8 --- .../NucleicRemote/Models/IdentityStore.swift | 66 +++++++++++++++++-- .../NucleicRemote/Views/SettingsView.swift | 38 +++++++++++ 2 files changed, 97 insertions(+), 7 deletions(-) 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/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