Files
nucleic-remote-ios/NucleicRemote/NucleicRemote/Views/SettingsView.swift
T
abkslmandClaude Fable 5 3e25ef9559 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]>
2026-07-04 21:05:01 -07:00

378 lines
18 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import SwiftUI
import NucleicProtocol
import NucleicTailnet
import UIKit
struct SettingsView: View {
@EnvironmentObject var store: RemoteStore
@State private var showScanner = false
@State private var showManualPair = false
/// Bumped after removing a paired Mac so the "Paired Macs" list re-reads the registry (mesh P3).
@State private var pairedHostsToken = UUID()
@AppStorage("nucleic.showRawEvents") private var showRaw = false
@AppStorage("nucleic.showLockEvents") private var showLockEvents = true
@AppStorage(HeartbeatSettings.shareAnonymousUsageKey) private var shareAnonymousUsage = true
@AppStorage(AppAppearance.storageKey) private var appearanceRaw = AppAppearance.system.rawValue
@AppStorage(AppTextSize.storageKey) private var textSizeRaw = AppTextSize.medium.rawValue
@AppStorage(ColorVisionMode.storageKey) private var colorVisionRaw = ColorVisionMode.standard.rawValue
var body: some View {
NavigationStack {
Form {
if store.demoMode {
Section {
Button(role: .destructive) { store.exitDemo() } label: {
Label("Leave demo", systemImage: "arrow.uturn.left")
}
} header: {
Text("Demo")
} footer: {
Text("You're exploring Nucleic with sample data and a simulated agent. Leave the demo to pair with your Mac.")
}
}
Section {
Picker("Theme", selection: $appearanceRaw) {
ForEach(AppAppearance.allCases) { appearance in
Text(appearance.label).tag(appearance.rawValue)
}
}
Picker("Text size", selection: $textSizeRaw) {
ForEach(AppTextSize.allCases) { size in
Text(size.label).tag(size.rawValue)
}
}
Picker("Color vision", selection: $colorVisionRaw) {
ForEach(ColorVisionMode.allCases) { mode in
Text(mode.label).tag(mode.rawValue)
}
}
} header: {
Text("Appearance")
} footer: {
Text("Color vision remaps status colors to hues that stay distinguishable. Every status also carries a text label, so color is never the only cue.")
}
Section("Connection") {
LabeledContent("Status", value: store.connectivity.label)
if !store.hostName.isEmpty {
LabeledContent("Mac", value: store.hostName)
}
// What this pairing can dial — the live transport is in Status above.
if let host = IdentityStore.loadPairedHost() {
LabeledContent("Transports", value: Self.transportsLabel(host))
}
Button("Reconnect") { store.reconnect() }
.disabled(!store.isPaired)
}
// Mesh P3: every Mac this phone is paired with. Today the connection uses the
// "Active" one (most-recently paired); the multiplexer will connect to all. Removing
// the active Mac unpairs the live connection; removing another just forgets it.
let pairedHosts = IdentityStore.pairedHosts()
if !pairedHosts.isEmpty {
Section("Paired Macs") {
ForEach(pairedHosts, id: \.fingerprint) { host in
let isActive = host.fingerprint == pairedHosts.last?.fingerprint
HStack(spacing: 10) {
Image(systemName: "desktopcomputer").foregroundStyle(.secondary)
VStack(alignment: .leading, spacing: 2) {
Text(host.hostName.isEmpty ? "Mac" : host.hostName)
Text(host.fingerprint.prefix(16) + "…")
.font(.footnote.monospaced()).foregroundStyle(.secondary)
}
Spacer()
if isActive {
Text("Active").font(.caption).foregroundStyle(.secondary)
}
Button(role: .destructive) {
if isActive {
store.unpair()
} else {
IdentityStore.removePairedHost(id: host.fingerprint)
}
pairedHostsToken = UUID()
} label: {
Image(systemName: "minus.circle")
}
.buttonStyle(.borderless)
}
}
}
.id(pairedHostsToken)
}
Section {
if TailnetSupport.isBuiltIn {
if let status = store.tailnetStatus {
LabeledContent("Node", value: status)
}
if let loginURL = store.tailnetLoginURL {
Link(destination: loginURL) {
Label("Open Tailscale login", systemImage: "arrow.up.forward.app")
}
} else {
Text("No setup needed — when your Mac shares over Tailscale, this iPhone joins your tailnet on first connect and opens a browser login to approve itself.")
.font(.caption).foregroundStyle(.secondary)
}
} else {
Text("This build doesn't include Tailscale support.")
.foregroundStyle(.secondary)
}
} header: {
Text("Tailscale")
} footer: {
Text("Needed only when your Mac shares over Tailscale (Mac ▸ Settings ▸ Remote ▸ Connection methods). This iPhone joins your tailnet in the browser on first connect.")
}
Section("This device") {
LabeledContent("Scope", value: store.grantedScope.rawValue.capitalized)
LabeledContent("Key fingerprint", value: store.deviceFingerprint)
.font(.footnote.monospaced())
}
Section {
Toggle("Show lock events", isOn: $showLockEvents)
Toggle("Show raw events", isOn: $showRaw)
} header: {
Text("Transcript")
} footer: {
Text("Raw events are unrecognized backend output, shown for debugging.")
}
Section {
Link(destination: URL(string: "https://nucleic.blakeslee.xyz/trusted-tester.html")!) {
Label("Apply to be a trusted tester", systemImage: "person.badge.shield.checkmark")
}
} header: {
Text("Trusted tester")
} footer: {
Text("This app ships through TestFlight. Apply for trusted-tester access to get earlier builds and shape where the app goes — we review your background and, if approved, add you to internal TestFlight.")
}
Section {
Toggle("Share anonymous usage", isOn: $shareAnonymousUsage)
} header: {
Text("Privacy")
} footer: {
Text("A once-a-day anonymous ping — a random install ID, app version, iOS version, and language — so we can count active installs. No account, no IP, no content.")
}
Section {
Button {
showScanner = true
} label: {
Label("Pair with a Mac", systemImage: "qrcode.viewfinder")
}
Button {
showManualPair = true
} label: {
Label("Enter code manually", systemImage: "keyboard")
}
if store.isPaired {
Button(role: .destructive) { store.unpair() } label: {
Label("Unpair this device", systemImage: "minus.circle")
}
}
} footer: {
Text("Code and diffs are end-to-end encrypted. The Nucleic Private Relay can't read them.")
}
}
.navigationTitle("Settings")
.sheet(isPresented: $showScanner) {
PairingScannerView { payload in
showScanner = false
store.pair(with: payload)
}
}
.sheet(isPresented: $showManualPair) {
ManualPairingView { payload in
showManualPair = false
store.pair(with: payload)
}
}
}
}
/// 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.
struct PairingIntroView: View {
@EnvironmentObject var store: RemoteStore
@State private var showScanner = false
@State private var showManualPair = false
var body: some View {
VStack(spacing: 24) {
Image(systemName: "qrcode.viewfinder").font(.system(size: 72)).foregroundStyle(.tint)
Text("Pair with your Mac").font(.title2.weight(.semibold))
Text("On your Mac, open Nucleic ▸ Settings ▸ Add device to show a QR code, then scan it here — or enter the pairing code beneath it manually.")
.multilineTextAlignment(.center)
.foregroundStyle(.secondary)
.padding(.horizontal, 32)
Button {
showScanner = true
} label: {
Label("Scan QR code", systemImage: "camera").font(.headline).padding(.horizontal)
}
.buttonStyle(.borderedProminent)
// A camera-free path — the iPad's scanner can't run while the app is mirrored to an
// external display or in some Stage Manager states; pasting the code always works.
Button {
showManualPair = true
} label: {
Label("Enter code manually", systemImage: "keyboard")
}
.buttonStyle(.bordered)
// Feedback after a scan: the whole UI is still the intro until pairing completes, so
// without this a failed or in-flight pair looks like "nothing happened."
pairingStatus
VStack(spacing: 6) {
Button { store.enterDemo() } label: {
Label("Explore a demo — no Mac needed", systemImage: "play.circle")
}
.buttonStyle(.bordered)
Text("Sample data and a simulated agent — no Mac or account required.")
.font(.footnote)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal, 32)
}
.padding(.top, 8)
}
.padding()
// Fill the available height so the enclosing VStack reaches full screen and the
// BuildBanner/DemoBanner strips stay pinned at the top. Without this the intro only
// claims its intrinsic height, so the stack gets centered and the banner drifts down.
.frame(maxWidth: .infinity, maxHeight: .infinity)
.sheet(isPresented: $showScanner) {
PairingScannerView { payload in
showScanner = false
store.pair(with: payload)
}
}
.sheet(isPresented: $showManualPair) {
ManualPairingView { payload in
showManualPair = false
store.pair(with: payload)
}
}
}
/// A status line reflecting an in-flight or failed pairing attempt. Silent in the resting
/// `.unpaired` state (the intro copy already covers that).
@ViewBuilder
private var pairingStatus: some View {
switch store.connectivity {
case .connecting, .reconnecting:
// A first tailnet pairing with no auth key detours through a browser login
// that can take minutes — and this intro is the ONLY screen an unpaired phone
// has (no Settings tab yet), so the way back to the login page must live here.
if let loginURL = store.tailnetLoginURL {
VStack(spacing: 6) {
Label("Approve this iPhone in your browser to join your tailnet.",
systemImage: "person.crop.circle.badge.checkmark")
.font(.footnote).foregroundStyle(.secondary)
.multilineTextAlignment(.center).padding(.horizontal, 32)
Link("Open the Tailscale login page", destination: loginURL)
.font(.footnote.weight(.medium))
}
} else {
Label("Connecting to your Mac…", systemImage: "arrow.triangle.2.circlepath")
.font(.footnote).foregroundStyle(.secondary)
}
case .hostOffline:
Label("Can't reach your Mac. Make sure it's on the same Wi‑Fi and remote access is on.",
systemImage: "wifi.exclamationmark")
.font(.footnote).foregroundStyle(.orange)
.multilineTextAlignment(.center).padding(.horizontal, 32)
case .failed(let message):
Label(message, systemImage: "exclamationmark.triangle.fill")
.font(.footnote).foregroundStyle(.red)
.multilineTextAlignment(.center).padding(.horizontal, 32)
case .connected, .unpaired:
EmptyView()
}
}
}
/// A camera-free pairing path (UX_IOS §7): paste the Mac's `nucleic://pair?d=…` code instead of
/// scanning its QR. The iPad's camera can be blocked while mirrored to an external display or in
/// some Stage Manager states, where the scanner's `couldNotStart` path leaves you stuck — this
/// always works. Parses with the same `PairingPayload(qrString:)` the scanner uses.
struct ManualPairingView: View {
let onEntered: (PairingPayload) -> Void
@Environment(\.dismiss) private var dismiss
@State private var code = ""
@State private var error: String?
@FocusState private var fieldFocused: Bool
var body: some View {
NavigationStack {
Form {
Section {
TextField("nucleic://pair?d=…", text: $code, axis: .vertical)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.keyboardType(.URL)
.font(.callout.monospaced())
.lineLimit(2...6)
.focused($fieldFocused)
.onChange(of: code) { error = nil }
} header: {
Text("Pairing code")
} footer: {
Text("On your Mac, open Nucleic ▸ Settings ▸ Add device and copy the pairing link shown beneath the QR code, then paste it here.")
}
if let error {
Label(error, systemImage: "exclamationmark.triangle.fill")
.font(.callout).foregroundStyle(.red)
}
Button {
submit()
} label: {
Label("Pair", systemImage: "link").frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.disabled(code.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
.keyboardShortcut(.defaultAction)
}
.navigationTitle("Enter pairing code")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) { Button("Cancel") { dismiss() } }
ToolbarItem(placement: .primaryAction) {
Button {
if let pasted = UIPasteboard.general.string { code = pasted }
} label: {
Label("Paste", systemImage: "doc.on.clipboard")
}
.disabled(!UIPasteboard.general.hasStrings)
}
}
.onAppear { fieldFocused = true }
}
}
private func submit() {
let trimmed = code.trimmingCharacters(in: .whitespacesAndNewlines)
guard let payload = try? PairingPayload(qrString: trimmed) else {
error = "That isn't a valid Nucleic pairing code. Copy the link beneath the QR on your Mac."
return
}
onEntered(payload)
dismiss()
}
}