Files
nucleic-remote-ios/NucleicRemote/NucleicRemote/Views/SettingsView.swift
T
abkslmandClaude Fable 5 57d0ccc5f6 ios: surface the browser-login step on the pairing intro screen
A first tailnet pairing with no auth key detours through a Tailscale browser
login that can take minutes. The intro screen — the only UI an unpaired phone
has — showed just "Connecting to your Mac…" for that whole window, and if
the user closed Safari there was no way back to the login page (the Settings
tab with the login link only exists once paired). The intro's pairing status
now explains the approval step and links to the login page whenever the
embedded node is waiting on one.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-03 16:25:27 -07:00

232 lines
11 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
struct SettingsView: View {
@EnvironmentObject var store: RemoteStore
@State private var showScanner = false
@State private var tailscaleAuthKey: String = TailnetAuthStore.loadAuthKey() ?? ""
@FocusState private var tailscaleKeyFocused: Bool
@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: host.tailnetHost != nil ? "LAN + Tailnet" : "LAN")
}
Button("Reconnect") { store.reconnect() }
.disabled(!store.isPaired)
}
Section {
if TailnetSupport.isBuiltIn {
// Commit on editing end, not per keystroke — a per-change save would
// clear the valid Keychain key on the first backspace of an edit.
SecureField("Auth key (tskey-auth-…)", text: $tailscaleAuthKey)
.autocorrectionDisabled()
.textInputAutocapitalization(.never)
.focused($tailscaleKeyFocused)
.onSubmit { TailnetAuthStore.saveAuthKey(tailscaleAuthKey) }
.onChange(of: tailscaleKeyFocused) {
if !tailscaleKeyFocused { TailnetAuthStore.saveAuthKey(tailscaleAuthKey) }
}
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("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 ▸ Connect via). Leave the key empty to approve this iPhone in your browser on first connect, or create an auth key in the Tailscale admin console (kept in the Keychain, used once to join your tailnet).")
}
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 {
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")
}
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. A relay (later) can't read them.")
}
}
.navigationTitle("Settings")
.sheet(isPresented: $showScanner) {
PairingScannerView { payload in
showScanner = false
store.pair(with: payload)
}
}
}
}
}
/// 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
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 iPhone to show a QR code, then scan it here.")
.multilineTextAlignment(.center)
.foregroundStyle(.secondary)
.padding(.horizontal, 32)
Button {
showScanner = true
} label: {
Label("Scan QR code", systemImage: "camera").font(.headline).padding(.horizontal)
}
.buttonStyle(.borderedProminent)
// 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)
}
}
}
/// 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()
}
}
}