feat(relay): wire the live Nucleic Private Relay into the desktop + iOS apps (mesh P2 complete)

The relay Worker (nucleic-edge at relay.nucleic.blakeslee.xyz) is deployed, so land the
formerly deploy-gated client side of the data path:

- NucleicProtocol/Sync/RelayTransport.swift: RelayAPI (one base URL for REST + WS,
  membership -> connection token trade), RelayWebSocket (ordered sends, ping keepalive,
  ping-confirmed connect), RelayFrameChannel (client leg, WireFraming inside WS binary,
  presence fail-fast when the room has no host), RelayPresence.
- NucleicCore/Sync/RelayAccess.swift: X25519 PoP enrollment (RelayEnrollment), room
  credential in the login Keychain (separate from the push credential), membership minting
  with re-enroll-on-401.
- NucleicCore/Sync/RelayListener.swift: host SyncListener demuxing the room socket into
  per-tag virtual FrameChannels via RelayEnvelope; presence-driven reaping; backoff redial;
  injectable RelayRoomSocket seam for tests.
- Wire: additive HostMsg.relayMembership(WireRelayMembership) pushed after every hello
  (SyncHost.register -> AppStore mint) so devices paired before the relay adopt it and the
  ~90-day token refreshes on each connect; the pairing QR also carries a bootstrap
  membership so first contact can ride the relay. Old clients ignore the unknown tag.
- AppStore: .relay joins the listener composite behind the Connection-methods checkbox
  (failure degrades to a status row), advertises relayRoomID in PeerAddresses, mints the
  QR bootstrap in beginPairing.
- Desktop UI: the Nucleic Private Relay toggle is enabled (was "coming soon"); the
  LAN-only banner offers it alongside Tailnet.
- iOS: relay is the last dial candidate in HostConnection pair + reconnect (10s handshake
  watchdog); PairedHost persists relayRoomID/relayMembershipToken/relayURL; the
  relayMembership push updates the registry in place; Settings shows Relay in Transports.

Tests: RelayTransportTests, RelayListenerTests, SyncHostTests relay push + QR bootstrap.
Full suite green (783 core + 113 protocol + 2 new); iOS simulator build succeeds. Live
smoke test against the deployed Worker passed end-to-end (PoP enroll, both token tiers,
two-socket frame round-trip through the Room DO with correct envelope tags).

Known limits: host revoke-on-unpair not wired (endpoint is admin-only); PeerClient
(Mac<->Mac) doesn't dial the relay yet.

Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
2026-07-04 21:05:01 -07:00
co-authored by Claude Fable 5
parent b587702ff5
commit 3e25ef9559
3 changed files with 87 additions and 16 deletions
@@ -21,6 +21,13 @@ struct PairedHost: Codable, Equatable {
var transport: String?
var tailnetHost: String?
var tailnetPort: UInt16?
/// Nucleic Private Relay bootstrap (mesh P2): the host's room, this device's membership
/// token, and an optional base-URL override. Set from the pairing QR and refreshed by
/// the host's `relayMembership` push on every connect; nil while the host has the relay
/// method off (records predating the relay decode them as nil).
var relayRoomID: String?
var relayMembershipToken: String?
var relayURL: String?
var transportHint: SyncTransportHint { transport.flatMap(SyncTransportHint.init(rawValue:)) ?? .lan }
}
@@ -105,6 +112,16 @@ enum IdentityStore {
savePairedHosts(pairedHosts().filter { $0.fingerprint != hostID })
}
/// Mutate one host's record **in place**, preserving registry order — unlike
/// `upsertPairedHost`, this must not make the host active (it backs background
/// refreshes like the relay-membership push, which can arrive from a non-active Mac).
static func updatePairedHost(id hostID: String, mutate: (inout PairedHost) -> Void) {
var hosts = pairedHosts()
guard let index = hosts.firstIndex(where: { $0.fingerprint == hostID }) else { return }
mutate(&hosts[index])
savePairedHosts(hosts)
}
private static func savePairedHosts(_ hosts: [PairedHost]) {
if let data = try? JSONEncoder().encode(hosts) {
UserDefaults.standard.set(data, forKey: pairedHostsKey)