import SwiftUI import CoreImage.CIFilterBuiltins 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 @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 { 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("") } 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 { Toggle("Share anonymous usage", isOn: $shareAnonymousUsage) } header: { Text("Privacy") } footer: { Text("") } 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: { Label("Join an Orbital mesh", 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("") } } .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) } } } } /// 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() } }