Files
nucleic/Sources/NucleicApp/SettingsView.swift
T

1648 lines
76 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import SwiftUI
import AppKit
import UniformTypeIdentifiers
import NucleicCore
import NucleicProtocol
/// App settings. A source-list sidebar groups the panes into sections — Interface, Agents & AI,
/// Environments, Connectivity — so the panel stays legible and keeps scaling as areas mature. It
/// replaces the original horizontal tab bar, which had run out of room at nine tabs (and dated from
/// when there were only a few). Each pane is a self-contained `Form`; the sidebar just picks which
/// one the detail column shows, and the choice is remembered across launches.
struct SettingsView: View {
@AppStorage(AppAppearance.storageKey) private var appearanceRaw = AppAppearance.dark.rawValue
@AppStorage(AppTextSize.storageKey) private var textSizeRaw = AppTextSize.medium.rawValue
/// Persists the last-open pane so reopening Settings returns you where you left off.
@AppStorage("nucleic.settings.selectedPane") private var storedPane = SettingsPane.general.rawValue
/// Starts unset so the first `onAppear` assignment restores `storedPane` — resolving it inline in
/// the initializer would fight the sidebar's selection binding on first render.
@State private var selection: SettingsPane?
/// Pins the sidebar open: sets `canCollapse = false` on the AppKit split-view item so the column
/// never collapses on its own when the window narrows (see `SidebarColumnController`). Paired with
/// `.toolbar(removing: .sidebarToggle)` below, which drops the manual collapse button — together
/// they make the sidebar impossible to hide.
@State private var sidebarColumn = SidebarColumnController()
private var appearance: AppAppearance { AppAppearance(rawValue: appearanceRaw) ?? .dark }
private var textSize: AppTextSize { AppTextSize(rawValue: textSizeRaw) ?? .medium }
var body: some View {
NavigationSplitView {
List(selection: $selection) {
// Emit each Section explicitly rather than through a `ForEach` over the groups:
// on macOS a sidebar `List` whose top-level `ForEach` yields `Section`s drops
// alternating sections, so the odd groups (Agents & AI, Connectivity) rendered as
// empty headers. Spelling out the sections sidesteps that; adding a future group is
// still a one-liner here plus a case in `SettingsGroup`.
sidebarSection(.interface)
sidebarSection(.agents)
sidebarSection(.environments)
sidebarSection(.connectivity)
}
// Fixed sidebar width: 1.3× the former 291pt minimum (≈378) — wide enough to
// fit every tab name without truncating to "…". Equal min/ideal/max sets the
// SwiftUI hint; the hard pin (non-draggable divider) is enforced in AppKit via
// SidebarColumnController.fixedWidth.
.navigationSplitViewColumnWidth(
min: SidebarColumnController.fixedWidth,
ideal: SidebarColumnController.fixedWidth,
max: SidebarColumnController.fixedWidth)
// Settings should always show its sidebar: drop the manual collapse control, and pin the
// AppKit split-view item open so a narrow window can't auto-collapse it either.
.toolbar(removing: .sidebarToggle)
.background { SidebarColumnConfigurator(controller: sidebarColumn) }
} detail: {
detailPane
.frame(maxWidth: .infinity, maxHeight: .infinity)
// Match the main window's detail surface. Each pane is a grouped `Form`, whose
// default scroll background is the system window gray (a warmer, lighter tone than
// our theme). Hide that and paint the app's own `AppTheme.background` so the Settings
// pane reads identically to the normal window's detail column (RootView paints the
// same fill there).
.scrollContentBackground(.hidden)
.background(AppTheme.background)
}
.navigationSplitViewStyle(.balanced)
// Fixed overall width so the settings window can't be resized narrow (which is what
// drove the sidebar's auto-collapse). 378pt sidebar + ~382pt detail = 760. Height stays
// flexible.
.frame(minWidth: 760, idealWidth: 760, maxWidth: 760, minHeight: 560, maxHeight: .infinity)
.preferredColorScheme(appearance.colorScheme)
.dynamicTypeSize(textSize.dynamicTypeSize)
.background(SettingsWindowConfigurator(reapplyToken: (selection ?? .general).rawValue))
.onAppear { if selection == nil { selection = SettingsPane(rawValue: storedPane) ?? .general } }
.onChange(of: selection) { _, pane in if let pane { storedPane = pane.rawValue } }
}
/// One titled sidebar section listing its group's panes as selectable rows.
private func sidebarSection(_ group: SettingsGroup) -> some View {
Section(group.title) {
ForEach(group.panes) { pane in
Label(pane.title, systemImage: pane.symbol).tag(pane)
}
}
}
/// The content column for the current selection. Each case is an existing `Form`-based pane view.
@ViewBuilder private var detailPane: some View {
switch selection ?? .general {
case .general: GeneralSettingsTab()
case .appearance: AppearanceSettingsTab()
case .notifications: NotificationSettingsTab()
case .chats: ChatSettingsTab()
case .agents: AgentsSettingsTab()
case .usage: UsageSettingsTab()
case .intelligence: IntelligenceSettingsTab()
case .sandbox: SandboxSettingsTab()
case .virtualMachines: MacVMSettingsTab()
case .control: ControlSettingsTab()
case .git: GitSettingsTab()
case .orbital: RemoteSettingsTab()
}
}
}
/// One selectable pane in the Settings sidebar. Raw values are stable (they back the persisted
/// last-open pane) and need not be contiguous, so panes can be reordered or inserted freely.
private enum SettingsPane: Int, Identifiable, CaseIterable, Hashable {
case general = 0
case appearance = 1
case notifications = 2
case chats = 3
case agents = 4
case intelligence = 5
case sandbox = 6
case virtualMachines = 7
case control = 8
case git = 9
case orbital = 10
case usage = 11
var id: Int { rawValue }
var title: String {
switch self {
case .general: "General"
case .appearance: "Appearance"
case .notifications: "Notifications"
case .chats: "Chats"
case .agents: "Agents"
case .intelligence: "Intelligence"
case .sandbox: "Sandbox"
case .virtualMachines: "Virtual Machines"
case .control: "Control"
case .git: "Git"
case .orbital: "Orbital"
case .usage: "Usage"
}
}
var symbol: String {
switch self {
case .general: "gearshape"
case .appearance: "paintbrush"
case .notifications: "bell.badge"
case .chats: "bubble.left.and.bubble.right"
case .agents: "cpu"
case .intelligence: "sparkles"
case .sandbox: "shippingbox"
case .virtualMachines: "desktopcomputer"
case .control: "lock.shield"
case .git: "arrow.triangle.branch"
case .orbital: "network"
case .usage: "gauge.with.dots.needle.67percent"
}
}
}
/// A titled section in the sidebar. Declaration order here is the on-screen order of both the
/// sections and the panes within them.
private enum SettingsGroup: Int, Identifiable, CaseIterable {
case interface
case agents
case environments
case connectivity
var id: Int { rawValue }
var title: String {
switch self {
case .interface: "Interface"
case .agents: "Agents & AI"
case .environments: "Environments"
case .connectivity: "Connectivity"
}
}
var panes: [SettingsPane] {
switch self {
case .interface: [.general, .appearance, .notifications, .chats]
case .agents: [.agents, .usage, .intelligence]
case .environments: [.sandbox, .virtualMachines, .control]
case .connectivity: [.git, .orbital]
}
}
}
/// Pins the Settings window's chrome that SwiftUI's `Settings` scene otherwise
/// manages on its own: a constant "Nucleic Settings" title (the default would
/// track the selected tab), float-above-everything level so the main window can't
/// cover it, and a vertically resizable frame.
private struct SettingsWindowConfigurator: NSViewRepresentable {
/// Re-runs `updateNSView` whenever the selected tab changes so we can restore
/// the title after the tab switch overwrites it.
var reapplyToken: Int?
func makeNSView(context: Context) -> NSView { NSView() }
func updateNSView(_ nsView: NSView, context: Context) {
DispatchQueue.main.async {
guard let window = nsView.window else { return }
window.title = "Nucleic Settings"
window.level = .floating
window.styleMask.insert(.resizable)
}
}
}
/// Appearance: the theme, text size, and color-vision accommodation. Split into its own pane (it
/// used to sit atop the General tab) so the sidebar has a dedicated, discoverable home for how the
/// app looks — the conventional place a mature macOS app keeps these.
private struct AppearanceSettingsTab: View {
@AppStorage(AppAppearance.storageKey) private var appearanceRaw = AppAppearance.dark.rawValue
@AppStorage(AppTextSize.storageKey) private var textSizeRaw = AppTextSize.medium.rawValue
@AppStorage(ColorVisionMode.storageKey) private var colorVisionRaw = ColorVisionMode.standard.rawValue
var body: some View {
Form {
Section("Appearance") {
Picker("Theme", selection: $appearanceRaw) {
ForEach(AppAppearance.allCases) { Text($0.label).tag($0.rawValue) }
}
.pickerStyle(.segmented)
Picker("Text size", selection: $textSizeRaw) {
ForEach(AppTextSize.allCases) { Text($0.label).tag($0.rawValue) }
}
Picker("Color vision", selection: $colorVisionRaw) {
ForEach(ColorVisionMode.allCases) { Text($0.label).tag($0.rawValue) }
}
}
}
.formStyle(.grouped)
}
}
/// Notifications: how a chat calls you back when it finishes or needs you — completion and blocked
/// sounds, the escalating alarm, and Dock bouncing. Gathered from the old General tab into one pane
/// since they're all facets of the same "get my attention" behavior.
private struct NotificationSettingsTab: View {
@AppStorage(ChatStatusSounds.playOnDoneKey) private var playDoneSound = true
@AppStorage(ChatStatusSounds.playOnBlockedKey) private var playBlockedSound = true
@AppStorage(ChatStatusSounds.escalatingAlarmKey) private var escalatingAlarm = false
@AppStorage(DockBounce.enabledKey) private var bounceDock = true
var body: some View {
Form {
Section("Sounds") {
Toggle("Play a sound when a chat finishes", isOn: $playDoneSound)
Toggle("Play a sound when a chat needs you", isOn: $playBlockedSound)
Toggle("Escalating alarm until you return", isOn: $escalatingAlarm)
.onChange(of: escalatingAlarm) { _, isOn in
if !isOn { ChatAlarm.shared.setActive(false) } // silence any alarm in progress
}
}
Section("Dock") {
Toggle("Bounce the Dock icon until you return", isOn: $bounceDock)
.onChange(of: bounceDock) { _, isOn in
if !isOn { DockBounce.shared.setPending(false) } // stop any bounce in progress
}
}
}
.formStyle(.grouped)
}
}
/// General: app-wide behavior that doesn't belong to a more specific pane — the home streak, power
/// (sleep) management, to-do labeling, diagnostics, privacy, updates, and the version footer.
/// Appearance and notification behaviors now have their own panes, leaving this one focused.
private struct GeneralSettingsTab: View {
@AppStorage(StreakBadge.showKey) private var showStreak = true
@AppStorage(SleepBlocker.enabledKey) private var blockSleep = false
@AppStorage(TriageLabelStyle.encouragingKey) private var encouragingTriageLabels = true
@AppStorage(SingularityPreparation.enabledKey) private var singularityPreparation = false
@AppStorage(MoveDiagnostics.loggingEnabledKey) private var smartMoveLogging = false
@AppStorage(HeartbeatSettings.shareAnonymousUsageKey) private var shareAnonymousUsage = true
/// Auto-update controller (shared with the sidebar banner). Drives the "Updates" section's
/// manual check; its background updates surface in the sidebar, not here.
@EnvironmentObject private var updater: AppUpdater
var body: some View {
Form {
Section("Home") {
Toggle("Show streak counter", isOn: $showStreak)
}
Section("Power") {
Toggle("Keep the Mac awake (block sleep)", isOn: $blockSleep)
.onChange(of: blockSleep) { _, isOn in
SleepBlocker.shared.setEnabled(isOn) // start/stop the assertion immediately
}
Text("Prevents your Mac from going to sleep while Nucleic is running, so long chats, "
+ "builds, and agent work aren't interrupted. The display can still sleep.")
.settingsCaption()
}
Section("To-dos") {
Toggle("Encouraging to-do labels", isOn: $encouragingTriageLabels)
}
Section("Diagnostics") {
Toggle("Smart Move logging", isOn: $smartMoveLogging)
Button("Reveal Log in Finder") {
let url = MoveDiagnostics.logFileURL
if FileManager.default.fileExists(atPath: url.path) {
NSWorkspace.shared.activateFileViewerSelecting([url])
} else {
try? FileManager.default.createDirectory(
at: MoveDiagnostics.supportDirectory, withIntermediateDirectories: true)
NSWorkspace.shared.activateFileViewerSelecting([MoveDiagnostics.supportDirectory])
}
}
.controlSize(.small)
}
Section("Privacy") {
Toggle("Share anonymous usage", isOn: $shareAnonymousUsage)
}
Section("Singularity preparation") {
Toggle("Prepare for the singularity", isOn: $singularityPreparation)
}
// Manual update check. Background updates surface as an unobtrusive sidebar banner;
// this is the on-demand check. Shown only for builds wired for auto-update (a packaged
// canary/beta/rc/stable embeds an appcast feed — dev/local builds keep the updater dormant).
if updater.isConfigured {
Section("Updates") {
HStack(spacing: 8) {
if case .found(let version) = updater.manual {
Button("Update Now — version \(version)") { updater.installManualUpdate() }
.controlSize(.small)
} else {
Button("Check for Updates…") { updater.checkForUpdates() }
.controlSize(.small)
.disabled(!updater.canCheckForUpdates)
}
updateStatusLabel
Spacer()
}
}
}
// App identity footer: the running version and the build (git commit) it was
// compiled from. Sits below the Updates section's "Check for Updates…" button and
// stays visible even on dev/local builds, where that section is hidden — so the
// exact build can be read (and copied for a bug report) without leaving Settings.
HStack {
Spacer()
Text(Self.versionFooter)
.settingsCaption()
.textSelection(.enabled)
Spacer()
}
.listRowBackground(Color.clear)
}
.formStyle(.grouped)
}
/// "Version 0.1.0 (build a1b2c3d 863)" — the marketing version (`CFBundleShortVersionString`),
/// the commit the binary was built from, and the monotonic `CFBundleVersion` build number,
/// all resolved once by `BuildInfo`.
private static var versionFooter: String {
let info = BuildInfo.current
let build = [info.buildLabel, info.buildNumber].compactMap { $0 }.joined(separator: " ")
return "Version \(info.version ?? "—") (build \(build))"
}
/// The text/spinner shown next to the update button, reflecting the last manual check.
@ViewBuilder private var updateStatusLabel: some View {
switch updater.manual {
case .checking:
ProgressView().controlSize(.small)
case .upToDate:
Text("You're up to date").settingsCaption()
case .downloading(let fraction):
Text(fraction > 0 ? "Updating… \(Int((fraction * 100).rounded()))%" : "Updating…")
.settingsCaption()
case .error(let message):
Text(message).settingsCaption()
case .idle, .found:
EmptyView()
}
}
}
/// Sandbox containers: the app-wide container service and per-session sandbox containers (for
/// non-control projects). Nucleic Control settings live in their own tab.
private struct SandboxSettingsTab: View {
@Environment(AppStore.self) private var store
@AppStorage(ContainerServiceSettings.serviceEnabledKey) private var containerServiceEnabled = false
@AppStorage(ContainerServiceSettings.sandboxByDefaultKey) private var sandboxByDefault = false
@AppStorage(ContainerServiceSettings.sessionContainerCPUsKey)
private var sessionCPUs = ContainerServiceSettings.defaultContainerCPUs
@AppStorage(ContainerServiceSettings.sessionContainerMemoryGiBKey)
private var sessionMemoryGiB = ContainerServiceSettings.defaultContainerMemoryGiB
/// A specific reason the in-process container runtime is unavailable on this machine, or `nil`
/// when supported. `nil` until checked on first appearance; drives the "unsupported" hint.
@State private var unsupportedReason: String?
var body: some View {
Form {
Section("Sandbox containers") {
// A Nucleic Control project pins the service on — it can't run without it — so the
// toggle is forced on and locked while one exists (reconcileContainerService).
Toggle("Enable container service", isOn: $containerServiceEnabled)
.disabled(store.containerServiceLockedOn)
LearnMoreLink("How agent sandboxing works", url: SupportURL.sandboxes)
if store.containerServiceLockedOn {
HStack(alignment: .firstTextBaseline, spacing: 6) {
Image(systemName: "lock.fill")
.foregroundStyle(.secondary)
Text("Required by your Nucleic Control projects. Remove or "
+ "convert every Control project to turn it off.")
.font(.caption)
}
}
if let unsupportedReason {
HStack(alignment: .firstTextBaseline, spacing: 6) {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundStyle(.orange)
Text("Container sandboxing isn't available. \(unsupportedReason)")
.font(.caption)
}
}
}
.task { unsupportedReason = ContainerEngine.unsupportedReason }
Section("New projects") {
Toggle("Sandbox new projects by default", isOn: $sandboxByDefault)
.disabled(!containerServiceEnabled)
}
// Per-session sandbox resource controls aren't wired up yet; show them disabled and
// grayed out so the capability is visible but clearly not yet available.
Section("Per-Session sandbox (coming soon)") {
Stepper("CPUs: \(sessionCPUs)", value: $sessionCPUs, in: 1...32)
Stepper("Memory: \(sessionMemoryGiB) GB", value: $sessionMemoryGiB, in: 1...128)
}
.disabled(true)
.opacity(0.5)
}
.formStyle(.grouped)
}
}
/// The Virtual Machines tab: the macOS-guest VM service (Apple `Virtualization`). Enables the
/// `mac_vm_exec` tool — each agent session gets its own isolated macOS VM for Xcode/simulator work,
/// the per-agent alternative to the shared-host `host_exec` (see docs/MACOS_VM.md). Also drives the
/// one-time golden base-image build and shows the live VMs.
private struct MacVMSettingsTab: View {
@Environment(AppStore.self) private var store
@AppStorage(MacVMSettings.serviceEnabledKey) private var serviceEnabled = false
@AppStorage(MacVMSettings.exposeByDefaultKey) private var exposeByDefault = true
@AppStorage(MacVMSettings.computerUseByDefaultKey) private var computerUseByDefault = true
@AppStorage(MacVMSettings.axAgentEnabledKey) private var axAgentEnabled = false
@AppStorage(MacVMSettings.alwaysOpenGuestDisplayKey) private var alwaysOpenGuestDisplay = false
@AppStorage(MacVMSettings.basePrebuiltPathKey) private var basePrebuiltPath = ""
@AppStorage(MacVMSettings.restoreImagePathKey) private var restoreImagePath = ""
@AppStorage(MacVMSettings.restoreImageURLKey) private var restoreImageURL = ""
@AppStorage(MacVMSettings.restoreImageChoiceKey) private var restoreImageChoice = "macos27-devbeta"
@AppStorage(MacVMSettings.vmCPUsKey) private var vmCPUs = MacVMSettings.defaultVMCPUs
@AppStorage(MacVMSettings.vmMemoryGiBKey) private var vmMemoryGiB = MacVMSettings.defaultVMMemoryGiB
@AppStorage(MacVMSettings.maxConcurrentVMsKey)
private var maxConcurrent = MacVMSettings.defaultMaxConcurrentVMs
@State private var baseProgress: MacVMBaseProgress?
/// Ordered list of build phases observed so far in the current build, so completed steps stay
/// on screen as a checklist and the whole thing visibly advances (rather than one lone spinner
/// that reads as "frozen" during a long indeterminate phase). Reset whenever no build is running.
@State private var seenBuildPhases: [MacVMBaseProgress.Phase] = []
@State private var runningVMs: [MacVMEntry] = []
@State private var baseOSVersion: String?
@State private var baseStatus: MacVMBaseStatus?
@State private var buildError: String?
@State private var building = false
@State private var recoveryBooting = false
/// Whether the "Advanced" restore-source overrides are expanded. Driven open by picking the
/// "Custom" base-version option (or by finding a manual override already stored on appearance).
@State private var advancedExpanded = false
/// Whether the "Advanced" power-user toggles under the service section are expanded.
@State private var serviceAdvancedExpanded = false
private var supported: Bool { store.macVMSupported }
var body: some View {
Form {
Section("macOS virtual machines") {
Toggle("Enable macOS VM service", isOn: $serviceEnabled)
.disabled(!supported)
if !supported {
HStack(alignment: .firstTextBaseline, spacing: 6) {
Image(systemName: "exclamationmark.triangle.fill").foregroundStyle(.orange)
Text("Running macOS guests requires Apple silicon.").font(.caption)
}
} else {
HStack(alignment: .firstTextBaseline, spacing: 6) {
Image(systemName: "internaldrive.fill").foregroundStyle(.orange)
Text("Keep at least 64 GB of free disk space before enabling — the base "
+ "image, restore image, and per-session VM disks add up fast.")
.font(.caption)
}
LearnMoreLink(url: SupportURL.macVMs)
}
Toggle("Expose to sandboxed sessions by default", isOn: $exposeByDefault)
.disabled(!serviceEnabled || !supported)
Toggle("Virtual computer use", isOn: $computerUseByDefault)
.disabled(!serviceEnabled || !supported)
.onChange(of: computerUseByDefault) { _, isOn in
// Semantic computer use builds on virtual computer use — turning the base off
// turns the dependent off too, so it can't linger enabled on its own.
if !isOn { axAgentEnabled = false }
}
if computerUseByDefault {
Text("Uses virtual drivers to work in the background, without opening a window, "
+ "capturing your mouse, or locking your keyboard. Requires no additional setup.")
.settingsCaption()
LearnMoreLink(url: SupportURL.macVMComputerUse)
Toggle("Semantic computer use", isOn: $axAgentEnabled)
.disabled(!serviceEnabled || !supported)
if axAgentEnabled {
Text("Improves the agent's understanding of human interfaces. Uses the guest's "
+ "software systems to read and act on the UI at the element level. Requires "
+ "some additional setup.")
.settingsCaption()
LearnMoreLink("Set up AX-based computer use", url: SupportURL.macVMAXAgent)
}
}
DisclosureGroup("Advanced", isExpanded: $serviceAdvancedExpanded) {
Toggle("Always open VM guest display", isOn: $alwaysOpenGuestDisplay)
.disabled(!serviceEnabled || !supported)
Text("Pops a live guest-screen window for every session macOS VM the instant it "
+ "boots — whatever launched it (a session tool or computer use) and whichever "
+ "chat is open, or none. The window closes when the VM stops. Normally the screen "
+ "is watched on demand (the Control panel's \"Observe\"); this forces it "
+ "always-on — useful for Nucleic developers, or anyone diagnosing a VM that "
+ "won't behave. (The base-image build has its own progress view above.)")
.settingsCaption()
}
}
Section("Base image") {
if let baseOSVersion {
LabeledContent("Installed guest macOS", value: baseOSVersion)
}
if let baseProgress {
VStack(alignment: .leading, spacing: 8) {
ForEach(seenBuildPhases, id: \.self) { phase in
buildStageRow(
phase,
active: phase == baseProgress.phase && phase != .ready,
fraction: baseProgress.fraction)
}
}
} else {
Button {
Task { await runBaseBuild() }
} label: {
Label(
baseStatus?.installed == true
? "Rebuild / re-provision base image" : "Build base image",
systemImage: "square.and.arrow.down.on.square")
.padding(.vertical, 4)
}
.disabled(!supported || building)
LearnMoreLink("How base-image builds work", url: SupportURL.macVMBaseImage)
if let baseStatus, baseStatus.installed {
baseStatusView(baseStatus)
}
}
if let buildError {
Text(buildError).font(.caption).foregroundStyle(.red)
}
Picker("Base macOS version", selection: $restoreImageChoice) {
ForEach(Self.ipswCatalog) { choice in
Text(choice.label).tag(choice.id)
}
Text("Custom").tag("custom")
}
.onChange(of: restoreImageChoice) { _, newValue in
applyIPSWChoice(newValue)
}
DisclosureGroup("Advanced", isExpanded: $advancedExpanded) {
LabeledContent("Prebuilt base bundle") {
HStack {
TextField("optional path", text: $basePrebuiltPath)
.textFieldStyle(.roundedBorder)
Button("Browse…") { chooseBasePrebuiltBundle() }
}
}
LabeledContent("Restore image (.ipsw)") {
HStack {
TextField("optional local path", text: $restoreImagePath)
.textFieldStyle(.roundedBorder)
Button("Browse…") { chooseRestoreImage() }
}
}
LabeledContent("Restore image URL") {
TextField("optional .ipsw URL (pin a macOS version)", text: $restoreImageURL)
.textFieldStyle(.roundedBorder)
}
Text("A local path or URL takes precedence over the picker above. Leave all empty to "
+ "install the newest macOS the host can run.")
.settingsCaption()
}
}
.disabled(!supported)
Section("Per-session VM resources") {
Stepper("CPUs: \(vmCPUs)", value: $vmCPUs, in: 1...32)
Stepper("Memory: \(vmMemoryGiB) GB", value: $vmMemoryGiB, in: 2...128)
Stepper("Max concurrent VMs: \(maxConcurrent)", value: $maxConcurrent, in: 1...8)
Text("macOS caps how many macOS guests run at once (2 on recent releases); extra "
+ "agents queue for a VM rather than failing.")
.settingsCaption()
}
.disabled(!serviceEnabled || !supported)
if !runningVMs.isEmpty {
Section("Running now") {
ForEach(runningVMs, id: \.name) { vm in
LabeledContent(vm.name, value: vm.ipAddress ?? "booting…")
}
}
}
}
.formStyle(.grouped)
.task { await poll() }
.onAppear { reconcileBaseImageChoice() }
.onChange(of: basePrebuiltPath) { _, _ in reconcileCustomSelection() }
.onChange(of: restoreImagePath) { _, _ in reconcileCustomSelection() }
.onChange(of: restoreImageURL) { _, _ in reconcileCustomSelection() }
}
// MARK: - Guest-image (.ipsw) catalog
/// One selectable restore image in the hardcoded catalog. `url` is the remote `.ipsw`; an empty
/// `url` is a placeholder (image not published yet) that falls back to "latest supported".
private struct IPSWChoice: Identifiable, Hashable {
let id: String
let label: String
let url: String
}
/// Hardcoded list of guest macOS restore images offered in the picker. Selecting one writes its
/// `url` into the `restoreImageURL` setting (empty → clears it, i.e. latest supported).
private static let ipswCatalog: [IPSWChoice] = [
IPSWChoice(
id: "macos27-devbeta",
label: "macOS 27 Developer Beta",
url: "https://updates.cdn-apple.com/2026SummerSeed/fullrestores/140-36017/"
+ "0F8D2136-7924-4F3E-A9AA-99722A9EFB39/UniversalMac_27.0_26A5378j_Restore.ipsw"),
IPSWChoice(id: "macos27-publicbeta", label: "macOS 27 Public Beta", url: ""),
]
/// Look up a catalog choice by its picker id.
private static func ipswChoice(for id: String) -> IPSWChoice? {
ipswCatalog.first { $0.id == id }
}
/// Apply a picker selection. "Custom" just pops open the Advanced overrides so the user can type
/// a source. A concrete catalog pick owns the restore source: it clears any manual overrides and
/// points the URL at the chosen image (empty for a not-yet-published placeholder).
private func applyIPSWChoice(_ id: String) {
if id == "custom" {
advancedExpanded = true
return
}
basePrebuiltPath = ""
restoreImagePath = ""
restoreImageURL = Self.ipswChoice(for: id)?.url ?? ""
}
/// True when the Advanced overrides describe a source no catalog entry covers — a manual prebuilt
/// bundle, a local `.ipsw`, or a restore URL that isn't one of the catalog images.
private func hasCustomAdvancedSource() -> Bool {
if !basePrebuiltPath.trimmingCharacters(in: .whitespaces).isEmpty { return true }
if !restoreImagePath.trimmingCharacters(in: .whitespaces).isEmpty { return true }
let url = restoreImageURL.trimmingCharacters(in: .whitespaces)
guard !url.isEmpty else { return false }
return !Self.ipswCatalog.contains { $0.url == url }
}
/// Flip the picker to "Custom" whenever the Advanced fields hold an override that isn't one of the
/// catalog images, so the selector always reflects what will actually be installed.
private func reconcileCustomSelection() {
if hasCustomAdvancedSource() && restoreImageChoice != "custom" {
restoreImageChoice = "custom"
}
}
/// Resolve the picker selection on appearance: a custom override wins (and opens Advanced), an
/// unknown/legacy value falls back to the default image, and the chosen image's URL is kept in sync.
private func reconcileBaseImageChoice() {
if hasCustomAdvancedSource() {
restoreImageChoice = "custom"
advancedExpanded = true
} else {
if Self.ipswChoice(for: restoreImageChoice) == nil { restoreImageChoice = "macos27-devbeta" }
applyIPSWChoice(restoreImageChoice)
}
}
/// Browse for a local `.ipsw` restore image and store its path.
private func chooseRestoreImage() {
let panel = NSOpenPanel()
panel.canChooseFiles = true
panel.canChooseDirectories = false
panel.allowsMultipleSelection = false
panel.allowedContentTypes = [UTType(filenameExtension: "ipsw")].compactMap { $0 }
panel.message = "Choose a macOS restore image (.ipsw)"
if panel.runModal() == .OK, let url = panel.url { restoreImagePath = url.path }
}
/// Browse for a prebuilt golden base VM bundle (a directory/package) and store its path.
private func chooseBasePrebuiltBundle() {
let panel = NSOpenPanel()
panel.canChooseFiles = true
panel.canChooseDirectories = true
panel.allowsMultipleSelection = false
panel.message = "Choose a prebuilt base VM bundle"
if panel.runModal() == .OK, let url = panel.url { basePrebuiltPath = url.path }
}
/// Post-build status: exec-ready vs. fully computer-use-ready. On macOS 27 the semantic AX agent's
/// grants are written automatically during the base build; the Recovery escape hatch below is a
/// fallback shown only if the agent still isn't ready after a build.
@ViewBuilder
private func baseStatusView(_ status: MacVMBaseStatus) -> some View {
// Computer use works host-side on any installed base. Show the readiness line, and the
// Recovery fallback ONLY for the optional semantic AX agent when it's enabled but not yet ready.
if status.axAgentReady {
Label("Ready — builds + computer use + semantic AX.", systemImage: "checkmark.seal.fill")
.font(.caption).foregroundStyle(.green)
} else if status.provisioned {
Label("Ready — builds + computer use.", systemImage: "checkmark.seal.fill")
.font(.caption).foregroundStyle(.green)
} else {
Label("Computer use ready. Toolchain provisioning incomplete — rebuild.",
systemImage: "checkmark.circle").font(.caption)
}
if axAgentEnabled && !status.axAgentReady && status.accountProvisioned {
VStack(alignment: .leading, spacing: 6) {
Button {
Task { await bootRecovery() }
} label: {
Label("Boot base in Recovery", systemImage: "lifepreserver")
}
.disabled(recoveryBooting)
Text("The semantic AX agent isn't ready. Its grants normally apply automatically during "
+ "the base build; if it's still not ready, apply them manually with the steps in "
+ "the setup guide.")
.font(.caption).foregroundStyle(.orange)
LearnMoreLink("Setup guide", url: SupportURL.macVMAXAgent)
}
}
}
private func runBaseBuild() async {
building = true
buildError = nil
defer { building = false }
do {
let path = restoreImagePath.trimmingCharacters(in: .whitespaces)
try await store.buildMacVMBaseImage(localRestoreImagePath: path.isEmpty ? nil : path)
} catch {
buildError = "\(error)"
}
}
/// Boot the golden base into recoveryOS in an interactive window so the operator can
/// `csrutil disable` — the lone computer-use step that can't be scripted over SSH.
private func bootRecovery() async {
recoveryBooting = true
buildError = nil
defer { recoveryBooting = false }
// Claim the base exclusively so a build or a fresh session clone can't read its disk while the
// Recovery VM has it booted writable; released when the window closes.
let path: String
do {
path = try await store.beginMacVMBaseRecovery()
} catch {
buildError = "\(error)"
return
}
#if arch(arm64)
MacVMRecoveryWindowController.present(baseRootPath: path) {
Task { await store.endMacVMBaseRecovery() }
}
#else
await store.endMacVMBaseRecovery()
buildError = "Recovery boot requires Apple silicon."
#endif
}
/// Poll base-build progress + the live VM list while the tab is open.
private func poll() async {
while !Task.isCancelled {
let progress = await store.macVMBaseProgress()
recordBuildPhase(progress)
baseProgress = progress
runningVMs = await store.runningMacVMs()
baseOSVersion = await store.macVMBaseOSVersion()
baseStatus = await store.macVMBaseStatus()
try? await Task.sleep(for: .seconds(2))
}
}
/// Grow (or reset) the observed-phase checklist. Phases are reported one at a time and advance
/// monotonically, so appending each newly-seen phase preserves build order; a `nil` progress
/// (no build running) clears the list for the next run.
private func recordBuildPhase(_ progress: MacVMBaseProgress?) {
guard let progress else {
if !seenBuildPhases.isEmpty { seenBuildPhases = [] }
return
}
if !seenBuildPhases.contains(progress.phase) {
seenBuildPhases.append(progress.phase)
}
}
/// One row in the build checklist: a green check for a completed stage, or the stage label plus
/// an animated linear bar (indeterminate, or determinate when the phase reports a fraction) for
/// the stage in progress.
@ViewBuilder
private func buildStageRow(
_ phase: MacVMBaseProgress.Phase, active: Bool, fraction: Double?
) -> some View {
HStack(alignment: .top, spacing: 8) {
Image(systemName: active ? "circle.fill" : "checkmark.circle.fill")
.font(.caption2)
.foregroundStyle(active ? AnyShapeStyle(.tint) : AnyShapeStyle(.green))
.frame(width: 14)
.padding(.top, 1)
VStack(alignment: .leading, spacing: 4) {
Text(MacVMBaseProgress(phase: phase).label)
.font(.caption)
.fontWeight(active ? .semibold : .regular)
.foregroundStyle(active ? .primary : .secondary)
if active {
if let fraction {
ProgressView(value: fraction).progressViewStyle(.linear)
} else {
ProgressView().progressViewStyle(.linear)
}
}
}
}
}
}
/// The Git tab: Nucleic's Managed Git credential and where it's used. The credential is always
/// used for Nucleic's own host-side git (cloning a project, pushing a branch, opening a PR) and is
/// optionally made available to sandboxed sessions. The SSH config section re-supplies any
/// connectivity Managed Git's hermetic ssh would otherwise miss.
private struct GitSettingsTab: View {
var body: some View {
Form {
GitHubAccessSection()
ManagedGitSSHConfigSection()
}
.formStyle(.grouped)
}
}
/// Extra OpenSSH config for Managed Git's connections to GitHub. Managed Git deliberately ignores
/// the user's real `~/.ssh/config` (to stay off their personal keys and ssh-agent), which also drops
/// any connectivity directives there — so this section lets the user re-supply just the connectivity
/// they need (a custom `Hostname`/`Port`, a `ProxyCommand`…). Identity/agent directives are ignored:
/// Managed Git always uses its own key. Applies to host and sandbox ssh alike, and only in the SSH /
/// Nucleic-managed-key modes (it's inert for token/none).
private struct ManagedGitSSHConfigSection: View {
@AppStorage(GitHubCredentialSettings.sshConfigKey) private var sshConfig = ""
var body: some View {
Section("SSH config") {
TextEditor(text: $sshConfig)
.font(.system(.caption, design: .monospaced))
.frame(minHeight: 80)
.overlay(RoundedRectangle(cornerRadius: 4).stroke(.quaternary))
}
}
}
/// The app-level Managed Git credential. It's always used for Nucleic's own host-side `git`
/// (cloning a project, pushing a branch, opening a PR) — pinned via SSH `IdentitiesOnly` so the
/// host never falls back to the user's personal `~/.ssh` keys — and is optionally injected into
/// sandboxed sessions ("Make available in sandbox"; Control sessions always get it). The secret
/// lives in the login Keychain (via `GitHubCredentialStore`), never in UserDefaults; only the mode
/// and flags are plain preferences. The Nucleic-managed key never surfaces its private half — only
/// its public key can be copied or exported.
private struct GitHubAccessSection: View {
@Environment(AppStore.self) private var store
@AppStorage(GitHubCredentialSettings.authModeKey) private var modeRaw = GitHubAuthMode.none.rawValue
@AppStorage(GitHubCredentialSettings.signCommitsKey) private var signCommits = false
@AppStorage(GitHubCredentialSettings.ghForceSSHKey) private var ghForceSSH = false
@AppStorage(GitHubCredentialSettings.gitForceSSHKey) private var gitForceSSH = false
@AppStorage(GitHubCredentialSettings.availableInSandboxKey) private var availableInSandbox = true
/// Draft input for the secret being entered; never pre-filled from the Keychain (we don't
/// read secrets back into the UI). `tokenStored` / `keyStored` reflect whether one is saved.
@State private var tokenDraft = ""
@State private var keyDraft = ""
/// A filesystem path to load a key from (the file-picker fills this; it can also be typed).
@State private var pathDraft = ""
/// Passphrase for an encrypted key being saved. Taken verbatim (never trimmed).
@State private var passphraseDraft = ""
@State private var tokenStored = false
@State private var keyStored = false
@State private var passphraseStored = false
/// A validation complaint about the pasted/loaded SSH key (not a real private key, or
/// encrypted-without-a-passphrase).
@State private var keyWarning: String?
/// The Nucleic-managed public key, when one has been generated; drives the `.managed` UI.
@State private var managedPublicKey: String?
/// A failure from generating the managed key.
@State private var managedError: String?
private var mode: GitHubAuthMode { GitHubAuthMode(rawValue: modeRaw) ?? .none }
/// True when Nucleic Control is in use — either turned on for new projects, or there's already
/// at least one Control project. Control sessions run in the shared container, which has no other
/// way to reach GitHub, so the credential is always made available to them; the toggle is then
/// forced on and locked.
private var controlActive: Bool {
ContainerServiceSettings.controlByDefault
|| store.projects.contains { $0.isNucleicControlled }
}
var body: some View {
Section("Managed Git") {
Picker("Authentication", selection: $modeRaw) {
Text("None").tag(GitHubAuthMode.none.rawValue)
Text("Token (HTTPS)").tag(GitHubAuthMode.token.rawValue)
Text("SSH key").tag(GitHubAuthMode.ssh.rawValue)
Text("Nucleic-managed key").tag(GitHubAuthMode.managed.rawValue)
}
switch mode {
case .none:
EmptyView()
case .token:
tokenControls
case .ssh:
sshKeyEntry
sshOptions
case .managed:
managedKeyEntry
sshOptions
}
// Whether sandboxed sessions also get this credential. Control sessions always do (their
// shared container can't reach GitHub otherwise), so it's forced on and locked then.
if controlActive {
Toggle("Make available in sandbox", isOn: .constant(true))
.disabled(true)
} else {
Toggle("Make available in sandbox", isOn: $availableInSandbox)
.disabled(mode == .none)
}
}
.onAppear {
refreshStoredFlags()
managedPublicKey = ManagedSSHKey.publicKey
// Keep the stored value consistent with the locked-on display, so per-session sandboxes
// also see it as available while Control is in use.
if controlActive { availableInSandbox = true }
}
}
// MARK: Token
@ViewBuilder private var tokenControls: some View {
tokenEntry()
}
/// Save/clear UI for the stored PAT. Backed by the one Keychain token slot, so the same secret
/// serves token mode (git + gh) and SSH mode (gh API).
@ViewBuilder private func tokenEntry() -> some View {
if tokenStored {
storedRow(label: "Token saved") {
GitHubCredentialStore.deleteToken()
refreshStoredFlags()
}
} else {
SecureField("Personal access token", text: $tokenDraft)
Button("Save token") {
GitHubCredentialStore.saveToken(tokenDraft.trimmingCharacters(in: .whitespacesAndNewlines))
tokenDraft = ""
refreshStoredFlags()
}
.disabled(tokenDraft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
}
}
// MARK: SSH (user-supplied key)
@ViewBuilder private var sshKeyEntry: some View {
if keyStored {
storedRow(label: passphraseStored ? "SSH key saved (with passphrase)" : "SSH key saved") {
GitHubCredentialStore.deleteSSHPrivateKey()
GitHubCredentialStore.deleteSSHPassphrase()
refreshStoredFlags()
}
} else {
TextEditor(text: $keyDraft)
.font(.system(.caption, design: .monospaced))
.frame(minHeight: 90)
.overlay(RoundedRectangle(cornerRadius: 4).stroke(.quaternary))
HStack {
Button("Choose File…") { chooseKeyFile() }
TextField("or type a key path", text: $pathDraft)
.textFieldStyle(.roundedBorder)
Button("Load") { loadKey(from: pathDraft) }
.disabled(pathDraft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
}
SecureField("Passphrase (only if the key is encrypted)", text: $passphraseDraft)
if let keyWarning {
Label(keyWarning, systemImage: "exclamationmark.triangle.fill")
.font(.caption)
.foregroundStyle(.orange)
}
Button("Save SSH key") { saveSSHKey() }
.disabled(keyDraft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
}
}
private func saveSSHKey() {
let key = keyDraft.trimmingCharacters(in: .whitespacesAndNewlines)
guard key.contains("PRIVATE KEY") else {
keyWarning = "That doesn't look like an OpenSSH private key (expected a "
+ "“-----BEGIN … PRIVATE KEY-----” block)."
return
}
// Passphrases can contain meaningful whitespace, so take the field verbatim.
let passphrase = passphraseDraft
if key.contains("ENCRYPTED") && passphrase.isEmpty {
keyWarning = "This key is passphrase-protected. Enter its passphrase below so the "
+ "sandbox can unlock it non-interactively."
return
}
keyWarning = nil
GitHubCredentialStore.saveSSHPrivateKey(key)
if passphrase.isEmpty {
GitHubCredentialStore.deleteSSHPassphrase()
} else {
GitHubCredentialStore.saveSSHPassphrase(passphrase)
}
keyDraft = ""
passphraseDraft = ""
pathDraft = ""
refreshStoredFlags()
}
/// Open a file picker for a private key and load its contents into the editor. `~/.ssh` is
/// hidden, so show hidden files.
private func chooseKeyFile() {
let panel = NSOpenPanel()
panel.canChooseFiles = true
panel.canChooseDirectories = false
panel.allowsMultipleSelection = false
panel.showsHiddenFiles = true
panel.message = "Choose an OpenSSH private key"
if panel.runModal() == .OK, let url = panel.url { loadKey(from: url.path) }
}
/// Read a private key from `path` (tilde-expanded) into the editor, where the usual Save flow
/// validates it. We don't save straight from disk so the user can confirm/add a passphrase first.
private func loadKey(from path: String) {
let trimmed = path.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return }
let expanded = (trimmed as NSString).expandingTildeInPath
do {
keyDraft = try String(contentsOfFile: expanded, encoding: .utf8)
pathDraft = ""
keyWarning = nil
} catch {
keyWarning = "Couldn't read a key from \(expanded): \(error.localizedDescription)"
}
}
// MARK: Nucleic-managed key
@ViewBuilder private var managedKeyEntry: some View {
if let pub = managedPublicKey {
Label("Key generated", systemImage: "checkmark.seal.fill")
.foregroundStyle(.green)
Text(pub)
.font(.system(.caption, design: .monospaced))
.textSelection(.enabled)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(6)
.background(RoundedRectangle(cornerRadius: 4).fill(.quaternary.opacity(0.4)))
HStack {
Button("Copy public key") { copyToPasteboard(pub) }
Button("Export…") { exportPublicKey(pub) }
Spacer()
Button("Regenerate", role: .destructive) { generateManagedKey() }
Button("Remove", role: .destructive) {
ManagedSSHKey.delete()
managedPublicKey = nil
}
}
} else {
Button("Generate Nucleic-managed key") { generateManagedKey() }
if let managedError {
Label(managedError, systemImage: "exclamationmark.triangle.fill")
.font(.caption)
.foregroundStyle(.orange)
}
}
}
private func generateManagedKey() {
do {
managedPublicKey = try ManagedSSHKey.generate()
managedError = nil
} catch {
managedError = "Couldn't generate a key: \(error.localizedDescription)"
}
}
private func exportPublicKey(_ pub: String) {
let panel = NSSavePanel()
panel.nameFieldStringValue = "nucleic-managed.pub"
panel.canCreateDirectories = true
if panel.runModal() == .OK, let url = panel.url {
try? (pub + "\n").write(to: url, atomically: true, encoding: .utf8)
}
}
private func copyToPasteboard(_ value: String) {
NSPasteboard.general.clearContents()
NSPasteboard.general.setString(value, forType: .string)
}
// MARK: Shared SSH options (apply to both .ssh and .managed)
@ViewBuilder private var sshOptions: some View {
Toggle("Sign commits with this SSH key", isOn: $signCommits)
Toggle("Force gh to use SSH", isOn: $ghForceSSH)
Toggle("Restrict git to SSH for GitHub", isOn: $gitForceSSH)
Text("API token for gh (optional)")
.font(.callout.weight(.medium))
tokenEntry()
}
// MARK: Helpers
@ViewBuilder private func storedRow(label: String, clear: @escaping () -> Void) -> some View {
HStack {
Label(label, systemImage: "checkmark.seal.fill")
.foregroundStyle(.green)
Spacer()
Button("Clear", role: .destructive, action: clear)
}
}
private func refreshStoredFlags() {
tokenStored = !(GitHubCredentialStore.loadToken() ?? "").isEmpty
keyStored = !(GitHubCredentialStore.loadSSHPrivateKey() ?? "").isEmpty
passphraseStored = !(GitHubCredentialStore.loadSSHPassphrase() ?? "").isEmpty
}
}
/// Nucleic Control: repos Nucleic clones under `~/.nucleic/control/` and manages end-to-end
/// (shared sandbox container, the `git` interceptor, autoship-eligible). Requires the container
/// service (Sandbox tab).
private struct ControlSettingsTab: View {
@Environment(AppStore.self) private var store
@AppStorage(ContainerServiceSettings.serviceEnabledKey) private var containerServiceEnabled = false
@AppStorage(ContainerServiceSettings.controlByDefaultKey) private var controlByDefault = false
@AppStorage(ContainerServiceSettings.nvrsionByDefaultKey) private var nvrsionByDefault = false
@AppStorage(ContainerServiceSettings.controlContainerCPUsKey)
private var controlCPUs = ContainerServiceSettings.defaultControlContainerCPUs
@AppStorage(ContainerServiceSettings.controlContainerMemoryGiBKey)
private var controlMemoryGiB = ContainerServiceSettings.recommendedControlContainerMemoryGiB()
@AppStorage(ContainerServiceSettings.controlAuthModeKey)
private var authMode = ControlAuthMode.oauth.rawValue
@AppStorage(ContainerServiceSettings.splitControlContainersByBackendKey)
private var splitContainersByBackend = false
@AppStorage(ContainerServiceSettings.vsockControlPlaneEnabledKey)
private var vsockControlPlane = true // default-on as of sandbox image v4 (ships control-bridge.js)
@AppStorage(ContainerServiceSettings.memoryManagementKey)
private var memoryManagement = MemoryManagementLevel.conservative.rawValue
@AppStorage(ContainerServiceSettings.commandTracingEnabledKey)
private var commandTracing = false
/// Draft for the API-key entry field (write-only — the stored key is never read back into UI).
@State private var apiKeyDraft = ""
/// Whether a key is currently in the Keychain, for the "Key saved" / Clear affordances.
@State private var keySaved = ControlAPIKeyStore.hasKey
/// True while a force-recreation is in flight, to disable the button and show progress.
@State private var recreating = false
/// True while a memory-reclaiming restart is in flight.
@State private var restarting = false
/// True while an on-demand "Reclaim memory now" pass is in flight.
@State private var reclaiming = false
/// Upper bound for the memory stepper: this Mac's total physical RAM. `--memory` is a ceiling
/// the VM grows into, so allocating more than the host has would be meaningless.
private var maxControlMemoryGiB: Int {
max(1, Int(ProcessInfo.processInfo.physicalMemory / 1_073_741_824))
}
var body: some View {
Form {
Section("Nucleic Control") {
if !containerServiceEnabled {
HStack(alignment: .firstTextBaseline, spacing: 6) {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundStyle(.orange)
Text("Turn on the container service in Settings → Sandbox to use Nucleic "
+ "Control.")
.font(.caption)
}
}
Toggle("Enable Nucleic Control on all projects", isOn: $controlByDefault)
.disabled(!containerServiceEnabled)
LearnMoreLink("What is Nucleic Control?", url: SupportURL.nucleicControl)
}
Section {
if !containerServiceEnabled {
HStack(alignment: .firstTextBaseline, spacing: 6) {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundStyle(.orange)
Text("Turn on the container service in Settings → Sandbox to use nvrsion.")
.font(.caption)
}
}
Toggle("Use nvrsion for new Control projects", isOn: $nvrsionByDefault)
.disabled(!containerServiceEnabled)
} header: {
HStack(spacing: 6) {
Text("nvrsion")
Text("BETA")
.font(.caption2).bold()
.padding(.horizontal, 5).padding(.vertical, 1)
.background(AppTheme.hairline, in: .capsule)
}
}
Section("Container authentication") {
Picker("Authenticate Claude with", selection: $authMode) {
Text("Claude subscription (OAuth)").tag(ControlAuthMode.oauth.rawValue)
Text("Anthropic API key").tag(ControlAuthMode.apiKey.rawValue)
}
.disabled(!containerServiceEnabled)
if authMode == ControlAuthMode.apiKey.rawValue {
SecureField("sk-ant-…", text: $apiKeyDraft)
.disabled(!containerServiceEnabled)
HStack(spacing: 10) {
Button("Save Key") {
if ControlAPIKeyStore.save(apiKeyDraft) { keySaved = ControlAPIKeyStore.hasKey }
apiKeyDraft = ""
}
.disabled(!containerServiceEnabled
|| apiKeyDraft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
if keySaved {
Button("Clear", role: .destructive) {
ControlAPIKeyStore.clear()
keySaved = false
}
Label("Key saved", systemImage: "checkmark.seal.fill")
.foregroundStyle(.green).font(.caption)
}
}
}
}
Section("Nucleic Control container") {
LabeledContent("CPUs", value: "Auto")
LabeledContent("Memory", value: "Auto")
Picker("Memory management", selection: $memoryManagement) {
ForEach(MemoryManagementLevel.allCases, id: \.rawValue) { level in
Text(level.label).tag(level.rawValue)
}
}
.disabled(!containerServiceEnabled)
Toggle("Agent Lifecycle Protection", isOn: $splitContainersByBackend)
.disabled(!containerServiceEnabled)
Toggle("Control plane over vsock", isOn: $vsockControlPlane)
.disabled(!containerServiceEnabled)
Toggle("Trace shell commands", isOn: $commandTracing)
.disabled(!containerServiceEnabled)
}
Section("Maintenance") {
Button {
reclaiming = true
Task {
await store.reclaimControlMemory()
reclaiming = false
}
} label: {
HStack(spacing: 6) {
if reclaiming { ProgressView().controlSize(.small) }
Text(reclaiming ? "Reclaiming…" : "Reclaim memory now")
}
}
.disabled(!containerServiceEnabled || reclaiming)
Button {
restarting = true
Task {
await store.restartControlSandbox()
restarting = false
}
} label: {
HStack(spacing: 6) {
if restarting { ProgressView().controlSize(.small) }
Text(restarting ? "Restarting…" : "Restart container")
}
}
.disabled(!containerServiceEnabled || restarting || recreating)
Button(role: .destructive) {
recreating = true
Task {
await store.recreateControlSandbox()
recreating = false
}
} label: {
HStack(spacing: 6) {
if recreating { ProgressView().controlSize(.small) }
Text(recreating ? "Re-creating…" : "Force re-creation")
}
}
.disabled(!containerServiceEnabled || recreating || restarting)
}
}
.formStyle(.grouped)
}
}
/// Everything about the chat experience: how the transcript renders, the composer
/// send key, and how completed chats are archived. The auto-archive policy syncs
/// to `AppStore` via `pushDefaults()`.
private struct ChatSettingsTab: View {
@Environment(AppStore.self) private var store
@AppStorage(TranscriptDisplay.showDebugKey) private var showDebugLines = false
@AppStorage(TranscriptDisplay.showLockEventsKey) private var showLockEvents = true
@AppStorage(SubmitKeyMode.storageKey) private var submitKeyRaw = SubmitKeyMode.modifierSends.rawValue
@AppStorage(AutoArchivePolicy.storageKey) private var autoArchiveRaw = AutoArchivePolicy.fallback.rawValue
@AppStorage(ArchivedWorktreeCleanupPolicy.storageKey) private var worktreeCleanupRaw = ArchivedWorktreeCleanupPolicy.fallback.rawValue
private var submitMode: SubmitKeyMode { SubmitKeyMode(rawValue: submitKeyRaw) ?? .modifierSends }
private var autoArchivePolicy: AutoArchivePolicy { AutoArchivePolicy(rawValue: autoArchiveRaw) ?? .fallback }
private var worktreeCleanupPolicy: ArchivedWorktreeCleanupPolicy {
ArchivedWorktreeCleanupPolicy(rawValue: worktreeCleanupRaw) ?? .fallback
}
var body: some View {
Form {
Section("Transcript") {
Toggle("Show advanced detail", isOn: $showDebugLines)
Toggle("Show lock events", isOn: $showLockEvents)
}
Section("Composer") {
Picker("Send with", selection: $submitKeyRaw) {
ForEach(SubmitKeyMode.allCases) { Text($0.label).tag($0.rawValue) }
}
}
Section("Archiving") {
Picker("Auto-archive completed chats", selection: $autoArchiveRaw) {
ForEach(AutoArchivePolicy.allCases) { Text($0.label).tag($0.rawValue) }
}
Picker("Delete archived chat worktrees", selection: $worktreeCleanupRaw) {
ForEach(ArchivedWorktreeCleanupPolicy.allCases) { Text($0.label).tag($0.rawValue) }
}
}
}
.formStyle(.grouped)
.onAppear { pushDefaults() }
.onChange(of: autoArchiveRaw) { _, _ in pushDefaults() }
.onChange(of: worktreeCleanupRaw) { _, _ in pushDefaults() }
}
private func pushDefaults() {
store.autoArchiveIdleInterval = autoArchivePolicy.interval
store.archivedWorktreeCleanupInterval = worktreeCleanupPolicy.interval
}
}
/// Defaults applied to new chats (model/effort/auto/autoship), synced to `AppStore` via
/// `pushDefaults()`. The default model also picks the backend (`BackendID.forModel`): a Claude
/// SKU runs on Claude Code, a GPT SKU on Codex.
private struct AgentsSettingsTab: View {
@Environment(AppStore.self) private var store
@AppStorage(ModelCatalog.defaultModelKey) private var defaultModel = ModelCatalog.fallbackModel
@AppStorage(ModelCatalog.defaultEffortKey) private var defaultEffort = ModelCatalog.fallbackEffort
@AppStorage(ModelCatalog.defaultAutoKey) private var defaultAuto = false
@AppStorage(ModelCatalog.defaultAutoShipKey) private var defaultAutoShip = false
@AppStorage(StatusIndicatorVisibility.storageKey) private var statusVisibilityRaw = StatusIndicatorVisibility.everywhere.rawValue
@AppStorage(StatusIndicatorVisibility.chatIncidentOverrideKey) private var showStatusInChatOnIncident = true
/// Live install + auth state per provider, populated by an async probe on appearance and
/// re-run whenever this tab comes back into view (so logging in/out elsewhere reflects).
@State private var providers: [ProviderStatus] = []
var body: some View {
Form {
Section("New chats") {
Picker("Default model", selection: $defaultModel) {
ForEach(ModelCatalog.models, id: \.self) { sku in
(Text(ModelCatalog.displayName(sku))
+ (ModelCatalog.contextBadge(for: sku).map { Text(" \($0)").foregroundColor(.secondary) } ?? Text(""))
).tag(sku)
}
}
Picker("Default \(ModelCatalog.effortNoun(for: defaultModel).lowercased())", selection: $defaultEffort) {
ForEach(ModelCatalog.efforts(for: defaultModel), id: \.self) { Text(ModelCatalog.effortDisplayName($0)).tag($0) }
}
Toggle("Start new chats in Auto mode", isOn: $defaultAuto)
Toggle("Autoship new chats", isOn: $defaultAutoShip)
}
Section("Backend") {
ForEach(providers) { provider in
HStack(spacing: 8) {
Circle()
.fill(provider.isConnected ? Color.green : Color.red)
.frame(width: 8, height: 8)
Text(provider.name)
Spacer()
Text(provider.isConnected ? "Connected" : "Not Connected")
.foregroundStyle(.secondary)
}
.help(provider.detail)
}
}
.task { providers = await ProviderAvailability.probeAll() }
Section("Service status") {
Picker("Show status indicator", selection: $statusVisibilityRaw) {
ForEach(StatusIndicatorVisibility.allCases) { Text($0.label).tag($0.rawValue) }
}
Toggle("Show in chat during an incident on the current model's provider", isOn: $showStatusInChatOnIncident)
.disabled(StatusIndicatorVisibility(rawValue: statusVisibilityRaw) == .everywhere)
ForEach(StatusProvider.allCases) { provider in
Toggle("Monitor \(provider.displayName)", isOn: providerBinding(provider))
}
}
}
.formStyle(.grouped)
.onAppear {
defaultEffort = ModelCatalog.clampedEffort(defaultEffort, for: defaultModel)
pushDefaults()
}
.onChange(of: defaultModel) { _, _ in
// A new default model may not support the stored effort (e.g. switching to Codex
// drops "max" → "xhigh").
defaultEffort = ModelCatalog.clampedEffort(defaultEffort, for: defaultModel)
pushDefaults()
}
.onChange(of: defaultEffort) { _, _ in pushDefaults() }
.onChange(of: defaultAuto) { _, _ in pushDefaults() }
.onChange(of: defaultAutoShip) { _, _ in
if defaultAutoShip { defaultAuto = true } // shipping implies autonomous
pushDefaults()
}
}
private func pushDefaults() {
store.defaultModel = defaultModel
store.defaultEffort = defaultEffort
store.defaultAuto = defaultAuto
store.defaultAutoShip = defaultAutoShip
}
/// Toggle binding for one provider's status monitoring: reads/writes the live store set
/// (so the row reflects state immediately) and persists the choice for next launch.
private func providerBinding(_ provider: StatusProvider) -> Binding<Bool> {
Binding(
get: { store.enabledStatusProviders.contains(provider) },
set: { isOn in
UserDefaults.standard.set(isOn, forKey: StatusIndicatorSettings.providerKey(provider))
var enabled = store.enabledStatusProviders
if isOn { enabled.insert(provider) } else { enabled.remove(provider) }
store.setEnabledStatusProviders(enabled)
})
}
}
/// Usage: the subscription-limit gauges from the home dashboard, gathered into Settings so
/// they're checkable without leaving the panel. Claude is live — the same `QuotaCard` the home
/// view shows, reading `AppStore.subscriptionUsage`. Codex is a placeholder (`CodexUsageCard`)
/// until its usage backend is wired up. Not a `Form`: the pane shows the dashboard cards
/// verbatim so the two views stay identical.
private struct UsageSettingsTab: View {
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 20) {
Text("The same account-wide subscription limits shown on the home dashboard.")
.settingsCaption()
QuotaCard(title: "Claude")
CodexUsageCard()
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(20)
}
}
}
/// Apple Intelligence model choice (used for summaries and auto-named chats), the
/// AI tool-call summarization options that depend on it, and its live availability
/// status. Summarization preferences sync to `AppStore` via `pushDefaults()`.
private struct IntelligenceSettingsTab: View {
@Environment(AppStore.self) private var store
@AppStorage(IntelligenceChoice.storageKey) private var choiceRaw = IntelligenceChoice.onDevice.rawValue
@AppStorage(TranscriptDisplay.summarizeToolCallsKey) private var summarizeToolCalls = true
@AppStorage(TranscriptDisplay.toolSummaryScopeKey) private var toolSummaryScopeRaw = ToolSummaryScope.bashOnly.rawValue
private var choice: IntelligenceChoice { IntelligenceChoice(rawValue: choiceRaw) ?? .onDevice }
private var toolSummaryScope: ToolSummaryScope { ToolSummaryScope(rawValue: toolSummaryScopeRaw) ?? .bashOnly }
var body: some View {
Form {
Section("Model") {
Picker("Model", selection: $choiceRaw) {
ForEach(IntelligenceChoice.allCases) { option in
// Energy Aware switches on AC vs. battery, so it's meaningless on a
// desktop — gray it out there (desktops have no battery to detect).
Text(option.label)
.tag(option.rawValue)
.disabled(option == .energyAware && !PowerSource.hasBattery)
}
}
.pickerStyle(.radioGroup)
}
Section("Tool call summaries") {
Toggle("Summarize tool calls with AI", isOn: $summarizeToolCalls)
if summarizeToolCalls {
Picker("AI summarizes", selection: $toolSummaryScopeRaw) {
Text("Bash commands only").tag(ToolSummaryScope.bashOnly.rawValue)
Text("All tool calls").tag(ToolSummaryScope.all.rawValue)
}
}
}
Section("Status") {
LabeledContent("On-device model", value: IntelligenceAvailability.status(for: .onDevice))
LabeledContent("Private Cloud Compute", value: IntelligenceAvailability.status(for: .privateCloudCompute))
if PowerSource.hasBattery {
LabeledContent("Energy Aware", value: IntelligenceAvailability.status(for: .energyAware))
}
}
.font(.callout)
}
.formStyle(.grouped)
.onAppear { pushDefaults() }
.onChange(of: summarizeToolCalls) { _, _ in pushDefaults() }
.onChange(of: toolSummaryScopeRaw) { _, _ in pushDefaults() }
}
private func pushDefaults() {
store.summarizeToolCalls = summarizeToolCalls
store.toolSummaryScope = toolSummaryScope
}
}
/// The "Orbital" tab: the meshing service, device pairing, and the sibling Macs meshed with
/// this one (host side).
private struct RemoteSettingsTab: View {
var body: some View {
Form {
RemoteAccessSection()
}
.formStyle(.grouped)
}
}
private extension View {
/// Shared styling for the small explanatory captions beneath settings controls.
func settingsCaption() -> some View {
self.font(.caption)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
}
/// Canonical links into the public support site (`nucleic.blakeslee.xyz/support`). The full
/// how-to lives on the web, so a toggle keeps a one-line hint here and defers the detail —
/// setup steps, requirements, gotchas — to the matching article via `LearnMoreLink`.
private enum SupportURL {
private static let base = "https://nucleic.blakeslee.xyz"
static let home = URL(string: "\(base)/support.html")!
static let nucleicControl = URL(string: "\(base)/support/nucleic-control.html")!
static let sandboxes = URL(string: "\(base)/support/sandboxes.html")!
static let macVMs = URL(string: "\(base)/support/virtual-machines.html")!
static let macVMBaseImage = URL(string: "\(base)/support/macos-vm-base-image.html")!
static let macVMComputerUse = URL(string: "\(base)/support/macos-vm-computer-use.html")!
static let macVMAXAgent = URL(string: "\(base)/support/macos-vm-ax-agent.html")!
}
/// A compact, caption-weight "Learn more" link to a support article. Sits directly under a
/// toggle's short hint, replacing the long inline instructions that used to clutter the UI.
private struct LearnMoreLink: View {
private let label: String
private let url: URL
init(_ label: String = "Learn more", url: URL) {
self.label = label
self.url = url
}
var body: some View {
Link(destination: url) {
HStack(spacing: 3) {
Text(label)
Image(systemName: "arrow.up.right").imageScale(.small)
}
.font(.caption)
}
.fixedSize(horizontal: false, vertical: true)
}
}