Nucleic-Session: 307EEE49-F1A9-4AF7-B2C1-72C8473CD6B4 Co-authored-by: Nucleic <[email protected]>
1762 lines
82 KiB
Swift
1762 lines
82 KiB
Swift
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)
|
||
}
|
||
}
|