Merge branch 'dev' into nucleic/trunk

This commit is contained in:
2026-07-04 22:43:59 -07:00
3 changed files with 87 additions and 16 deletions
@@ -83,6 +83,9 @@ final class HostConnection {
private enum TransportAttempt {
case lan(NWEndpoint)
case tailnet(host: String, port: UInt16)
/// Nucleic Private Relay (mesh P2) — always the last candidate: works from anywhere,
/// but a direct path beats a brokered one when both exist.
case relay(base: URL, membershipToken: String)
}
private struct ConnectPlan {
@@ -118,14 +121,11 @@ final class HostConnection {
fail("This pairing code needs a newer version of Nucleic Remote.")
return
}
guard hint != .relay else {
fail("Relay connections aren't supported yet.")
return
}
let candidates = buildCandidates(
fingerprint: payload.hostStaticKey.fingerprintHex,
lanHost: payload.lanHost, lanPort: payload.lanPort,
tailnet: hint == .tailnet ? (payload.tailnetHost, payload.tailnetPort) : nil)
tailnet: hint == .tailnet ? (payload.tailnetHost, payload.tailnetPort) : nil,
relay: (payload.relayMembershipToken, payload.relayURL))
guard !candidates.isEmpty else {
fail(hint == .tailnet && !TailnetSupport.isBuiltIn
? TailnetError.notBuiltIn.errorDescription ?? "Tailscale support isn't built in"
@@ -146,14 +146,11 @@ final class HostConnection {
connectivity = reconnectAttempts == 0 ? .connecting : .reconnecting
hostName = host.hostName
callbacks.didUpdate()
guard host.transportHint != .relay else {
fail("Relay connections aren't supported yet.")
return
}
let candidates = buildCandidates(
fingerprint: host.fingerprint,
lanHost: host.lanHost, lanPort: host.lanPort,
tailnet: host.transportHint == .tailnet ? (host.tailnetHost, host.tailnetPort) : nil)
tailnet: host.transportHint == .tailnet ? (host.tailnetHost, host.tailnetPort) : nil,
relay: (host.relayMembershipToken, host.relayURL))
guard !candidates.isEmpty else {
connectivity = .hostOffline
callbacks.didUpdate()
@@ -176,7 +173,8 @@ final class HostConnection {
private func buildCandidates(
fingerprint: String?, lanHost: String?, lanPort: UInt16?,
tailnet: (host: String?, port: UInt16?)?
tailnet: (host: String?, port: UInt16?)?,
relay: (membershipToken: String?, url: String?)? = nil
) -> [TransportAttempt] {
var candidates: [TransportAttempt] = []
if let endpoint = discovery.endpoint(forFingerprint: fingerprint, lanHost: lanHost, lanPort: lanPort) {
@@ -185,6 +183,9 @@ final class HostConnection {
if let tailnet, let host = tailnet.host, let port = tailnet.port, TailnetSupport.isBuiltIn {
candidates.append(.tailnet(host: host, port: port))
}
if let relay, let token = relay.membershipToken, !token.isEmpty {
candidates.append(.relay(base: RelayAPI.baseURL(relay.url), membershipToken: token))
}
return candidates
}
@@ -223,13 +224,39 @@ final class HostConnection {
mode: plan.mode, deviceID: plan.deviceID, pairingPayload: plan.pairingPayload)
} catch {
guard !Task.isCancelled else { return }
self.tailnetAttemptFailed(error, isPairing: plan.pairingPayload != nil)
self.asyncAttemptFailed(error, isPairing: plan.pairingPayload != nil)
}
}
case .relay(let base, let membershipToken):
activeTransport = .relay
connectTask = Task { [weak self] in
guard let self else { return }
do {
let channel = try await RelayFrameChannel.dial(
base: base, membershipToken: membershipToken)
guard !Task.isCancelled else { channel.close(); return }
// A room with no live host swallows frames silently until presence says
// otherwise — bound the handshake like the LAN path does.
self.lanConnectTimeout?.cancel()
self.lanConnectTimeout = Task { [weak self, weak channel] in
try? await Task.sleep(for: .seconds(10))
guard !Task.isCancelled, let self, !self.connectivity.isLive else { return }
channel?.close()
}
self.startClient(
channel: channel, hostStaticKey: plan.hostStaticKey,
mode: plan.mode, deviceID: plan.deviceID, pairingPayload: plan.pairingPayload)
} catch {
guard !Task.isCancelled else { return }
self.asyncAttemptFailed(error, isPairing: plan.pairingPayload != nil)
}
}
}
}
private func tailnetAttemptFailed(_ error: Error, isPairing: Bool) {
/// A candidate that dials asynchronously (tailnet, relay) failed before producing a
/// channel — fall through to the next candidate or schedule a retry.
private func asyncAttemptFailed(_ error: Error, isPairing: Bool) {
if tryNextCandidate() { return }
if isPairing {
fail(error.localizedDescription)
@@ -347,7 +374,10 @@ final class HostConnection {
hostStaticKey: hostKey, fingerprint: hostKey.fingerprintHex,
lanHost: payload.lanHost, lanPort: payload.lanPort,
transport: payload.transport, tailnetHost: payload.tailnetHost,
tailnetPort: payload.tailnetPort))
tailnetPort: payload.tailnetPort,
relayRoomID: payload.relayRoomID,
relayMembershipToken: payload.relayMembershipToken,
relayURL: payload.relayURL))
}
callbacks.didUpdate()
send(.listSessions)
@@ -394,6 +424,21 @@ final class HostConnection {
case .transferAccept, .transferReject, .transferReady, .transferCommitted, .transferChunkAck:
// Session-transfer replies (mesh P5) only reach a *source* Mac; a phone is never one.
break
case .relayMembership(let membership):
// The host issued/refreshed this device's relay credential (mesh P2). Persist it
// in place (no reordering — this can arrive from a non-active Mac) so the relay
// is a dial candidate on the next reconnect; also patch the in-memory pin so an
// imminent retry uses the fresh token without a registry round-trip.
IdentityStore.updatePairedHost(id: hostID) {
$0.relayRoomID = membership.roomID
$0.relayMembershipToken = membership.token
$0.relayURL = membership.url
}
if pinnedHost?.fingerprint == hostID {
pinnedHost?.relayRoomID = membership.roomID
pinnedHost?.relayMembershipToken = membership.token
pinnedHost?.relayURL = membership.url
}
case .wireError(let error):
if error.code == .channelMismatch {
connectivity = .failed(error.message)
@@ -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)
@@ -60,7 +60,7 @@ struct SettingsView: View {
}
// What this pairing can dial — the live transport is in Status above.
if let host = IdentityStore.loadPairedHost() {
LabeledContent("Transports", value: host.tailnetHost != nil ? "LAN + Tailnet" : "LAN")
LabeledContent("Transports", value: Self.transportsLabel(host))
}
Button("Reconnect") { store.reconnect() }
.disabled(!store.isPaired)
@@ -175,7 +175,7 @@ struct SettingsView: View {
}
}
} footer: {
Text("Code and diffs are end-to-end encrypted. A relay (later) can't read them.")
Text("Code and diffs are end-to-end encrypted. The Nucleic Private Relay can't read them.")
}
}
.navigationTitle("Settings")
@@ -193,6 +193,15 @@ struct SettingsView: View {
}
}
}
/// Every path this pairing can dial: LAN always, plus what the host advertised — a
/// tailnet hint from the QR and/or a relay membership (QR or the host's later push).
private static func transportsLabel(_ host: PairedHost) -> String {
var parts = ["LAN"]
if host.tailnetHost != nil { parts.append("Tailnet") }
if host.relayMembershipToken?.isEmpty == false { parts.append("Relay") }
return parts.joined(separator: " + ")
}
}
/// Shown before pairing (UX_IOS §7): explain the flow and launch the scanner.