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:
2026-07-04 17:14:30 -07:00
co-authored by Claude Opus 4.8
parent ac379a0523
commit 630380085e
2 changed files with 97 additions and 7 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
}
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