Nucleic-Session: FC94353F-9F0D-4BCC-AD7C-0FB14E38C2E5 Co-authored-by: Nucleic <[email protected]>
367 lines
17 KiB
Swift
367 lines
17 KiB
Swift
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)
|
||
// 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 — all connected at once and shown
|
||
// together in the app (no active-host selector). Removing one forgets just that Mac
|
||
// and drops its connection; the others keep running.
|
||
let pairedHosts = IdentityStore.pairedHosts()
|
||
if !pairedHosts.isEmpty {
|
||
Section("Mesh") {
|
||
ForEach(pairedHosts, id: \.fingerprint) { host in
|
||
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()
|
||
Button(role: .destructive) {
|
||
store.unpair(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("Join a Mac's group", 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("Join your Mac's group").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 join code beneath it manually. Scanning any Mac in the group connects you to all of them.")
|
||
.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("Join code")
|
||
} footer: {
|
||
Text("On your Mac, open Nucleic ▸ Settings ▸ Add device and copy the join 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("Join", 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()
|
||
}
|
||
}
|