2735 lines
130 KiB
Swift
2735 lines
130 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.
|
||
// The main-app sidebar is 306pt; Settings needs 30% less width (214.2pt).
|
||
@State private var sidebarColumn = SidebarColumnController(
|
||
fixedWidth: SidebarColumnController.defaultFixedWidth * 0.7)
|
||
|
||
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: 30% narrower than the main-app sidebar. Equal min/ideal/max sets the
|
||
// SwiftUI hint; the hard pin (non-draggable divider) is enforced in AppKit via
|
||
// `sidebarColumn.fixedWidth`.
|
||
.navigationSplitViewColumnWidth(
|
||
min: sidebarColumn.fixedWidth,
|
||
ideal: sidebarColumn.fixedWidth,
|
||
max: sidebarColumn.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). The narrower sidebar leaves more room for detail. Height stays
|
||
// flexible.
|
||
.frame(minWidth: 840, idealWidth: 840, maxWidth: 840, 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 .covalence: 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 covalence = 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 .covalence: "Covalence"
|
||
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 .covalence: "circle.dotted.and.circle"
|
||
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, .covalence]
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 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
|
||
@AppStorage(UltraGlass.enabledKey) private var ultraGlass = false
|
||
|
||
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) }
|
||
}
|
||
}
|
||
|
||
Section("Materials") {
|
||
Toggle("Full Glass", isOn: $ultraGlass)
|
||
Text("Full Glass mode replaces most opaque surfaces with the system glass material.")
|
||
.font(.caption).foregroundStyle(.secondary)
|
||
}
|
||
}
|
||
.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
|
||
@State private var sleepBlocker = SleepBlocker.shared
|
||
@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("Smart Sleep", isOn: Binding(
|
||
get: { blockSleep },
|
||
set: { isOn in
|
||
blockSleep = isOn
|
||
sleepBlocker.setEnabled(isOn)
|
||
}))
|
||
Text("Blocks sleep while agents are running, uses DarkWake when no agents are "
|
||
+ "running to preserve power while staying up-to-date.")
|
||
.settingsCaption()
|
||
if blockSleep, sleepBlocker.requiresHelperApproval {
|
||
Button("Approve Smart Sleep Helper…") {
|
||
sleepBlocker.openHelperApprovalSettings()
|
||
}
|
||
Text("In System Settings, go to General > Login Items & Extensions, then turn "
|
||
+ "on Nucleic under Allow in the Background.")
|
||
.settingsCaption()
|
||
}
|
||
if blockSleep, let error = sleepBlocker.lastError {
|
||
Text(error)
|
||
.settingsCaption()
|
||
.foregroundStyle(.red)
|
||
}
|
||
}
|
||
|
||
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.hostExecEnabledKey) private var hostExecEnabled = true
|
||
@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)
|
||
}
|
||
|
||
Section("Host access") {
|
||
Toggle(isOn: $hostExecEnabled) {
|
||
VStack(alignment: .leading, spacing: 2) {
|
||
Text("Allow the host_exec tool")
|
||
Text("Lets sandboxed agents request to run commands on the host machine "
|
||
+ "(outside the container) — e.g. building or running a macOS binary. "
|
||
+ "Each call still needs your explicit approval. Turn this off to remove "
|
||
+ "the escape hatch entirely: the tool is never offered and the agent is "
|
||
+ "never told it exists, regardless of a project's \"Allow host build/run\" "
|
||
+ "setting.")
|
||
.font(.caption).foregroundStyle(.secondary)
|
||
.fixedSize(horizontal: false, vertical: true)
|
||
}
|
||
}
|
||
.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.autoOpenVMMonitorsKey) private var autoOpenVMMonitors = false
|
||
@AppStorage(MacVMSettings.pictureInPictureVMMonitorsKey) private var pipVMMonitors = true
|
||
@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
|
||
|
||
// Linux VM service (a separate, independently-gated guest OS on the same Virtualization stack).
|
||
@AppStorage(MacVMSettings.linuxServiceEnabledKey) private var linuxServiceEnabled = false
|
||
@AppStorage(MacVMSettings.linuxExposeByDefaultKey) private var linuxExposeByDefault = true
|
||
@AppStorage(MacVMSettings.linuxComputerUseByDefaultKey) private var linuxComputerUseByDefault = true
|
||
@AppStorage(MacVMSettings.linuxAxAgentEnabledKey) private var linuxAxAgentEnabled = false
|
||
|
||
// Cross-guest computer-use default + the agent-container tool gate (a container-service setting,
|
||
// surfaced here alongside the VM controls so all virtualization knobs live together).
|
||
@AppStorage(MacVMSettings.defaultComputerUseVMTypeKey)
|
||
private var defaultComputerUseVMType = ComputerUseVMType.linux.rawValue
|
||
@AppStorage(ContainerServiceSettings.agentContainersEnabledKey)
|
||
private var agentContainersEnabled = true
|
||
@AppStorage(ContainerServiceSettings.serviceEnabledKey) private var containerServiceEnabled = false
|
||
|
||
@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
|
||
// Linux base build/state (the build-progress bar `baseProgress` is shared — only one base builds
|
||
// at a time — and routed to whichever section started it via `linuxBuilding`).
|
||
@State private var linuxBaseStatus: MacVMBaseStatus?
|
||
@State private var linuxBuildError: String?
|
||
@State private var linuxBuilding = false
|
||
@State private var linuxDeleting = false
|
||
@State private var confirmingLinuxDelete = 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 (design-only) Linux "Packages…" panel is presented.
|
||
@State private var showingPackages = 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?
|
||
/// True while an "Add to base image" injection (boot base → copy → power off) is in flight.
|
||
@State private var addingToBase = false
|
||
/// The outcome of the last "Add to base image" injection, shown beneath the button.
|
||
@State private var baseAddStatus: String?
|
||
/// Ids of the common packages (``MacVMPackage``) opted into installing in the base image (Settings →
|
||
/// "Common packages"). Loaded from / written back to `MacVMSettings.selectedPackageIDs`.
|
||
@State private var selectedPackageIDs: Set<String> = []
|
||
/// True while a "Copy to running VMs" package push is in flight (disables the button).
|
||
@State private var pushingPackages = false
|
||
/// The outcome of the last package "Copy to running VMs" push, shown beneath the button.
|
||
@State private var packagePushStatus: String?
|
||
/// True while an "Add to base image" package injection is in flight.
|
||
@State private var addingPackagesToBase = false
|
||
/// The outcome of the last package "Add to base image" injection, shown beneath the button.
|
||
@State private var packageAddStatus: String?
|
||
|
||
private var supported: Bool { store.macVMSupported }
|
||
|
||
/// True when every configured "Included app" has already been staged into the installed base image
|
||
/// — either by a prior Import or automatically during base provisioning (both record the app's path
|
||
/// in ``MacVMBaseStatus/installedAppPaths``). With nothing left to import, the Import button greys
|
||
/// out. `false` when there are no configured apps (there's simply nothing to import).
|
||
private var allIncludedAppsImported: Bool {
|
||
guard let baseStatus, !bundledApps.isEmpty else { return false }
|
||
let imported = Set(baseStatus.installedAppPaths)
|
||
return bundledApps.allSatisfy { imported.contains($0) }
|
||
}
|
||
|
||
/// True while at least one of the two guest services is enabled — the gate for the shared
|
||
/// session-default and resource controls, which are meaningless with no VM to run.
|
||
private var anyServiceEnabled: Bool { serviceEnabled || linuxServiceEnabled }
|
||
|
||
/// Single pane covering both guest OSes (macOS, Linux) and the knobs shared by both. The top of
|
||
/// the pane holds the cross-guest switches — which engines are on, how the agent may drive them,
|
||
/// and session defaults — so the per-platform sections below can focus purely on base images and
|
||
/// the apps/packages baked into them.
|
||
var body: some View {
|
||
Form {
|
||
virtualizationEngineSection
|
||
computerUseSection
|
||
macOSSection
|
||
linuxSection
|
||
resourcesSection
|
||
runningNowSection
|
||
}
|
||
.formStyle(.grouped)
|
||
.task { await poll() }
|
||
.onReceive(NotificationCenter.default.publisher(for: .macVMObserverWindowClosed)) { note in
|
||
// The user closed the base-build diagnostic monitor window directly (red X) instead of via
|
||
// "Hide VM screen" — flip the toggle back to "Show" and clear the engine's remembered
|
||
// request so it doesn't pop back. (Ignore closes of any other surface's viewer.)
|
||
guard note.object as? String == MacVMEngine.baseProvisionSurfaceName, observingBaseVM
|
||
else { return }
|
||
observingBaseVM = false
|
||
Task { await store.setMacVMBaseObserver(visible: false) }
|
||
}
|
||
.onAppear {
|
||
reconcileBaseImageChoice()
|
||
bundledApps = MacVMSettings.bundledAppPaths
|
||
selectedPackageIDs = Set(MacVMSettings.selectedPackageIDs)
|
||
// Exposing a session's own VMs is now always-on — normalize any stored opt-out.
|
||
exposeByDefault = true
|
||
linuxExposeByDefault = true
|
||
}
|
||
.sheet(isPresented: $showingPackages) { LinuxPackagesSheet() }
|
||
}
|
||
|
||
// MARK: - Virtualization engine (which guests are available)
|
||
|
||
@ViewBuilder private var virtualizationEngineSection: some View {
|
||
Section("Virtualization engine") {
|
||
Toggle("macOS", isOn: $serviceEnabled)
|
||
.disabled(!supported)
|
||
Toggle("Linux", isOn: $linuxServiceEnabled)
|
||
.disabled(!supported)
|
||
if !supported {
|
||
HStack(alignment: .firstTextBaseline, spacing: 6) {
|
||
Image(systemName: "exclamationmark.triangle.fill").foregroundStyle(.orange)
|
||
Text("Virtual machines require an Apple silicon Mac.").font(.caption)
|
||
}
|
||
} else {
|
||
LearnMoreLink(url: SupportURL.virtualMachines)
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - Shared session defaults
|
||
|
||
/// True when both guests are set to allow kernel-level use — the "on" state of the merged
|
||
/// "Kernel-level use" toggle, and the gate for whether "Semantic understanding" is shown.
|
||
private var kernelUseBoth: Bool { computerUseByDefault && linuxComputerUseByDefault }
|
||
|
||
/// Binding for the merged "Kernel-level use" toggle. Writes both guests together; turning it off
|
||
/// also clears both guests' semantic flag, which builds on kernel-level use.
|
||
private var kernelUseBothBinding: Binding<Bool> {
|
||
Binding(
|
||
get: { kernelUseBoth },
|
||
set: {
|
||
computerUseByDefault = $0
|
||
linuxComputerUseByDefault = $0
|
||
if !$0 { axAgentEnabled = false; linuxAxAgentEnabled = false }
|
||
})
|
||
}
|
||
|
||
/// Binding for the merged "Semantic understanding" toggle (both guests written together).
|
||
private var semanticBothBinding: Binding<Bool> {
|
||
Binding(
|
||
get: { axAgentEnabled && linuxAxAgentEnabled },
|
||
set: { axAgentEnabled = $0; linuxAxAgentEnabled = $0 })
|
||
}
|
||
|
||
// MARK: - macOS (base image + included apps)
|
||
|
||
@ViewBuilder private var macOSSection: some View {
|
||
Section("macOS virtual machines") {
|
||
if let baseOSVersion {
|
||
LabeledContent("Base image", value: "macOS \(baseOSVersion)")
|
||
}
|
||
if building, baseProgress == nil, !linuxBuilding {
|
||
// `building` flips true synchronously on click, but the engine's first progress
|
||
// phase can be up to ~a minute out — it loads/validates the cached (~14 GB) restore
|
||
// image, or stages the provisioning share and boots the guest, before publishing a
|
||
// phase. Without this the panel just shows the greyed-out "…" menu and the action
|
||
// feels dead; a spinner bridges the gap until the real phase checklist takes over.
|
||
HStack(spacing: 8) {
|
||
ProgressView().controlSize(.small)
|
||
Text("Starting base image build…").settingsCaption()
|
||
}
|
||
} else if let baseProgress, !linuxBuilding {
|
||
VStack(alignment: .leading, spacing: 8) {
|
||
ForEach(seenBuildPhases, id: \.self) { phase in
|
||
buildStageRow(
|
||
phase, os: .macOS,
|
||
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()
|
||
// Once the build reaches `.ready` the provisioning VM has already powered off and its
|
||
// surface detached (there's no live screen left to show), so drop the toggle entirely.
|
||
if baseProgress.phase != .ready {
|
||
Button {
|
||
Task {
|
||
observingBaseVM.toggle()
|
||
await store.setMacVMBaseObserver(visible: observingBaseVM)
|
||
}
|
||
} label: {
|
||
Label(
|
||
observingBaseVM ? "Hide VM screen" : "Show VM screen",
|
||
systemImage: observingBaseVM ? "eye.slash" : "eye")
|
||
}
|
||
}
|
||
} else if let baseStatus, baseStatus.installed {
|
||
// Built: the status line reads as "Ready — …", with the destructive/rebuild actions
|
||
// tucked into a trailing "…" menu so the settled state stays uncluttered.
|
||
HStack {
|
||
baseStatusView(baseStatus)
|
||
Spacer()
|
||
Menu {
|
||
Button {
|
||
Task { await runBaseBuild() }
|
||
} label: {
|
||
Label("Rebuild / re-provision base image", systemImage: "arrow.clockwise")
|
||
}
|
||
Button(role: .destructive) {
|
||
confirmingBaseDelete = true
|
||
} label: {
|
||
Label("Delete base image…", systemImage: "trash")
|
||
}
|
||
} label: {
|
||
Image(systemName: "ellipsis")
|
||
}
|
||
.menuStyle(.borderlessButton)
|
||
.menuIndicator(.hidden)
|
||
.fixedSize()
|
||
.disabled(!supported || building || deleting)
|
||
}
|
||
} else {
|
||
Button {
|
||
Task { await runBaseBuild() }
|
||
} label: {
|
||
Label("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 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)
|
||
}
|
||
|
||
includedAppsControls
|
||
|
||
commonPackagesControls
|
||
|
||
}
|
||
.disabled(!serviceEnabled || !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.")
|
||
}
|
||
}
|
||
|
||
/// The "Included apps" controls — a drop zone plus the import/copy actions — merged inline into
|
||
/// the macOS section. Dropped `.app` bundles are baked into the base image.
|
||
@ViewBuilder private var includedAppsControls: some View {
|
||
VStack(alignment: .leading, spacing: 10) {
|
||
if !bundledApps.isEmpty {
|
||
ForEach(bundledApps, id: \.self) { path in
|
||
includedAppRow(path)
|
||
}
|
||
}
|
||
|
||
HStack {
|
||
Button {
|
||
chooseApps()
|
||
} label: {
|
||
Label("Add app…", systemImage: "plus")
|
||
}
|
||
if let baseStatus, baseStatus.installed {
|
||
Button {
|
||
Task { await addAppsToBase() }
|
||
} label: {
|
||
Label(
|
||
addingToBase ? "Importing…" : "Import",
|
||
systemImage: "square.and.arrow.down.on.square")
|
||
}
|
||
.disabled(
|
||
addingToBase || building || deleting || bundledApps.isEmpty
|
||
|| allIncludedAppsImported)
|
||
}
|
||
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 baseAddStatus {
|
||
Text(baseAddStatus).font(.caption).foregroundStyle(.secondary)
|
||
}
|
||
|
||
if !runningVMs.isEmpty {
|
||
Button {
|
||
Task { await pushAppsToRunningVMs() }
|
||
} label: {
|
||
HStack(spacing: 6) {
|
||
if pushingApps { ProgressView().controlSize(.small) }
|
||
Label(
|
||
"Copy to \(runningVMs.count) running VM\(runningVMs.count == 1 ? "" : "s")",
|
||
systemImage: "arrow.down.app")
|
||
}
|
||
}
|
||
.disabled(pushingApps || bundledApps.isEmpty)
|
||
if let appPushStatus {
|
||
Text(appPushStatus).font(.caption).foregroundStyle(.secondary)
|
||
}
|
||
}
|
||
}
|
||
|
||
/// The "Common packages" controls — a checklist of curated tools the guest fetches and installs
|
||
/// itself (e.g. the latest Chrome), plus the same import/copy actions as "Included apps". Entries
|
||
/// the platform can't automate yet (``MacVMPackage/available`` == false) show grayed out with a
|
||
/// note, so the UI advertises what's coming without letting it be selected.
|
||
@ViewBuilder private var commonPackagesControls: some View {
|
||
VStack(alignment: .leading, spacing: 10) {
|
||
Text("Common packages")
|
||
.font(.callout.weight(.medium))
|
||
Text("Tools the VM downloads and installs itself — no bundle to supply.")
|
||
.font(.caption).foregroundStyle(.secondary)
|
||
|
||
ForEach(MacVMPackage.catalog) { pkg in
|
||
commonPackageRow(pkg)
|
||
}
|
||
|
||
HStack {
|
||
if let baseStatus, baseStatus.installed {
|
||
Button {
|
||
Task { await addPackagesToBase() }
|
||
} label: {
|
||
Label(
|
||
addingPackagesToBase ? "Importing…" : "Import",
|
||
systemImage: "square.and.arrow.down.on.square")
|
||
}
|
||
.disabled(addingPackagesToBase || building || deleting || selectedPackageIDs.isEmpty)
|
||
}
|
||
if !selectedPackageIDs.isEmpty {
|
||
Spacer()
|
||
Text("^[\(selectedPackageIDs.count) package](inflect: true) selected")
|
||
.font(.caption).foregroundStyle(.secondary)
|
||
}
|
||
}
|
||
}
|
||
.padding(8)
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
|
||
if let packageAddStatus {
|
||
Text(packageAddStatus).font(.caption).foregroundStyle(.secondary)
|
||
}
|
||
|
||
if !runningVMs.isEmpty {
|
||
Button {
|
||
Task { await pushPackagesToRunningVMs() }
|
||
} label: {
|
||
HStack(spacing: 6) {
|
||
if pushingPackages { ProgressView().controlSize(.small) }
|
||
Label(
|
||
"Install in \(runningVMs.count) running VM\(runningVMs.count == 1 ? "" : "s")",
|
||
systemImage: "arrow.down.app")
|
||
}
|
||
}
|
||
.disabled(pushingPackages || selectedPackageIDs.isEmpty)
|
||
if let packagePushStatus {
|
||
Text(packagePushStatus).font(.caption).foregroundStyle(.secondary)
|
||
}
|
||
}
|
||
}
|
||
|
||
/// One row in the "Common packages" checklist: a toggle bound to the selection for installable
|
||
/// packages, or a disabled row with the "coming soon" note for placeholders.
|
||
@ViewBuilder private func commonPackageRow(_ pkg: MacVMPackage) -> some View {
|
||
Toggle(isOn: Binding(
|
||
get: { selectedPackageIDs.contains(pkg.id) },
|
||
set: { on in
|
||
if on { selectedPackageIDs.insert(pkg.id) } else { selectedPackageIDs.remove(pkg.id) }
|
||
MacVMSettings.setSelectedPackageIDs(MacVMPackage.catalog.map(\.id)
|
||
.filter { selectedPackageIDs.contains($0) })
|
||
})
|
||
) {
|
||
VStack(alignment: .leading, spacing: 1) {
|
||
Text(pkg.name).font(.callout)
|
||
let subtext = pkg.available ? pkg.summary : (pkg.unavailableNote ?? pkg.summary)
|
||
if !subtext.isEmpty {
|
||
Text(subtext)
|
||
.font(.caption2).foregroundStyle(.secondary)
|
||
}
|
||
}
|
||
}
|
||
.toggleStyle(.checkbox)
|
||
.disabled(!pkg.isInstallable)
|
||
}
|
||
|
||
// MARK: - Linux guest sections
|
||
|
||
@ViewBuilder private var linuxSection: some View {
|
||
Section("Linux virtual machines") {
|
||
if let s = linuxBaseStatus, s.installed {
|
||
LabeledContent("Base image", value: linuxDistroLabel(s))
|
||
}
|
||
if let baseProgress, linuxBuilding {
|
||
VStack(alignment: .leading, spacing: 8) {
|
||
ForEach(seenBuildPhases, id: \.self) { phase in
|
||
buildStageRow(
|
||
phase, os: .linux,
|
||
active: phase == baseProgress.phase && phase != .ready,
|
||
fraction: baseProgress.fraction,
|
||
detail: phase == baseProgress.phase ? baseProgress.detail : nil)
|
||
}
|
||
}
|
||
Text("This can take a while — downloading and assembling the Linux base, then "
|
||
+ "installing the desktop and toolchain. It runs in the background; you can keep "
|
||
+ "working.")
|
||
.settingsCaption()
|
||
} else if let s = linuxBaseStatus, s.installed {
|
||
HStack {
|
||
linuxBaseStatusView(s)
|
||
Spacer()
|
||
Menu {
|
||
Button {
|
||
Task { await runLinuxBaseBuild() }
|
||
} label: {
|
||
Label("Rebuild Linux base image", systemImage: "arrow.clockwise")
|
||
}
|
||
Button(role: .destructive) {
|
||
confirmingLinuxDelete = true
|
||
} label: {
|
||
Label("Delete Linux base image…", systemImage: "trash")
|
||
}
|
||
} label: {
|
||
Image(systemName: "ellipsis")
|
||
}
|
||
.menuStyle(.borderlessButton)
|
||
.menuIndicator(.hidden)
|
||
.fixedSize()
|
||
.disabled(!supported || linuxBuilding || linuxDeleting)
|
||
}
|
||
} else {
|
||
Button {
|
||
Task { await runLinuxBaseBuild() }
|
||
} label: {
|
||
Label("Build Linux base image", systemImage: "square.and.arrow.down.on.square")
|
||
.padding(.vertical, 4)
|
||
}
|
||
.disabled(!linuxServiceEnabled || !supported || linuxBuilding || linuxDeleting || building)
|
||
}
|
||
if let linuxBuildError {
|
||
Text(linuxBuildError).font(.caption).foregroundStyle(.red)
|
||
}
|
||
|
||
Button {
|
||
showingPackages = true
|
||
} label: {
|
||
Label("Packages…", systemImage: "shippingbox")
|
||
}
|
||
}
|
||
.disabled(!linuxServiceEnabled || !supported)
|
||
.confirmationDialog(
|
||
"Delete the installed Linux base image?", isPresented: $confirmingLinuxDelete,
|
||
titleVisibility: .visible
|
||
) {
|
||
Button("Delete & rebuild", role: .destructive) {
|
||
Task { await deleteLinuxBaseImage() }
|
||
}
|
||
Button("Cancel", role: .cancel) {}
|
||
} message: {
|
||
Text("Removes the installed Linux base so the next build rebuilds it from scratch. "
|
||
+ "Running sessions are unaffected.")
|
||
}
|
||
}
|
||
|
||
/// The Linux base "Ready — …" status line (or an "incomplete, rebuild" note), mirroring
|
||
/// ``baseStatusView`` for macOS.
|
||
@ViewBuilder private func linuxBaseStatusView(_ status: MacVMBaseStatus) -> some View {
|
||
if status.provisioned {
|
||
Label("Ready — desktop, builds, and computer use.", systemImage: "checkmark.seal.fill")
|
||
.font(.caption).foregroundStyle(.green)
|
||
} else {
|
||
Label("Installed — desktop/toolchain provisioning incomplete; rebuild.",
|
||
systemImage: "checkmark.circle").font(.caption)
|
||
}
|
||
}
|
||
|
||
/// A friendly distro label for the installed Linux base (e.g. "Ubuntu 26.04 LTS"), the Linux
|
||
/// counterpart to the macOS "Installed guest macOS" version line.
|
||
private func linuxDistroLabel(_ status: MacVMBaseStatus) -> String {
|
||
if let v = status.osVersion, !v.isEmpty { return "Ubuntu \(v) LTS" }
|
||
return "Ubuntu LTS"
|
||
}
|
||
|
||
// MARK: - Shared sections (apply across both guests)
|
||
|
||
@ViewBuilder private var computerUseSection: some View {
|
||
Section("Computer use") {
|
||
// The two kinds of computer use, applied to whichever guests are enabled. Semantic
|
||
// understanding builds on kernel-level use, so it only appears once that's on.
|
||
Toggle("Kernel-level use", isOn: kernelUseBothBinding)
|
||
.disabled(!anyServiceEnabled || !supported)
|
||
if kernelUseBoth {
|
||
Toggle("Semantic understanding", isOn: semanticBothBinding)
|
||
.disabled(!anyServiceEnabled || !supported)
|
||
LearnMoreLink(url: SupportURL.computerUse)
|
||
}
|
||
|
||
Picker("Default computer use VM type", selection: $defaultComputerUseVMType) {
|
||
ForEach(ComputerUseVMType.allCases, id: \.rawValue) { type in
|
||
Text(type.label).tag(type.rawValue)
|
||
}
|
||
}
|
||
.disabled(!supported)
|
||
|
||
Toggle("Let agents create lightweight containers for simpler work",
|
||
isOn: $agentContainersEnabled)
|
||
.disabled(!containerServiceEnabled)
|
||
|
||
Toggle("Automatically open VM monitors", isOn: $autoOpenVMMonitors)
|
||
.disabled(!anyServiceEnabled || !supported)
|
||
Text("Shows the open chat's VM screen in the right column while it has a VM running, "
|
||
+ "and closes it again when you switch to a chat without one.")
|
||
.settingsCaption()
|
||
|
||
Toggle("Float VM monitors in Picture in Picture", isOn: $pipVMMonitors)
|
||
.disabled(!anyServiceEnabled || !supported)
|
||
.onChange(of: pipVMMonitors) { _, on in
|
||
if !on { VMMonitorPiPController.shared.hide() } // retract immediately
|
||
}
|
||
Text("Keeps the open chat's VM screen in a small window that floats above all other "
|
||
+ "windows — even other apps — while it has a VM running. Look-only: it never "
|
||
+ "captures your mouse or keyboard.")
|
||
.settingsCaption()
|
||
}
|
||
}
|
||
|
||
/// Design-only preview of the Linux base "Packages…" panel: many ways to customize what's baked
|
||
/// into the base image, all disabled until the backing is implemented.
|
||
private struct LinuxPackagesSheet: View {
|
||
@Environment(\.dismiss) private var dismiss
|
||
@State private var aptPackages = ""
|
||
@State private var snapPackages = ""
|
||
@State private var pipPackages = ""
|
||
@State private var repositories = ""
|
||
@State private var provisioningScript = ""
|
||
|
||
var body: some View {
|
||
VStack(spacing: 0) {
|
||
HStack {
|
||
VStack(alignment: .leading, spacing: 2) {
|
||
Text("Linux base packages").font(.headline)
|
||
Text("Customize what's baked into the Linux base image.")
|
||
.font(.caption).foregroundStyle(.secondary)
|
||
}
|
||
Spacer()
|
||
Text("Coming soon")
|
||
.font(.caption.weight(.medium))
|
||
.padding(.horizontal, 8).padding(.vertical, 3)
|
||
.background(Capsule().fill(.quaternary))
|
||
}
|
||
.padding(16)
|
||
|
||
Divider()
|
||
|
||
Form {
|
||
Section("System packages") {
|
||
TextField("APT packages", text: $aptPackages, prompt: Text("git, build-essential, jq"))
|
||
TextField("Snap packages", text: $snapPackages, prompt: Text("code, chromium"))
|
||
}
|
||
Section("Language packages") {
|
||
TextField("pip packages", text: $pipPackages, prompt: Text("numpy, requests"))
|
||
}
|
||
Section("Repositories") {
|
||
TextField("APT repositories / PPAs", text: $repositories,
|
||
prompt: Text("ppa:deadsnakes/ppa"))
|
||
Button {
|
||
} label: {
|
||
Label("Import package list…", systemImage: "square.and.arrow.down")
|
||
}
|
||
}
|
||
Section("Custom provisioning") {
|
||
TextEditor(text: $provisioningScript)
|
||
.frame(minHeight: 80)
|
||
.font(.system(.caption, design: .monospaced))
|
||
Text("Runs during the base build, after packages are installed.")
|
||
.settingsCaption()
|
||
}
|
||
}
|
||
.formStyle(.grouped)
|
||
.disabled(true) // design-only: nothing here is wired up yet
|
||
|
||
Divider()
|
||
|
||
HStack {
|
||
Spacer()
|
||
Button("Done") { dismiss() }
|
||
.keyboardShortcut(.defaultAction)
|
||
}
|
||
.padding(16)
|
||
}
|
||
.frame(width: 460, height: 560)
|
||
}
|
||
}
|
||
|
||
@ViewBuilder private var resourcesSection: some View {
|
||
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("Applies to macOS and Linux guests alike. 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 && !linuxServiceEnabled) || !supported)
|
||
}
|
||
|
||
@ViewBuilder private var runningNowSection: some View {
|
||
if !runningVMs.isEmpty {
|
||
Section("Running now") {
|
||
ForEach(runningVMs, id: \.name) { vm in
|
||
// Prefer the NAT IP when the guest has one, but never let a missing IP read as
|
||
// "booting…" — the vsock control plane leaves a fully-ready guest with no lease, so
|
||
// fall back to the real readiness state instead of the (absent) IP.
|
||
LabeledContent(vm.name, value: vm.ipAddress ?? (vm.booting ? "booting…" : "running"))
|
||
}
|
||
}
|
||
} else {
|
||
Section("Running now") {
|
||
Text("No virtual machines are running right now.")
|
||
.settingsCaption()
|
||
}
|
||
}
|
||
}
|
||
|
||
// 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()
|
||
}
|
||
|
||
/// Inject the configured apps into the existing base image (boot → copy → power off), so new VMs
|
||
/// include them without a full rebuild. Refreshes base status afterward.
|
||
private func addAppsToBase() async {
|
||
addingToBase = true
|
||
// Live status while the (multi-minute) boot → copy → power-off runs: the base image also
|
||
// appears in the sidebar's Virtual Machines section while this is in flight.
|
||
baseAddStatus = "Booting the base image to install apps — this can take a few minutes. "
|
||
+ "It appears in the sidebar under Virtual Machines while it runs."
|
||
defer { addingToBase = false }
|
||
baseAddStatus = await store.addIncludedAppsToMacVMBase()
|
||
baseStatus = await store.macVMBaseStatus()
|
||
}
|
||
|
||
/// Install the selected common packages into every running VM over vsock (no rebuild).
|
||
private func pushPackagesToRunningVMs() async {
|
||
pushingPackages = true
|
||
packagePushStatus = nil
|
||
defer { pushingPackages = false }
|
||
packagePushStatus = await store.pushSelectedPackagesToRunningMacVMs()
|
||
}
|
||
|
||
/// Install the selected common packages into the existing base image (boot → install → power off),
|
||
/// so new VMs include them without a full rebuild. Refreshes base status afterward.
|
||
private func addPackagesToBase() async {
|
||
addingPackagesToBase = true
|
||
packageAddStatus = "Booting the base image to install packages — this can take a few minutes. "
|
||
+ "It appears in the sidebar under Virtual Machines while it runs."
|
||
defer { addingPackagesToBase = false }
|
||
packageAddStatus = await store.addSelectedPackagesToMacVMBase()
|
||
baseStatus = await store.macVMBaseStatus()
|
||
}
|
||
|
||
/// 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)"
|
||
}
|
||
}
|
||
|
||
/// Kick off the Linux base build. `linuxBuilding` routes the shared progress bar to the Linux
|
||
/// section (only one base builds at a time).
|
||
private func runLinuxBaseBuild() async {
|
||
linuxBuilding = true
|
||
linuxBuildError = nil
|
||
defer { linuxBuilding = false }
|
||
do {
|
||
try await store.buildLinuxVMBaseImage()
|
||
} catch {
|
||
linuxBuildError = "\(error)"
|
||
}
|
||
linuxBaseStatus = await store.linuxVMBaseStatus()
|
||
}
|
||
|
||
/// Delete the installed Linux base so the next build rebuilds from scratch.
|
||
private func deleteLinuxBaseImage() async {
|
||
linuxDeleting = true
|
||
linuxBuildError = nil
|
||
defer { linuxDeleting = false }
|
||
do {
|
||
try await store.deleteLinuxVMBaseImage()
|
||
linuxBaseStatus = await store.linuxVMBaseStatus()
|
||
} catch {
|
||
linuxBuildError = "\(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
|
||
// Re-derive which section the shared build belongs to from engine state, not just the
|
||
// view-local flags: those are set optimistically on click but reset to `false` whenever the
|
||
// user navigates out of and back into Settings (the tab's `@State` is torn down), which used
|
||
// to make an in-flight build's progress vanish — or worse, a live Linux build re-render under
|
||
// the macOS section. The engine flag outlives the view, so a returning tab re-adopts it.
|
||
let buildingGuest = await store.macVMBaseBuildGuest()
|
||
building = buildingGuest == .macOS
|
||
linuxBuilding = buildingGuest == .linux
|
||
// 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()
|
||
linuxBaseStatus = await store.linuxVMBaseStatus()
|
||
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, os: GuestOS, 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(for: os))
|
||
.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 Nucleic’s secret store (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
|
||
/// Optional overrides for the git identity a Nucleic-managed key commits as. Empty name → the
|
||
/// default "Nucleic"; empty email → keep the repo's own `user.email` (so GitHub can verify the
|
||
/// commit against the account whose signing key the managed key is).
|
||
@AppStorage(GitHubCredentialSettings.managedCommitNameKey) private var commitName = ""
|
||
@AppStorage(GitHubCredentialSettings.managedCommitEmailKey) private var commitEmail = ""
|
||
|
||
/// 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
|
||
commitIdentityEntry
|
||
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)
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: Managed commit identity (author/committer overrides)
|
||
|
||
/// Overrides for the git identity a Nucleic-managed key commits as. By default a managed-key
|
||
/// commit is authored as "Nucleic" but keeps the repo's own `user.email`, so GitHub can verify it
|
||
/// against the account the managed key belongs to. These fields let the user pin a specific
|
||
/// name/email instead; blank means "use the default".
|
||
@ViewBuilder private var commitIdentityEntry: some View {
|
||
LabeledContent("Commit name") {
|
||
TextField("Nucleic", text: $commitName)
|
||
.textFieldStyle(.roundedBorder)
|
||
.frame(maxWidth: 220)
|
||
}
|
||
LabeledContent("Commit email") {
|
||
TextField("your git config email", text: $commitEmail)
|
||
.textFieldStyle(.roundedBorder)
|
||
.frame(maxWidth: 220)
|
||
}
|
||
Text("Commits Nucleic makes with the managed key are authored as this name; a blank email "
|
||
+ "keeps the repository's own git email so GitHub can verify them against the account the "
|
||
+ "key belongs to.")
|
||
.font(.caption)
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
|
||
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(ModelCatalog.defaultAutoKey) private var defaultAuto = false
|
||
@AppStorage(ModelCatalog.defaultAutoShipKey) private var defaultAutoShip = 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.codexControlAuthModeKey)
|
||
private var codexAuthMode = ControlAuthMode.oauth.rawValue
|
||
@AppStorage(ContainerServiceSettings.splitControlContainersByBackendKey)
|
||
private var splitContainersByBackend = false
|
||
@AppStorage(ContainerServiceSettings.memoryManagementKey)
|
||
private var memoryManagement = MemoryManagementLevel.conservative.rawValue
|
||
@AppStorage(ContainerServiceSettings.commandTracingEnabledKey)
|
||
private var commandTracing = false
|
||
@AppStorage(ContainerServiceSettings.autoCheckImageUpdatesKey)
|
||
private var autoCheckImageUpdates = true
|
||
/// 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
|
||
/// Codex API-key draft + saved-state, siblings of the Claude ones above.
|
||
@State private var codexAPIKeyDraft = ""
|
||
@State private var codexKeySaved = CodexControlAPIKeyStore.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
|
||
/// True while an in-place "Check for updates" (agent-CLI refresh) is in flight.
|
||
@State private var checkingUpdates = false
|
||
/// Result of the last "Check for updates" run, presented in a confirmation alert. `nil` hides it.
|
||
@State private var updateResults: [AgentCLIUpdateResult]?
|
||
|
||
/// 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)
|
||
Toggle("Enable Autoship on new chats by default", isOn: $defaultAutoShip)
|
||
.onChange(of: defaultAutoShip) { _, enabled in
|
||
if enabled { defaultAuto = true } // shipping implies autonomous
|
||
store.defaultAuto = defaultAuto
|
||
store.defaultAutoShip = enabled
|
||
}
|
||
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)
|
||
}
|
||
}
|
||
}
|
||
|
||
Picker("Authenticate Codex with", selection: $codexAuthMode) {
|
||
Text("ChatGPT subscription (OAuth)").tag(ControlAuthMode.oauth.rawValue)
|
||
Text("OpenAI API key").tag(ControlAuthMode.apiKey.rawValue)
|
||
}
|
||
.disabled(!containerServiceEnabled)
|
||
|
||
if codexAuthMode == ControlAuthMode.apiKey.rawValue {
|
||
SecureField("sk-…", text: $codexAPIKeyDraft)
|
||
.disabled(!containerServiceEnabled)
|
||
HStack(spacing: 10) {
|
||
Button("Save Key") {
|
||
if CodexControlAPIKeyStore.save(codexAPIKeyDraft) { codexKeySaved = CodexControlAPIKeyStore.hasKey }
|
||
codexAPIKeyDraft = ""
|
||
}
|
||
.disabled(!containerServiceEnabled
|
||
|| codexAPIKeyDraft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
||
if codexKeySaved {
|
||
Button("Clear", role: .destructive) {
|
||
CodexControlAPIKeyStore.clear()
|
||
codexKeySaved = false
|
||
}
|
||
Label("Key saved", systemImage: "checkmark.seal.fill")
|
||
.foregroundStyle(.green).font(.caption)
|
||
}
|
||
}
|
||
} else {
|
||
HStack(spacing: 10) {
|
||
Button(store.codexLoginInProgress ? "Signing in\u{2026}" : "Sign in to Codex") {
|
||
Task { await store.loginCodex() }
|
||
}
|
||
.disabled(!containerServiceEnabled || store.codexLoginInProgress)
|
||
if store.codexLoginInProgress { ProgressView().controlSize(.small) }
|
||
}
|
||
Text("Nucleic signs you in through your browser and writes `~/.codex/auth.json` \u{2014} no separate `codex login` terminal step. The login is kept in sync after each turn and shared to your other devices over Covalence.")
|
||
.font(.caption).foregroundStyle(.secondary)
|
||
}
|
||
}
|
||
|
||
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("Trace shell commands", isOn: $commandTracing)
|
||
.disabled(!containerServiceEnabled)
|
||
|
||
Toggle("Automatically update the sandbox image", isOn: $autoCheckImageUpdates)
|
||
.disabled(!containerServiceEnabled)
|
||
.onChange(of: autoCheckImageUpdates) { store.startImageUpdateChecksIfNeeded() }
|
||
.help("Periodically check the registry for a newer sandbox base image (a small "
|
||
+ "digest check, no download) and surface it in Maintenance. When on, the next "
|
||
+ "sandbox that starts also pulls a newer image automatically instead of "
|
||
+ "reusing the cached one — no waiting for an app update. A container already "
|
||
+ "running keeps its current image until it's re-created.")
|
||
}
|
||
|
||
Section("Maintenance") {
|
||
Button {
|
||
checkingUpdates = true
|
||
Task {
|
||
// Check the registry for a newer base image (cheap digest HEAD) and refresh
|
||
// the in-place CLIs together — one "check for updates" covers both assets.
|
||
async let image = store.checkForImageUpdate(userInitiated: true)
|
||
async let clis = store.updateControlAgentCLIs()
|
||
let (_, results) = await (image, clis)
|
||
checkingUpdates = false
|
||
updateResults = results
|
||
}
|
||
} label: {
|
||
HStack(spacing: 6) {
|
||
if checkingUpdates { ProgressView().controlSize(.small) }
|
||
Text(checkingUpdates ? "Checking…" : "Check for updates")
|
||
}
|
||
}
|
||
.disabled(!containerServiceEnabled || checkingUpdates)
|
||
.help("Check the registry for a newer sandbox base image and update the Codex and "
|
||
+ "Claude Code CLIs in the running container to their latest versions — instant "
|
||
+ "access to new models. Applying a base-image update uses Force re-creation.")
|
||
|
||
imageUpdateStatusRow
|
||
|
||
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)
|
||
.task {
|
||
// Refresh the base-image update status when the tab opens (cheap digest check; no-op when
|
||
// the service is off), so the Maintenance surface reflects the registry without waiting
|
||
// for the periodic background loop.
|
||
await store.checkForImageUpdate()
|
||
}
|
||
.alert("Check for updates", isPresented: Binding(
|
||
get: { updateResults != nil },
|
||
set: { if !$0 { updateResults = nil } }
|
||
), presenting: updateResults) { _ in
|
||
Button("OK", role: .cancel) { updateResults = nil }
|
||
} message: { results in
|
||
Text(Self.imageStatusLine(store.imageUpdateStatus) + Self.updateSummary(results))
|
||
}
|
||
}
|
||
|
||
/// One-line base-image verdict prepended to the "Check for updates" alert.
|
||
private static func imageStatusLine(_ status: ImageUpdateStatus) -> String {
|
||
switch status {
|
||
case .updateAvailable:
|
||
return ContainerServiceSettings.autoCheckImageUpdates
|
||
? "Base image: a newer version is available — the next sandbox to start picks it up "
|
||
+ "automatically, or apply it now with Update now / Force re-creation.\n\n"
|
||
: "Base image: a newer version is available — apply it with Update now / Force re-creation.\n\n"
|
||
case .upToDate:
|
||
return "Base image: up to date.\n\n"
|
||
case .notInstalled:
|
||
return "Base image: not downloaded yet.\n\n"
|
||
case .unknown:
|
||
return "Base image: couldn't check (offline or registry unreachable).\n\n"
|
||
case .idle, .checking:
|
||
return ""
|
||
}
|
||
}
|
||
|
||
/// A row reflecting the sandbox base-image asset auto-update check (`store.imageUpdateStatus`).
|
||
/// Invites the user to apply an available update via Force re-creation; otherwise a subtle status
|
||
/// line (or nothing before the first check / when the registry couldn't be reached).
|
||
@ViewBuilder private var imageUpdateStatusRow: some View {
|
||
switch store.imageUpdateStatus {
|
||
case .updateAvailable:
|
||
HStack(spacing: 8) {
|
||
Label("A newer sandbox image is available", systemImage: "shippingbox")
|
||
.foregroundStyle(.orange)
|
||
Spacer()
|
||
Button("Update now") {
|
||
recreating = true
|
||
Task {
|
||
await store.applyImageUpdate()
|
||
recreating = false
|
||
}
|
||
}
|
||
.disabled(recreating || restarting)
|
||
}
|
||
case .checking:
|
||
Label("Checking for image updates…", systemImage: "arrow.triangle.2.circlepath")
|
||
.foregroundStyle(.secondary).font(.caption)
|
||
case .upToDate:
|
||
Label("Sandbox image is up to date", systemImage: "checkmark.circle")
|
||
.foregroundStyle(.secondary).font(.caption)
|
||
case .notInstalled:
|
||
Label("Sandbox image not downloaded yet", systemImage: "arrow.down.circle")
|
||
.foregroundStyle(.secondary).font(.caption)
|
||
case .idle, .unknown:
|
||
EmptyView()
|
||
}
|
||
}
|
||
|
||
/// Human-readable summary of a "Check for updates" run for the confirmation alert.
|
||
private static func updateSummary(_ results: [AgentCLIUpdateResult]) -> String {
|
||
guard !results.isEmpty else {
|
||
return "No running control container to update. Start a control chat (or use Force "
|
||
+ "re-creation to rebuild the base image), then try again."
|
||
}
|
||
return results.map { r in
|
||
let head = r.success ? "✓ \(r.container)" : "✗ \(r.container)"
|
||
let detail = r.detail.isEmpty ? "" : "\n\(r.detail)"
|
||
return head + detail
|
||
}.joined(separator: "\n\n")
|
||
}
|
||
}
|
||
|
||
/// 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
|
||
@AppStorage(EphemeralChatPolicy.savedByDefaultKey) private var scratchSavedByDefault = true
|
||
|
||
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) }
|
||
}
|
||
}
|
||
|
||
Section("Scratch chats") {
|
||
Toggle("Keep scratch chats by default", isOn: $scratchSavedByDefault)
|
||
Text(scratchSavedByDefault
|
||
? "New out-of-project (Scratch) chats are kept when you close their window; switch a chat to Ephemeral in its window to discard it on close."
|
||
: "New out-of-project (Scratch) chats are discarded — along with their scratch workspace — when you close their window; switch a chat to Saved in its window to keep it.")
|
||
.font(.caption)
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
}
|
||
.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), 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.defaultSupervisorModelKey) private var defaultSupervisorModel = ModelCatalog.fallbackSupervisorModel
|
||
@AppStorage(ModelCatalog.defaultOrchestraWorkerModelKey) private var defaultOrchestraWorkerModel = ModelCatalog.fallbackOrchestraWorkerModel
|
||
@AppStorage(ModelCatalog.defaultOrchestraMaxWorkersKey) private var defaultOrchestraMaxWorkers = ModelCatalog.fallbackOrchestraMaxWorkers
|
||
@AppStorage(ModelCatalog.defaultAutoKey) private var defaultAuto = false
|
||
@AppStorage(StatusIndicatorVisibility.storageKey) private var statusVisibilityRaw = StatusIndicatorVisibility.everywhere.rawValue
|
||
@AppStorage(StatusIndicatorVisibility.chatIncidentOverrideKey) private var showStatusInChatOnIncident = true
|
||
@AppStorage(ExternalAgentIntegration.mcp.defaultsKey)
|
||
private var externalMCPModels = ExternalAgentIntegration.mcp.defaultModelSelection
|
||
@AppStorage(ExternalAgentIntegration.skills.defaultsKey)
|
||
private var externalSkillsModels = ExternalAgentIntegration.skills.defaultModelSelection
|
||
@AppStorage(ExternalAgentIntegration.plugins.defaultsKey)
|
||
private var externalPluginModels = ExternalAgentIntegration.plugins.defaultModelSelection
|
||
@AppStorage(ACPAgent.genericExecutableDefaultsKey) private var genericACPExecutable = ""
|
||
@AppStorage(ACPAgent.genericArgumentsDefaultsKey) private var genericACPArguments = ""
|
||
|
||
/// 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] = []
|
||
|
||
/// Three-state status dot: green = connected, orange = installed-but-unauthenticated, red = not
|
||
/// installed. The middle state is the one that pairs with the "Log in" button.
|
||
private static func backendStatusColor(_ p: ProviderStatus) -> Color {
|
||
if p.isConnected { return .green }
|
||
if p.installed { return .orange }
|
||
return .red
|
||
}
|
||
|
||
/// The three-state label shown at the trailing edge of a backend row.
|
||
private static func backendStatusText(_ p: ProviderStatus) -> String {
|
||
if p.isConnected { return "Connected" }
|
||
if p.installed { return "Unauthenticated" }
|
||
return "Not Connected"
|
||
}
|
||
|
||
/// Providers Nucleic can sign in for via its mediated OAuth — Claude and Codex. Others own their
|
||
/// own auth, so no in-app button is offered for them.
|
||
private static func canLogIn(_ backend: BackendID) -> Bool {
|
||
switch backend {
|
||
case .claudeCode, .codex, .codexExec: return true
|
||
default: return false
|
||
}
|
||
}
|
||
|
||
var body: some View {
|
||
Form {
|
||
Section("New chats") {
|
||
Picker("Default model", selection: $defaultModel) {
|
||
ForEach(ModelCatalog.models, id: \.self) { sku in
|
||
let badge = ModelCatalog.contextBadge(for: sku).map { Text(" \($0)").foregroundColor(.secondary) } ?? Text("")
|
||
Text("\(ModelCatalog.displayName(sku))\(badge)")
|
||
.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)
|
||
}
|
||
|
||
Section("Orchestra") {
|
||
Picker("Supervisor model", selection: $defaultSupervisorModel) {
|
||
ForEach(ModelCatalog.models, id: \.self) { sku in
|
||
let badge = ModelCatalog.contextBadge(for: sku).map { Text(" \($0)").foregroundColor(.secondary) } ?? Text("")
|
||
Text("\(ModelCatalog.displayName(sku))\(badge)")
|
||
.tag(sku)
|
||
}
|
||
}
|
||
Picker("Worker model", selection: $defaultOrchestraWorkerModel) {
|
||
ForEach(ModelCatalog.models, id: \.self) { sku in
|
||
let badge = ModelCatalog.contextBadge(for: sku).map { Text(" \($0)").foregroundColor(.secondary) } ?? Text("")
|
||
Text("\(ModelCatalog.displayName(sku))\(badge)")
|
||
.tag(sku)
|
||
}
|
||
}
|
||
Picker("Max concurrent workers", selection: $defaultOrchestraMaxWorkers) {
|
||
ForEach(ModelCatalog.orchestraMaxWorkerChoices, id: \.self) { count in
|
||
Text(count <= 0 ? "Unlimited" : "\(count)").tag(count)
|
||
}
|
||
}
|
||
.help("How many worker subagents the supervisor may run at once. Extra spawns wait for a free slot. The fan-out multiplies token usage, so raise this deliberately.")
|
||
}
|
||
|
||
Section("External integrations") {
|
||
integrationModelsRow("MCP servers", selection: $externalMCPModels)
|
||
integrationModelsRow("Skills", selection: $externalSkillsModels)
|
||
integrationModelsRow("Plugins", selection: $externalPluginModels)
|
||
Text(
|
||
"Use provider-configured integrations from your agent homes. Choose one or "
|
||
+ "more models for each category. Skills retain their existing all-model "
|
||
+ "behavior; MCP servers and plugins are explicit opt-ins.")
|
||
.font(.caption)
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
|
||
Section("Custom ACP agent") {
|
||
TextField("Executable", text: $genericACPExecutable, prompt: Text("acp-agent"))
|
||
.textFieldStyle(.roundedBorder)
|
||
TextField("Arguments", text: $genericACPArguments, prompt: Text("acp"))
|
||
.textFieldStyle(.roundedBorder)
|
||
Text("Configure any command that speaks Agent Client Protocol over stdio. Arguments are split without invoking a shell; quotes and backslash escaping are supported.")
|
||
.font(.caption)
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
|
||
Section("Backend") {
|
||
ForEach(providers) { provider in
|
||
HStack(spacing: 8) {
|
||
Circle()
|
||
.fill(Self.backendStatusColor(provider))
|
||
.frame(width: 8, height: 8)
|
||
Text(provider.name)
|
||
Spacer()
|
||
// Installed but not signed in, for a provider Nucleic can drive → a one-click
|
||
// browser sign-in (Claude/Codex OAuth). Re-probe after so the row updates.
|
||
if provider.installed, !provider.authenticated,
|
||
Self.canLogIn(provider.backend) {
|
||
Button("Log in") {
|
||
Task {
|
||
await store.login(forBackend: provider.backend)
|
||
providers = await ProviderAvailability.probeAll()
|
||
}
|
||
}
|
||
.controlSize(.small)
|
||
}
|
||
Text(Self.backendStatusText(provider))
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
.help(provider.detail)
|
||
}
|
||
}
|
||
.task(id: genericACPExecutable + "|" + genericACPArguments) {
|
||
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: defaultSupervisorModel) { _, _ in pushDefaults() }
|
||
.onChange(of: defaultOrchestraWorkerModel) { _, _ in pushDefaults() }
|
||
.onChange(of: defaultOrchestraMaxWorkers) { _, _ in pushDefaults() }
|
||
.onChange(of: defaultAuto) { _, _ in pushDefaults() }
|
||
}
|
||
|
||
@ViewBuilder
|
||
private func integrationModelsRow(_ title: String, selection: Binding<String>) -> some View {
|
||
HStack {
|
||
Text(title)
|
||
Spacer()
|
||
Menu(scopeLabel(selection.wrappedValue)) {
|
||
Button("All models") { selection.wrappedValue = "*" }
|
||
Button("No models") {
|
||
selection.wrappedValue = ExternalAgentIntegrationSettings.encodedModels([])
|
||
}
|
||
Divider()
|
||
ForEach(ModelCatalog.models, id: \.self) { model in
|
||
Button {
|
||
toggleIntegrationModel(model, selection: selection)
|
||
} label: {
|
||
if scopeContains(model, raw: selection.wrappedValue) {
|
||
Label(ModelCatalog.displayName(model), systemImage: "checkmark")
|
||
} else {
|
||
Text(ModelCatalog.displayName(model))
|
||
}
|
||
}
|
||
}
|
||
}
|
||
.fixedSize()
|
||
}
|
||
}
|
||
|
||
private func scopeContains(_ model: String, raw: String) -> Bool {
|
||
let models = ExternalAgentIntegrationSettings.models(from: raw)
|
||
return models.contains("*") || models.contains(model)
|
||
}
|
||
|
||
private func scopeLabel(_ raw: String) -> String {
|
||
let models = ExternalAgentIntegrationSettings.models(from: raw)
|
||
if models.contains("*") { return "All models" }
|
||
if models.isEmpty { return "No models" }
|
||
if models.count == 1, let model = models.first {
|
||
return ModelCatalog.displayName(model)
|
||
}
|
||
return "\(models.count) models"
|
||
}
|
||
|
||
private func toggleIntegrationModel(_ model: String, selection: Binding<String>) {
|
||
var models = ExternalAgentIntegrationSettings.models(from: selection.wrappedValue)
|
||
if models.contains("*") { models = Set(ModelCatalog.models) }
|
||
if models.contains(model) {
|
||
models.remove(model)
|
||
} else {
|
||
models.insert(model)
|
||
}
|
||
selection.wrappedValue = models == Set(ModelCatalog.models)
|
||
? "*" : ExternalAgentIntegrationSettings.encodedModels(models)
|
||
}
|
||
|
||
private func pushDefaults() {
|
||
store.defaultModel = defaultModel
|
||
store.defaultEffort = defaultEffort
|
||
store.defaultSupervisorModel = defaultSupervisorModel
|
||
store.defaultOrchestraWorkerModel = defaultOrchestraWorkerModel
|
||
store.defaultOrchestraMaxConcurrentWorkers = defaultOrchestraMaxWorkers
|
||
store.defaultAuto = defaultAuto
|
||
}
|
||
|
||
/// 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. Both are the same cards the home view shows:
|
||
/// `QuotaCard` reads `AppStore.subscriptionUsage` (Claude) and `CodexUsageCard` reads
|
||
/// `AppStore.codexUsage` (Codex), each falling back to its own placeholder before data lands.
|
||
/// 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 "Covalence" tab: the global Covalence switch plus its mesh, transport, relay, and runner
|
||
/// settings.
|
||
private struct RemoteSettingsTab: View {
|
||
@Environment(AppStore.self) private var store
|
||
@AppStorage(AppStore.remoteEnabledDefaultsKey) private var covalenceEnabled = false
|
||
// Mesh participation knobs — moved here from the Agents tab so every cloud/mesh setting
|
||
// lives on the Covalence tab. Both only take effect while the mesh (Covalence) is on, so they
|
||
// ride the same `covalenceEnabled` gate as the rest of the pane.
|
||
@AppStorage(MeshDispatchSettings.acceptsKey) private var acceptsMeshDispatch = true
|
||
@AppStorage(MeshMessagingSettings.enabledKey) private var meshMessagingEnabled = false
|
||
@AppStorage(MeshDebugLog.enabledKey) private var meshDebugLogging = false
|
||
@State private var confirmReset = false
|
||
@State private var exportingLogs = false
|
||
|
||
var body: some View {
|
||
Form {
|
||
Section {
|
||
Toggle("Covalence", isOn: Binding(
|
||
get: { covalenceEnabled },
|
||
set: { enabled in
|
||
covalenceEnabled = enabled
|
||
Task {
|
||
enabled
|
||
? await store.startSyncServer()
|
||
: await store.stopSyncServer()
|
||
}
|
||
}
|
||
))
|
||
}
|
||
|
||
Group {
|
||
Section("Covalence dispatch") {
|
||
Toggle("Accept Covalence-dispatched chats", isOn: $acceptsMeshDispatch)
|
||
Text("Lets the composer's \"Covalence\" destination on your other devices "
|
||
+ "auto-route new chats here when this host is the best fit — and lets "
|
||
+ "idle Covalence chats move here between turns when this host frees up.")
|
||
.font(.caption)
|
||
.foregroundStyle(.secondary)
|
||
Toggle("Agent messaging across the mesh", isOn: $meshMessagingEnabled)
|
||
Text("Lets agents send short messages to sessions on this Mac and your other "
|
||
+ "meshed Macs/runners (`nucleic_send_message`). Deliveries land as visible "
|
||
+ "user turns in the target chat. Applies to chats started after toggling.")
|
||
.font(.caption)
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
.onChange(of: acceptsMeshDispatch) {
|
||
// The presence card carries this flag — re-announce it mesh-wide.
|
||
store.publishRunnerPresence()
|
||
}
|
||
|
||
RemoteAccessSection()
|
||
}
|
||
.disabled(!covalenceEnabled)
|
||
.opacity(covalenceEnabled ? 1 : 0.5)
|
||
|
||
// Left outside the `covalenceEnabled` gate on purpose — this is the escape hatch for
|
||
// when the mesh is stuck or Covalence won't come back up on its own, so it needs to
|
||
// work whether or not the toggle above currently reads "on". The viewer sits with
|
||
// it for the same reason: diagnosing a stuck mesh is exactly when you need it.
|
||
Section {
|
||
Button {
|
||
CovalenceMeshViewerWindowController.shared.open(store: store)
|
||
} label: {
|
||
Label("Open Covalence Mesh Viewer", systemImage: "waveform.path.ecg")
|
||
}
|
||
Text("Live mesh state, host health, and an event log of dispatch, rebalance, "
|
||
+ "and transfer decisions — for observing Covalence and diagnosing issues.")
|
||
.font(.caption).foregroundStyle(.secondary)
|
||
Toggle("Mesh debug logging", isOn: $meshDebugLogging)
|
||
Text("Writes every mesh event — relay dials and failures, peer connects, "
|
||
+ "dispatch and transfer decisions — to a rolling log file that survives "
|
||
+ "relaunches, so problems can be diagnosed after the fact.")
|
||
.font(.caption).foregroundStyle(.secondary)
|
||
Button {
|
||
exportMeshLogs()
|
||
} label: {
|
||
if exportingLogs {
|
||
Label("Gathering logs…", systemImage: "square.and.arrow.up")
|
||
} else {
|
||
Label("Export Mesh & Cloud Logs…", systemImage: "square.and.arrow.up")
|
||
}
|
||
}
|
||
.disabled(exportingLogs)
|
||
Text("Saves one report with this Mac's mesh state and event trail plus the "
|
||
+ "Covalence cloud view: the relay room's connection log and the runner "
|
||
+ "pool's status.")
|
||
.font(.caption).foregroundStyle(.secondary)
|
||
Button(role: .destructive) {
|
||
confirmReset = true
|
||
} label: {
|
||
Label("Reset Covalence…", systemImage: "arrow.counterclockwise")
|
||
}
|
||
} footer: {
|
||
Text("Wipes this Mac's local mesh identity, paired devices, and relay credentials, "
|
||
+ "then turns Covalence off. For debugging, or when the mesh is stuck and nothing "
|
||
+ "else fixes it. Every paired device will need to re-pair afterward.")
|
||
}
|
||
.confirmationDialog(
|
||
"Reset Covalence?", isPresented: $confirmReset, titleVisibility: .visible
|
||
) {
|
||
Button("Reset Covalence", role: .destructive) {
|
||
Task { await store.resetCovalenceMeshConfig() }
|
||
}
|
||
Button("Cancel", role: .cancel) {}
|
||
} message: {
|
||
Text("This forgets every paired phone and Mac, deletes this Mac's mesh identity "
|
||
+ "and relay credentials, and turns Covalence off. Nothing else on this Mac is "
|
||
+ "affected. You can re-enable Covalence and re-pair devices anytime.")
|
||
}
|
||
}
|
||
.formStyle(.grouped)
|
||
}
|
||
|
||
/// Gather the mesh + cloud diagnostics report (async — it fetches the relay room log
|
||
/// and pool status) and hand it to a save panel.
|
||
private func exportMeshLogs() {
|
||
exportingLogs = true
|
||
Task {
|
||
let report = await store.exportMeshDiagnostics()
|
||
exportingLogs = false
|
||
let panel = NSSavePanel()
|
||
let stamp = Date().formatted(.iso8601.year().month().day().timeSeparator(.omitted))
|
||
panel.nameFieldStringValue = "nucleic-mesh-logs-\(stamp).txt"
|
||
panel.canCreateDirectories = true
|
||
if panel.runModal() == .OK, let url = panel.url {
|
||
try? report.write(to: url, atomically: true, encoding: .utf8)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
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 virtualMachines = URL(string: "\(base)/support/virtual-machines.html")!
|
||
static let macVMBaseImage = URL(string: "\(base)/support/macos-vm-base-image.html")!
|
||
static let computerUse = URL(string: "\(base)/support/computer-use.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)
|
||
}
|
||
}
|