Mesh P3 (foundation): iOS paired-host registry + migration
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 <[email protected]>
This commit is contained in:
@@ -29,7 +29,12 @@ struct PairedHost: Codable, Equatable {
|
|||||||
/// (UserDefaults). The identity is generated once on first launch and reused thereafter.
|
/// (UserDefaults). The identity is generated once on first launch and reused thereafter.
|
||||||
enum IdentityStore {
|
enum IdentityStore {
|
||||||
private static let keychainAccount = "xyz.blakeslee.nucleic.remote.identity"
|
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"
|
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"
|
private static let deviceIDKey = "nucleic.deviceID"
|
||||||
|
|
||||||
static func loadOrCreateIdentity() -> DeviceIdentity {
|
static func loadOrCreateIdentity() -> DeviceIdentity {
|
||||||
@@ -61,19 +66,66 @@ enum IdentityStore {
|
|||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
static func loadPairedHost() -> PairedHost? {
|
// MARK: - Paired-host registry (mesh P3)
|
||||||
guard let data = UserDefaults.standard.data(forKey: pairedHostKey) else { return nil }
|
|
||||||
return try? JSONDecoder().decode(PairedHost.self, from: data)
|
/// 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) {
|
/// The one paired Mac with `hostID` (its `fingerprint`), if any.
|
||||||
if let data = try? JSONEncoder().encode(host) {
|
static func pairedHost(id hostID: String) -> PairedHost? {
|
||||||
UserDefaults.standard.set(data, forKey: pairedHostKey)
|
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() {
|
static func clearPairedHost() {
|
||||||
UserDefaults.standard.removeObject(forKey: pairedHostKey)
|
if let active = pairedHosts().last { removePairedHost(id: active.fingerprint) }
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Keychain
|
// MARK: - Keychain
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ struct SettingsView: View {
|
|||||||
@EnvironmentObject var store: RemoteStore
|
@EnvironmentObject var store: RemoteStore
|
||||||
@State private var showScanner = false
|
@State private var showScanner = false
|
||||||
@State private var showManualPair = 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() ?? ""
|
@State private var tailscaleAuthKey: String = TailnetAuthStore.loadAuthKey() ?? ""
|
||||||
@FocusState private var tailscaleKeyFocused: Bool
|
@FocusState private var tailscaleKeyFocused: Bool
|
||||||
@AppStorage("nucleic.showRawEvents") private var showRaw = false
|
@AppStorage("nucleic.showRawEvents") private var showRaw = false
|
||||||
@@ -66,6 +68,42 @@ struct SettingsView: View {
|
|||||||
.disabled(!store.isPaired)
|
.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 {
|
Section {
|
||||||
if TailnetSupport.isBuiltIn {
|
if TailnetSupport.isBuiltIn {
|
||||||
// Commit on editing end, not per keystroke — a per-change save would
|
// Commit on editing end, not per keystroke — a per-change save would
|
||||||
|
|||||||
Reference in New Issue
Block a user