Files
nucleic-remote-ios/NucleicRemote/NucleicRemote/Views/SettingsView.swift
T

482 lines
21 KiB
Swift
Raw Normal View History

import SwiftUI
import CoreImage.CIFilterBuiltins
2026-06-26 15:07:58 -07:00
import NucleicProtocol
import NucleicTailnet
import UIKit
struct SettingsView: View {
@EnvironmentObject var store: RemoteStore
@State private var showScanner = false
@State private var showManualPair = false
@State private var showAddDevice = 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
2026-06-26 15:07:58 -07:00
@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("")
}
}
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("")
}
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("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("")
}
Section {
2026-06-26 15:08:00 -07:00
Toggle("Share anonymous usage", isOn: $shareAnonymousUsage)
} header: {
Text("Privacy")
} footer: {
Text("")
2026-06-26 15:08:00 -07:00
}
Section {
// Grow the mesh: ask a connected Mac to mint a join code this phone shows —
// scan it from a new iPhone/iPad, or copy the link to a new Mac. Only when a
// live Mac can mint one (the phone can't originate a join code on its own).
if store.canAddDeviceToMesh {
Button {
showAddDevice = true
} label: {
Label("Add a device to this mesh", systemImage: "plus.circle")
}
}
Button {
showScanner = true
} label: {
2026-07-11 03:23:21 -07:00
Label("Join a Carbon mesh", systemImage: "circle.dotted.and.circle")
}
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("")
}
}
.navigationTitle("Settings")
.compactTabBarInset()
.sheet(isPresented: $showScanner) {
PairingScannerView { payload in
showScanner = false
store.pair(with: payload)
}
}
.sheet(isPresented: $showManualPair) {
ManualPairingView { payload in
showManualPair = false
store.pair(with: payload)
}
}
.sheet(isPresented: $showAddDevice, onDismiss: { store.cancelPairingCode() }) {
AddDeviceView()
}
}
}
/// 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()
}
}
/// "Add a device to this mesh": the phone can't mint a join code itself (it runs no listener), so
/// it asks a connected Mac to open a pairing window and relays back that Mac's code — shown here as
/// a QR to scan from a new iPhone/iPad, or a copyable link to send to a new Mac. Scanning/pasting it
/// joins the new device to the whole group. Dismissing the sheet closes the Mac's pairing window
/// (`RemoteStore.cancelPairingCode`, wired to the sheet's `onDismiss`).
struct AddDeviceView: View {
@EnvironmentObject var store: RemoteStore
@Environment(\.dismiss) private var dismiss
@State private var copied = false
@State private var copyResetTask: Task<Void, Never>?
var body: some View {
NavigationStack {
Form {
switch store.addDevice {
case .idle, .requesting:
Section {
HStack {
ProgressView()
Text("Asking a Mac in your mesh for a join code…")
.foregroundStyle(.secondary)
}
}
case .unavailable:
Section {
Label("Couldn't get a join code", systemImage: "exclamationmark.triangle.fill")
.foregroundStyle(.orange)
Text("This needs a Mac in your mesh that's connected right now. Make sure "
+ "one is reachable with its meshing service on, then try again.")
.font(.callout).foregroundStyle(.secondary)
Button("Try again") { store.requestPairingCode() }
}
case .ready(let code):
codeSection(code)
}
}
.navigationTitle("Add a device")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .confirmationAction) { Button("Done") { dismiss() } }
}
// A Mac joining via this code needs the user's OK — the accepting Mac forwarded its
// confirm here so it can be approved from the phone (an iPhone/iPad joins hands-free).
.confirmationDialog(
"Let “\(store.pendingMacPairRequest?.label ?? "another Mac")” join?",
isPresented: Binding(
get: { store.pendingMacPairRequest != nil },
set: { if !$0 { store.respondMacPair(false) } }),
titleVisibility: .visible
) {
Button("Allow") { store.respondMacPair(true) }
Button("Don't allow", role: .destructive) { store.respondMacPair(false) }
Button("Cancel", role: .cancel) { store.respondMacPair(false) }
} message: {
Text("It joins the whole group — it gets the same control of your Macs' sessions an "
+ "iPhone does, and every Mac here stays connected.")
}
}
// Fire the request as the sheet appears; `onDismiss` (on the presenter) tells the Mac to
// close its pairing window.
.onAppear { if case .ready = store.addDevice {} else { store.requestPairingCode() } }
.onDisappear { copyResetTask?.cancel() }
}
@ViewBuilder
private func codeSection(_ code: String) -> some View {
Section {
if let image = Self.qrImage(code) {
HStack {
Spacer()
Image(uiImage: image)
.interpolation(.none)
.resizable()
.frame(width: 220, height: 220)
.padding(10)
.background(.white, in: RoundedRectangle(cornerRadius: 12))
Spacer()
}
.listRowBackground(Color.clear)
}
} header: {
Text("Scan to join")
} footer: {
2026-07-11 03:23:21 -07:00
Text("On a new iPhone or iPad, open Nucleic Remote ▸ Join a Carbon mesh and scan this. "
+ "It joins the whole group — every Mac and device here.")
}
Section {
Text(code)
.font(.system(.caption, design: .monospaced))
.lineLimit(2)
.truncationMode(.middle)
.textSelection(.enabled)
Button {
copyCode(code)
} label: {
Label(copied ? "Copied" : "Copy join code",
systemImage: copied ? "checkmark" : "doc.on.doc")
}
} header: {
Text("Or send a code")
} footer: {
Text("Paste into “Join mesh…” on another Mac, or “Enter code manually” on an iPhone or "
+ "iPad. A Mac joining asks you to approve it right here; an iPhone or iPad joins "
+ "on its own.")
}
}
private func copyCode(_ code: String) {
UIPasteboard.general.string = code
withAnimation(.easeOut(duration: 0.12)) { copied = true }
copyResetTask?.cancel()
copyResetTask = Task {
try? await Task.sleep(for: .seconds(1.4))
guard !Task.isCancelled else { return }
withAnimation(.easeIn(duration: 0.2)) { copied = false }
}
}
/// Render the join string as a QR code (CoreImage), mirroring the Mac's pairing sheet.
private static func qrImage(_ string: String) -> UIImage? {
let filter = CIFilter.qrCodeGenerator()
filter.message = Data(string.utf8)
filter.correctionLevel = "M"
guard let output = filter.outputImage else { return nil }
let scaled = output.transformed(by: CGAffineTransform(scaleX: 10, y: 10))
let context = CIContext()
guard let cg = context.createCGImage(scaled, from: scaled.extent) else { return nil }
return UIImage(cgImage: cg)
}
}