diff --git a/NucleicRemote/NucleicRemote/Models/HostConnection.swift b/NucleicRemote/NucleicRemote/Models/HostConnection.swift index 8e13db1..967cbbf 100644 --- a/NucleicRemote/NucleicRemote/Models/HostConnection.swift +++ b/NucleicRemote/NucleicRemote/Models/HostConnection.swift @@ -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) diff --git a/NucleicRemote/NucleicRemote/Models/IdentityStore.swift b/NucleicRemote/NucleicRemote/Models/IdentityStore.swift index d7493fe..ecfbe49 100644 --- a/NucleicRemote/NucleicRemote/Models/IdentityStore.swift +++ b/NucleicRemote/NucleicRemote/Models/IdentityStore.swift @@ -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) diff --git a/NucleicRemote/NucleicRemote/Views/SettingsView.swift b/NucleicRemote/NucleicRemote/Views/SettingsView.swift index baf9980..8840575 100644 --- a/NucleicRemote/NucleicRemote/Views/SettingsView.swift +++ b/NucleicRemote/NucleicRemote/Views/SettingsView.swift @@ -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.