525 lines
24 KiB
Swift
525 lines
24 KiB
Swift
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
|
||
@State private var showRenameDevice = false
|
||
@State private var deviceName = IdentityStore.deviceName()
|
||
@State private var deviceNameDraft = ""
|
||
/// 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") {
|
||
// A read-only diagnostics view of the mesh as this phone sees it — the
|
||
// iOS analogue of the Mac's Covalence Mesh Viewer.
|
||
NavigationLink {
|
||
MeshInfoView()
|
||
} label: {
|
||
Label("Mesh info", systemImage: "point.3.connected.trianglepath.dotted")
|
||
}
|
||
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("Name", value: deviceName)
|
||
Button("Rename device") {
|
||
deviceNameDraft = deviceName
|
||
showRenameDevice = true
|
||
}
|
||
LabeledContent("Scope", value: store.grantedScope.rawValue.capitalized)
|
||
LabeledContent("Key fingerprint", value: store.deviceFingerprint)
|
||
.font(.footnote.monospaced())
|
||
}
|
||
|
||
Section {
|
||
Toggle("Resolve over Nucleic Edge", isOn: Binding(
|
||
get: { store.syncedSettings.resolveApprovalsViaCloud },
|
||
set: { store.updateSyncedSettings(
|
||
SyncedSettings(resolveApprovalsViaCloud: $0)) }))
|
||
} header: {
|
||
Text("Lock-screen approvals")
|
||
} footer: {
|
||
Text("Send Approve/Deny from the Live Activity to your Mac through the Nucleic "
|
||
+ "relay, so a decision lands even when this iPhone is locked or off your "
|
||
+ "Wi-Fi. When off, the lock-screen buttons open Nucleic to approve "
|
||
+ "locally instead. Synced with your Mac.")
|
||
}
|
||
|
||
Section {
|
||
Toggle("Show lock events", isOn: $showLockEvents)
|
||
Toggle("Show advanced detail", isOn: $showRaw)
|
||
} header: {
|
||
Text("Transcript")
|
||
} footer: {
|
||
Text("Advanced detail surfaces per-turn token counts, rate-limit notices, turn "
|
||
+ "markers, and raw events. Hidden by default so consecutive tool calls "
|
||
+ "read as one group.")
|
||
}
|
||
|
||
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 a Covalence 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()
|
||
}
|
||
.alert("Rename this device", isPresented: $showRenameDevice) {
|
||
TextField("Device name", text: $deviceNameDraft)
|
||
Button("Cancel", role: .cancel) {}
|
||
Button("Save") {
|
||
if store.renameDevice(to: deviceNameDraft) {
|
||
deviceName = store.deviceName
|
||
}
|
||
}
|
||
.disabled(deviceNameDraft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
||
} message: {
|
||
Text("This name identifies your iPhone or iPad to every Mac in the mesh.")
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 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: {
|
||
Text("On a new iPhone or iPad, open Nucleic Remote ▸ Join a Covalence 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)
|
||
}
|
||
}
|