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