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 { 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) } }