2026-06-13 01:12:55 -07:00
import SwiftUI
2026-06-26 15:07:58 -07:00
import NucleicProtocol
2026-07-03 03:50:45 -07:00
import NucleicTailnet
2026-07-04 00:48:56 -07:00
import UIKit
2026-06-13 01:12:55 -07:00
struct SettingsView : View {
@ EnvironmentObject var store : RemoteStore
@ State private var showScanner = false
2026-07-04 00:48:56 -07:00
@ State private var showManualPair = false
2026-07-04 17:14:30 -07:00
/// Bumped after removing a paired Mac so the "Paired Macs" list re-reads the registry (mesh P3).
@ State private var pairedHostsToken = UUID ()
2026-06-21 04:03:53 -07:00
@ 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
2026-07-02 03:43:13 -07:00
@ 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
2026-06-13 01:12:55 -07:00
var body : some View {
NavigationStack {
Form {
2026-07-02 19:26:43 -07:00
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." )
}
}
2026-07-02 03:43:13 -07:00
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." )
}
2026-06-13 01:12:55 -07:00
Section ( "Connection" ) {
LabeledContent ( "Status" , value : store . connectivity . label )
if ! store . hostName . isEmpty {
LabeledContent ( "Mac" , value : store . hostName )
}
2026-07-03 16:14:45 -07:00
// What this pairing can dial — the live transport is in Status above.
if let host = IdentityStore . loadPairedHost () {
2026-07-04 21:05:01 -07:00
LabeledContent ( "Transports" , value : Self . transportsLabel ( host ))
2026-07-03 03:50:45 -07:00
}
2026-06-13 01:12:55 -07:00
Button ( "Reconnect" ) { store . reconnect () }
. disabled ( ! store . isPaired )
}
2026-07-04 17:14:30 -07:00
// 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 )
}
2026-07-03 03:50:45 -07:00
Section {
if TailnetSupport . isBuiltIn {
if let status = store . tailnetStatus {
LabeledContent ( "Node" , value : status )
}
2026-07-03 16:14:45 -07:00
if let loginURL = store . tailnetLoginURL {
Link ( destination : loginURL ) {
Label ( "Open Tailscale login" , systemImage : "arrow.up.forward.app" )
}
2026-07-04 18:37:15 -07:00
} 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 )
2026-07-03 16:14:45 -07:00
}
2026-07-03 03:50:45 -07:00
} else {
Text ( "This build doesn't include Tailscale support." )
. foregroundStyle (. secondary )
}
} header : {
Text ( "Tailscale" )
} footer : {
2026-07-04 18:37:15 -07:00
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." )
2026-07-03 03:50:45 -07:00
}
2026-06-13 01:12:55 -07:00
Section ( "This device" ) {
LabeledContent ( "Scope" , value : store . grantedScope . rawValue . capitalized )
LabeledContent ( "Key fingerprint" , value : store . deviceFingerprint )
. font (. footnote . monospaced ())
}
Section {
2026-06-21 04:03:53 -07:00
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." )
}
2026-07-03 17:40:28 -07:00
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." )
}
2026-06-21 04:03:53 -07:00
Section {
2026-06-26 15:08:00 -07:00
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 {
2026-06-13 01:12:55 -07:00
Button {
showScanner = true
} label : {
Label ( "Pair with a Mac" , systemImage : "qrcode.viewfinder" )
}
2026-07-04 00:48:56 -07:00
Button {
showManualPair = true
} label : {
Label ( "Enter code manually" , systemImage : "keyboard" )
}
2026-06-13 01:12:55 -07:00
if store . isPaired {
Button ( role : . destructive ) { store . unpair () } label : {
Label ( "Unpair this device" , systemImage : "minus.circle" )
}
}
} footer : {
2026-07-04 21:05:01 -07:00
Text ( "Code and diffs are end-to-end encrypted. The Nucleic Private Relay can't read them." )
2026-06-13 01:12:55 -07:00
}
}
. navigationTitle ( "Settings" )
. sheet ( isPresented : $ showScanner ) {
PairingScannerView { payload in
showScanner = false
store . pair ( with : payload )
}
}
2026-07-04 00:48:56 -07:00
. sheet ( isPresented : $ showManualPair ) {
ManualPairingView { payload in
showManualPair = false
store . pair ( with : payload )
}
}
2026-06-13 01:12:55 -07:00
}
}
2026-07-04 21:05:01 -07:00
/// 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 : " + " )
}
2026-06-13 01:12:55 -07:00
}
/// 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
2026-07-04 00:48:56 -07:00
@ State private var showManualPair = false
2026-06-13 01:12:55 -07:00
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 ))
2026-07-04 00:48:56 -07:00
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." )
2026-06-13 01:12:55 -07:00
. multilineTextAlignment (. center )
. foregroundStyle (. secondary )
. padding (. horizontal , 32 )
Button {
showScanner = true
} label : {
Label ( "Scan QR code" , systemImage : "camera" ). font (. headline ). padding (. horizontal )
}
. buttonStyle (. borderedProminent )
2026-07-02 19:26:43 -07:00
2026-07-04 00:48:56 -07:00
// 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 )
2026-07-03 01:42:40 -07:00
// 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
2026-07-02 19:26:43 -07:00
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 )
2026-06-13 01:12:55 -07:00
}
. padding ()
2026-07-02 19:34:09 -07:00
// 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 )
2026-06-13 01:12:55 -07:00
. sheet ( isPresented : $ showScanner ) {
PairingScannerView { payload in
showScanner = false
store . pair ( with : payload )
}
}
2026-07-04 00:48:56 -07:00
. sheet ( isPresented : $ showManualPair ) {
ManualPairingView { payload in
showManualPair = false
store . pair ( with : payload )
}
}
2026-06-13 01:12:55 -07:00
}
2026-07-03 01:42:40 -07:00
/// 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 :
2026-07-03 16:25:27 -07:00
// 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 )
}
2026-07-03 01:42:40 -07:00
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 ()
}
}
2026-06-13 01:12:55 -07:00
}
2026-07-04 00:48:56 -07:00
/// 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 ()
}
}