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 : {
2026-07-05 21:44:58 -07:00
Text ( "" )
2026-07-02 19:26:43 -07:00
}
}
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 : {
2026-07-05 21:44:58 -07:00
Text ( "" )
2026-07-02 03:43:13 -07:00
}
2026-06-13 01:12:55 -07:00
Section ( "Connection" ) {
LabeledContent ( "Status" , value : store . connectivity . label )
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-05 02:03:17 -07:00
// 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.
2026-07-04 17:14:30 -07:00
let pairedHosts = IdentityStore . pairedHosts ()
if ! pairedHosts . isEmpty {
2026-07-05 00:28:21 -07:00
Section ( "Mesh" ) {
2026-07-04 17:14:30 -07:00
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 ) {
2026-07-05 02:03:17 -07:00
store . unpair ( host . fingerprint )
2026-07-04 17:14:30 -07:00
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-05 21:44:58 -07:00
Text ( "" )
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 : {
2026-07-05 21:44:58 -07:00
Text ( "" )
2026-06-21 04:03:53 -07:00
}
2026-07-03 17:40:28 -07:00
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 : {
2026-07-05 21:44:58 -07:00
Text ( "" )
2026-06-26 15:08:00 -07:00
}
Section {
2026-06-13 01:12:55 -07:00
Button {
showScanner = true
} label : {
2026-07-05 00:28:21 -07:00
Label ( "Join a Mac's group" , systemImage : "qrcode.viewfinder" )
2026-06-13 01:12:55 -07:00
}
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-05 21:44:58 -07:00
Text ( "" )
2026-06-13 01:12:55 -07:00
}
}
. navigationTitle ( "Settings" )
nvrsion: Add: Resolve conflict, reapply tab bar load animation fixes, refactor chat tool call card alignment, fix historical transcript data fetching, fix freezing during exit, refine modal design, adjust spacing, hide bottom tab bar on sessions, adjust “New Chat” button, fix LAN routing issues, implement C fix, remove incorrect backgrounds, remove full modal, refine swiping behavior, adjust Model Picker Label, refine tab bar animation, adjust settings, refine latency, fix keyboard delay, remove Trusted Tester Section, remove Setting Descriptions, fix concurrent session issues in tab bar load animation, fix tab bar load animation overlap at bottom.
2026-07-05 21:39:33 -07:00
. compactTabBarInset ()
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-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 )
2026-07-05 00:28:21 -07:00
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." )
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 : {
2026-07-05 00:28:21 -07:00
Text ( "Join code" )
2026-07-04 00:48:56 -07:00
} footer : {
2026-07-05 00:28:21 -07:00
Text ( "On your Mac, open Nucleic ▸ Settings ▸ Add device and copy the join link shown beneath the QR code, then paste it here." )
2026-07-04 00:48:56 -07:00
}
if let error {
Label ( error , systemImage : "exclamationmark.triangle.fill" )
. font (. callout ). foregroundStyle (. red )
}
Button {
submit ()
} label : {
2026-07-05 00:28:21 -07:00
Label ( "Join" , systemImage : "link" ). frame ( maxWidth : . infinity )
2026-07-04 00:48:56 -07:00
}
. 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 ()
}
}