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 { private enum TransportAttempt {
case lan(NWEndpoint) case lan(NWEndpoint)
case tailnet(host: String, port: UInt16) 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 { private struct ConnectPlan {
@@ -118,14 +121,11 @@ final class HostConnection {
fail("This pairing code needs a newer version of Nucleic Remote.") fail("This pairing code needs a newer version of Nucleic Remote.")
return return
} }
guard hint != .relay else {
fail("Relay connections aren't supported yet.")
return
}
let candidates = buildCandidates( let candidates = buildCandidates(
fingerprint: payload.hostStaticKey.fingerprintHex, fingerprint: payload.hostStaticKey.fingerprintHex,
lanHost: payload.lanHost, lanPort: payload.lanPort, 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 { guard !candidates.isEmpty else {
fail(hint == .tailnet && !TailnetSupport.isBuiltIn fail(hint == .tailnet && !TailnetSupport.isBuiltIn
? TailnetError.notBuiltIn.errorDescription ?? "Tailscale support isn't built in" ? TailnetError.notBuiltIn.errorDescription ?? "Tailscale support isn't built in"
@@ -146,14 +146,11 @@ final class HostConnection {
connectivity = reconnectAttempts == 0 ? .connecting : .reconnecting connectivity = reconnectAttempts == 0 ? .connecting : .reconnecting
hostName = host.hostName hostName = host.hostName
callbacks.didUpdate() callbacks.didUpdate()
guard host.transportHint != .relay else {
fail("Relay connections aren't supported yet.")
return
}
let candidates = buildCandidates( let candidates = buildCandidates(
fingerprint: host.fingerprint, fingerprint: host.fingerprint,
lanHost: host.lanHost, lanPort: host.lanPort, 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 { guard !candidates.isEmpty else {
connectivity = .hostOffline connectivity = .hostOffline
callbacks.didUpdate() callbacks.didUpdate()
@@ -176,7 +173,8 @@ final class HostConnection {
private func buildCandidates( private func buildCandidates(
fingerprint: String?, lanHost: String?, lanPort: UInt16?, fingerprint: String?, lanHost: String?, lanPort: UInt16?,
tailnet: (host: String?, port: UInt16?)? tailnet: (host: String?, port: UInt16?)?,
relay: (membershipToken: String?, url: String?)? = nil
) -> [TransportAttempt] { ) -> [TransportAttempt] {
var candidates: [TransportAttempt] = [] var candidates: [TransportAttempt] = []
if let endpoint = discovery.endpoint(forFingerprint: fingerprint, lanHost: lanHost, lanPort: lanPort) { 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 { if let tailnet, let host = tailnet.host, let port = tailnet.port, TailnetSupport.isBuiltIn {
candidates.append(.tailnet(host: host, port: port)) 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 return candidates
} }
@@ -223,13 +224,39 @@ final class HostConnection {
mode: plan.mode, deviceID: plan.deviceID, pairingPayload: plan.pairingPayload) mode: plan.mode, deviceID: plan.deviceID, pairingPayload: plan.pairingPayload)
} catch { } catch {
guard !Task.isCancelled else { return } 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 tryNextCandidate() { return }
if isPairing { if isPairing {
fail(error.localizedDescription) fail(error.localizedDescription)
@@ -347,7 +374,10 @@ final class HostConnection {
hostStaticKey: hostKey, fingerprint: hostKey.fingerprintHex, hostStaticKey: hostKey, fingerprint: hostKey.fingerprintHex,
lanHost: payload.lanHost, lanPort: payload.lanPort, lanHost: payload.lanHost, lanPort: payload.lanPort,
transport: payload.transport, tailnetHost: payload.tailnetHost, transport: payload.transport, tailnetHost: payload.tailnetHost,
tailnetPort: payload.tailnetPort)) tailnetPort: payload.tailnetPort,
relayRoomID: payload.relayRoomID,
relayMembershipToken: payload.relayMembershipToken,
relayURL: payload.relayURL))
} }
callbacks.didUpdate() callbacks.didUpdate()
send(.listSessions) send(.listSessions)
@@ -394,6 +424,21 @@ final class HostConnection {
case .transferAccept, .transferReject, .transferReady, .transferCommitted, .transferChunkAck: case .transferAccept, .transferReject, .transferReady, .transferCommitted, .transferChunkAck:
// Session-transfer replies (mesh P5) only reach a *source* Mac; a phone is never one. // Session-transfer replies (mesh P5) only reach a *source* Mac; a phone is never one.
break 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): case .wireError(let error):
if error.code == .channelMismatch { if error.code == .channelMismatch {
connectivity = .failed(error.message) connectivity = .failed(error.message)
@@ -21,6 +21,13 @@ struct PairedHost: Codable, Equatable {
var transport: String? var transport: String?
var tailnetHost: String? var tailnetHost: String?
var tailnetPort: UInt16? 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 } var transportHint: SyncTransportHint { transport.flatMap(SyncTransportHint.init(rawValue:)) ?? .lan }
} }
@@ -105,6 +112,16 @@ enum IdentityStore {
savePairedHosts(pairedHosts().filter { $0.fingerprint != hostID }) 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]) { private static func savePairedHosts(_ hosts: [PairedHost]) {
if let data = try? JSONEncoder().encode(hosts) { if let data = try? JSONEncoder().encode(hosts) {
UserDefaults.standard.set(data, forKey: pairedHostsKey) 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. // What this pairing can dial — the live transport is in Status above.
if let host = IdentityStore.loadPairedHost() { if let host = IdentityStore.loadPairedHost() {
LabeledContent("Transports", value: host.tailnetHost != nil ? "LAN + Tailnet" : "LAN") LabeledContent("Transports", value: Self.transportsLabel(host))
} }
Button("Reconnect") { store.reconnect() } Button("Reconnect") { store.reconnect() }
.disabled(!store.isPaired) .disabled(!store.isPaired)
@@ -175,7 +175,7 @@ struct SettingsView: View {
} }
} }
} footer: { } 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") .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. /// Shown before pairing (UX_IOS §7): explain the flow and launch the scanner.