Files
nucleic/Sources/NucleicApp/SettingsView.swift
T
abkslmandnucleic bb019516b0 Add App Bundle Integration
Nucleic-Session: 307EEE49-F1A9-4AF7-B2C1-72C8473CD6B4
Co-authored-by: Nucleic <[email protected]>
2026-07-08 05:03:35 -07:00

1762 lines
82 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.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 deleting = false
/// Drives the "Delete base image" destructive confirmation dialog.
@State private var confirmingBaseDelete = false
/// Whether the base-build diagnostic monitor window is currently shown.
@State private var observingBaseVM = false
/// Whether the "Advanced" power-user toggles under the service section are expanded.
@State private var serviceAdvancedExpanded = false
/// Host paths of the user-picked `.app` bundles to bake into the base image's `/Applications`
/// (Settings → "Included apps"). Loaded from / written back to `MacVMSettings.bundledAppPaths`.
@State private var bundledApps: [String] = []
/// Whether an app bundle is currently being dragged over the "Included apps" drop zone.
@State private var appDropTargeted = false
/// True while a "Copy to running VMs" push is in flight (disables the button).
@State private var pushingApps = false
/// The outcome of the last "Copy to running VMs" push, shown beneath the button.
@State private var appPushStatus: String?
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 }
}
// Descriptions stay visible whenever their toggle is — so the user knows what each does
// before turning it on, not only after.
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()
if computerUseByDefault {
Toggle("Semantic computer use", isOn: $axAgentEnabled)
.disabled(!serviceEnabled || !supported)
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. May "
+ "significantly increase token usage.")
.settingsCaption()
}
LearnMoreLink(url: SupportURL.macVMComputerUse)
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,
detail: phase == baseProgress.phase ? baseProgress.detail : nil)
}
}
Text("This can take a while — installing and provisioning macOS downloads several "
+ "gigabytes and reboots the guest a few times. You can leave this and keep working; "
+ "it runs in the background.")
.settingsCaption()
Button {
Task {
observingBaseVM.toggle()
await store.setMacVMBaseObserver(visible: observingBaseVM)
}
} label: {
Label(
observingBaseVM ? "Hide VM screen" : "Show VM screen",
systemImage: observingBaseVM ? "eye.slash" : "eye")
}
Text("Opens a live window of the base VM during the build — watch (or click into) a "
+ "build that's stuck, e.g. a first-boot Setup Assistant blocking the automated "
+ "provisioning.")
.settingsCaption()
} 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 || deleting)
LearnMoreLink("How base-image builds work", url: SupportURL.macVMBaseImage)
if let baseStatus, baseStatus.installed {
baseStatusView(baseStatus)
Button(role: .destructive) {
confirmingBaseDelete = true
} label: {
Label("Delete base image…", systemImage: "trash")
}
.disabled(!supported || building || deleting)
Text("Removes the installed base so the next build reinstalls macOS from "
+ "scratch — unlike Rebuild, which only re-runs provisioning on the existing "
+ "install. Re-downloads the ~14 GB restore image.")
.settingsCaption()
}
}
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)
}
}
.onChange(of: restoreImageChoice) { _, newValue in
applyIPSWChoice(newValue)
}
}
.disabled(!supported)
.confirmationDialog(
"Delete the installed base image?", isPresented: $confirmingBaseDelete,
titleVisibility: .visible
) {
Button("Delete & reinstall (keep download)", role: .destructive) {
Task { await deleteBaseImage(includingRestoreImages: false) }
}
Button("Delete & re-download everything", role: .destructive) {
Task { await deleteBaseImage(includingRestoreImages: true) }
}
Button("Cancel", role: .cancel) {}
} message: {
Text("Removes the installed macOS base so the next build reinstalls from scratch — "
+ "unlike Rebuild, which only re-provisions the existing install. Keep the cached "
+ "~14 GB restore image for a faster reinstall, or re-download it too for a fully "
+ "clean slate. Running sessions and any configured prebuilt base are unaffected.")
}
Section("Included apps") {
VStack(alignment: .leading, spacing: 10) {
Text("Drag in — or browse for — “.app” bundles to bake into the base image's "
+ "Applications folder, so every session VM has them. They're copied in when "
+ "you build or re-provision the base image above.")
.settingsCaption()
if bundledApps.isEmpty {
HStack(spacing: 6) {
Image(systemName: "square.and.arrow.down")
.foregroundStyle(.secondary)
Text("Drop “.app” bundles here, or use “Add app…”.")
.font(.caption).foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.vertical, 6)
} else {
ForEach(bundledApps, id: \.self) { path in
includedAppRow(path)
}
}
HStack {
Button {
chooseApps()
} label: {
Label("Add app…", systemImage: "plus")
}
if !bundledApps.isEmpty {
Spacer()
Text("^[\(bundledApps.count) app](inflect: true)")
.font(.caption).foregroundStyle(.secondary)
}
}
}
.padding(8)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: 8)
.strokeBorder(
appDropTargeted ? AnyShapeStyle(.tint) : AnyShapeStyle(.clear),
style: StrokeStyle(lineWidth: 2, dash: [5]))
)
.dropDestination(for: URL.self) { urls, _ in
addApps(urls)
} isTargeted: { appDropTargeted = $0 }
if let baseStatus, baseStatus.installed {
Text("Rebuild / re-provision the base image above to apply changes to the "
+ "already-installed base — or copy them straight into any running VMs below.")
.settingsCaption()
}
if !runningVMs.isEmpty {
Button {
Task { await pushAppsToRunningVMs() }
} label: {
Label(
"Copy to \(runningVMs.count) running VM\(runningVMs.count == 1 ? "" : "s")",
systemImage: "arrow.down.app")
}
.disabled(pushingApps || bundledApps.isEmpty)
Text("Streams the apps above into each running VM's Applications folder over the "
+ "guest agent (no rebuild, no restart). Existing copies are replaced.")
.settingsCaption()
if let appPushStatus {
Text(appPushStatus).font(.caption).foregroundStyle(.secondary)
}
}
}
.disabled(!serviceEnabled || !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()
bundledApps = MacVMSettings.bundledAppPaths
}
}
// MARK: - Included apps (baked into the base image's /Applications)
/// One row in the "Included apps" list: the app's Finder icon, its name, its full path, and a
/// remove control.
@ViewBuilder
private func includedAppRow(_ path: String) -> some View {
HStack(spacing: 10) {
Image(nsImage: NSWorkspace.shared.icon(forFile: path))
.resizable()
.frame(width: 22, height: 22)
VStack(alignment: .leading, spacing: 1) {
Text(Self.appDisplayName(path)).font(.callout)
Text(path)
.font(.caption2).foregroundStyle(.secondary)
.lineLimit(1).truncationMode(.middle)
}
Spacer()
Button {
removeApp(path)
} label: {
Image(systemName: "minus.circle.fill")
.foregroundStyle(.secondary)
}
.buttonStyle(.borderless)
.help("Remove")
}
}
/// The user-facing app name (bundle name minus the `.app` extension).
private static func appDisplayName(_ path: String) -> String {
((path as NSString).lastPathComponent as NSString).deletingPathExtension
}
/// Add dragged/browsed URLs, keeping only existing `.app` bundles and de-duplicating against the
/// current list. Returns whether anything was accepted (also the `.dropDestination` result).
@discardableResult
private func addApps(_ urls: [URL]) -> Bool {
let fm = FileManager.default
let apps = urls.filter {
$0.pathExtension.lowercased() == "app" && fm.fileExists(atPath: $0.path)
}
guard !apps.isEmpty else { return false }
var updated = bundledApps
for url in apps where !updated.contains(url.path) {
updated.append(url.path)
}
guard updated != bundledApps else { return false }
bundledApps = updated
MacVMSettings.setBundledAppPaths(bundledApps)
return true
}
/// Drop an app from the include list (does not touch the source bundle on disk).
private func removeApp(_ path: String) {
bundledApps.removeAll { $0 == path }
MacVMSettings.setBundledAppPaths(bundledApps)
}
/// Copy the configured apps into every running VM over vsock (no rebuild), surfacing the outcome.
private func pushAppsToRunningVMs() async {
pushingApps = true
appPushStatus = nil
defer { pushingApps = false }
appPushStatus = await store.pushIncludedAppsToRunningMacVMs()
}
/// Browse for one or more `.app` bundles to include, defaulting to `/Applications`.
private func chooseApps() {
let panel = NSOpenPanel()
panel.canChooseFiles = true
panel.canChooseDirectories = false
panel.allowsMultipleSelection = true
panel.allowedContentTypes = [.application]
panel.treatsFilePackagesAsDirectories = false
panel.directoryURL = URL(fileURLWithPath: "/Applications")
panel.message = "Choose “.app” bundles to include in the macOS VM base image"
panel.prompt = "Add"
if panel.runModal() == .OK { addApps(panel.urls) }
}
// 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: point the restore-image URL at the chosen catalog image (empty for a
/// not-yet-published placeholder, which the engine treats as "latest supported").
private func applyIPSWChoice(_ id: String) {
restoreImageURL = Self.ipswChoice(for: id)?.url ?? ""
}
/// Resolve the picker selection on appearance: an unknown/legacy value falls back to the default
/// image and the chosen image's URL is kept in sync. Also clears any stale manual restore-source
/// overrides left by the removed "Advanced" panel so they can't silently override the picker.
private func reconcileBaseImageChoice() {
UserDefaults.standard.removeObject(forKey: MacVMSettings.basePrebuiltPathKey)
UserDefaults.standard.removeObject(forKey: MacVMSettings.restoreImagePathKey)
if Self.ipswChoice(for: restoreImageChoice) == nil { restoreImageChoice = "macos27-devbeta" }
applyIPSWChoice(restoreImageChoice)
}
/// Post-build status: exec-ready vs. fully computer-use-ready. Semantic AX ships in the base
/// image and its grants are written automatically during the build, so once the base is
/// provisioned everything — builds, computer use, and semantic AX — is ready.
@ViewBuilder
private func baseStatusView(_ status: MacVMBaseStatus) -> some View {
// Computer use works host-side on any installed base; a fully provisioned base adds the toolchain.
if status.provisioned {
Label("Ready — builds + computer use + semantic AX.", systemImage: "checkmark.seal.fill")
.font(.caption).foregroundStyle(.green)
} else {
Label("Computer use ready. Toolchain provisioning incomplete — rebuild.",
systemImage: "checkmark.circle").font(.caption)
}
}
private func runBaseBuild() async {
building = true
buildError = nil
defer { building = false }
do {
try await store.buildMacVMBaseImage(localRestoreImagePath: nil)
} catch {
buildError = "\(error)"
}
}
/// Delete the installed base (and cached restore image) so the next build reinstalls macOS from
/// scratch. Refreshes status immediately so the panel flips back to "Build base image".
private func deleteBaseImage(includingRestoreImages: Bool) async {
deleting = true
buildError = nil
defer { deleting = false }
do {
try await store.deleteMacVMBaseImage(includingRestoreImages: includingRestoreImages)
baseStatus = await store.macVMBaseStatus()
baseOSVersion = await store.macVMBaseOSVersion()
} catch {
buildError = "\(error)"
}
}
/// 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
// The build ended (surface detached, monitor auto-hidden) — reset the toggle to match.
if progress == nil, observingBaseVM {
observingBaseVM = false
await store.setMacVMBaseObserver(visible: false)
}
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?, detail: String? = nil
) -> 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)
}
if let detail, !detail.isEmpty {
Text(detail)
.font(.caption2)
.foregroundStyle(.secondary)
.lineLimit(2)
.textSelection(.enabled)
}
}
}
}
}
}
/// 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)
}
}