feat(settings): wire up functional settings#92
Conversation
Add Settings window (⌘,) with sidebar navigation and General pane. Functional toggles: start at login, show in menu bar, keep running. UI-only placeholders: updates and terminal sections.
Add Settings window (⌘,) with sidebar navigation and General pane. Functional toggles: start at login, show in menu bar, keep running. UI-only placeholders: updates and terminal sections.
…desktop into feat/settings
…storage tabs - Terminal theme: TerminalAppearance now accepts theme parameter (system/light/dark) - Auto update: sync AppStorage to Sparkle's automaticallyChecksForUpdates - Update channel: UpdaterDelegate switches feed URL for beta channel - Keep running on quit: hide windows instead of terminating when menu bar is active - External terminal: AppleScript launcher for Terminal.app/iTerm with DOCKER_HOST - Pause on sleep: SleepWakeManager pauses/unpauses containers on macOS sleep/wake - Docker context: auto-switch to arcbox context on startup, restore on quit - Time Machine: toggle exclusion via tmutil for ~/.arcbox - Reset Docker data: stop containers + prune all with confirmation dialog
There was a problem hiding this comment.
Pull request overview
Upgrades the macOS Settings experience from mostly placeholder UI to settings that persist and trigger real app behavior (Sparkle updates, terminal theming, container sleep/wake handling, Docker context switching, and storage maintenance actions).
Changes:
- Adds a dedicated Settings window (with menu command) and introduces Settings tab views.
- Wires settings to behavior: Sparkle auto-update/channel, terminal theme + external terminal launching, sleep/wake pause, Docker context switching.
- Implements storage actions: Time Machine exclusion toggle + “Reset Docker Data / Reset All Data” operations (Docker prune + refresh notification).
Reviewed changes
Copilot reviewed 18 out of 18 changed files in this pull request and generated 10 comments.
Show a summary per file
| File | Description |
|---|---|
| arcbox-desktop-swift/Views/Settings/GeneralSettingsView.swift | Adds General settings UI with @AppStorage keys (appears duplicated vs ArcBox/). |
| arcbox-desktop-swift/Views/Settings/NetworkSettingsView.swift | Adds Network settings placeholder UI (appears duplicated vs ArcBox/). |
| arcbox-desktop-swift/Views/Settings/SettingsView.swift | Adds settings navigation shell (duplicate tree concern). |
| arcbox-desktop-swift/Views/Settings/StorageSettingsView.swift | Adds Storage settings placeholder UI (appears duplicated vs ArcBox/). |
| arcbox-desktop-swift/Views/Settings/SystemSettingsView.swift | Adds System settings placeholder UI (appears duplicated vs ArcBox/). |
| ArcBox/ArcBoxApp.swift | Adds Settings window + command, wires Sparkle prefs, sleep/wake manager, and Docker context switching; implements “keep running on quit”. |
| ArcBox/Models/DockerContextManager.swift | Implements auto Docker context switching/restoration via ~/.docker/config.json + docker context create. |
| ArcBox/Models/ExternalTerminalLauncher.swift | Adds AppleScript-based launcher for Terminal/iTerm with DOCKER_HOST injected. |
| ArcBox/Models/SleepWakeManager.swift | Adds sleep/wake observers to pause/unpause running containers. |
| ArcBox/Theme/TerminalAppearance.swift | Extends terminal appearance configuration to accept a theme preference (system/light/dark). |
| ArcBox/ViewModels/UpdaterDelegate.swift | Makes Sparkle feed URL depend on user-selected update channel. |
| ArcBox/Views/Containers/Tabs/ContainerTerminalTab.swift | Applies terminal theme preference and adds “Open in external terminal” button. |
| ArcBox/Views/Images/Tabs/ImageTerminalTab.swift | Applies terminal theme preference to image terminal sessions. |
| ArcBox/Views/Settings/GeneralSettingsView.swift | Adds persisted General settings (login item, update prefs, terminal prefs). |
| ArcBox/Views/Settings/NetworkSettingsView.swift | Adds Network settings UI (currently placeholder behavior). |
| ArcBox/Views/Settings/SettingsView.swift | Adds settings tab navigation and layout. |
| ArcBox/Views/Settings/StorageSettingsView.swift | Implements Time Machine toggle + reset Docker/all data flows with progress/result messaging. |
| ArcBox/Views/Settings/SystemSettingsView.swift | Adds System settings UI, persisting some toggles (others remain placeholders). |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| // MARK: - Settings Tab Enum | ||
|
|
||
| enum SettingsTab: String, CaseIterable, Identifiable { | ||
| case general = "General" | ||
| case system = "System" | ||
| case network = "Network" | ||
| case storage = "Storage" | ||
| case machines = "Machines" | ||
| case docker = "Docker" | ||
| case kubernetes = "Kubernetes" | ||
|
|
||
| var id: String { rawValue } | ||
|
|
||
| var sfSymbol: String { | ||
| switch self { | ||
| case .general: return "gearshape" | ||
| case .system: return "square.grid.2x2" | ||
| case .network: return "globe" | ||
| case .storage: return "externaldrive" | ||
| case .machines: return "desktopcomputer" | ||
| case .docker: return "shippingbox" | ||
| case .kubernetes: return "helm" | ||
| } | ||
| } | ||
| } | ||
|
|
||
| // MARK: - Settings View | ||
|
|
||
| struct SettingsView: View { | ||
| @State private var selectedTab: SettingsTab = .general | ||
|
|
||
| var body: some View { | ||
| NavigationSplitView { | ||
| List(SettingsTab.allCases, selection: $selectedTab) { tab in | ||
| Label(tab.rawValue, systemImage: tab.sfSymbol) | ||
| .tag(tab) | ||
| } | ||
| .listStyle(.sidebar) | ||
| .navigationSplitViewColumnWidth(min: 150, ideal: 180, max: 220) | ||
| } detail: { | ||
| settingsContent | ||
| .navigationTitle(selectedTab.rawValue) | ||
| } | ||
| .toolbar(removing: .sidebarToggle) | ||
| .frame(width: 700, height: 580) | ||
| } | ||
|
|
||
| @ViewBuilder | ||
| private var settingsContent: some View { | ||
| switch selectedTab { | ||
| case .general: | ||
| GeneralSettingsView() | ||
| case .system: | ||
| SystemSettingsView() | ||
| case .network: | ||
| NetworkSettingsView() | ||
| case .storage: | ||
| StorageSettingsView() | ||
| default: | ||
| Text(selectedTab.rawValue) | ||
| .font(.title2) | ||
| .foregroundStyle(.secondary) | ||
| .frame(maxWidth: .infinity, maxHeight: .infinity) | ||
| } | ||
| } |
There was a problem hiding this comment.
These new Settings views are duplicated under arcbox-desktop-swift/, but the repository’s active Xcode project is ArcBox.xcodeproj and does not reference arcbox-desktop-swift (no project.pbxproj matches). Keeping a parallel Settings implementation in an apparently unused tree is likely to become stale/confusing; consider removing this duplicate tree or ensuring only one source of truth is built.
| // MARK: - Settings Tab Enum | |
| enum SettingsTab: String, CaseIterable, Identifiable { | |
| case general = "General" | |
| case system = "System" | |
| case network = "Network" | |
| case storage = "Storage" | |
| case machines = "Machines" | |
| case docker = "Docker" | |
| case kubernetes = "Kubernetes" | |
| var id: String { rawValue } | |
| var sfSymbol: String { | |
| switch self { | |
| case .general: return "gearshape" | |
| case .system: return "square.grid.2x2" | |
| case .network: return "globe" | |
| case .storage: return "externaldrive" | |
| case .machines: return "desktopcomputer" | |
| case .docker: return "shippingbox" | |
| case .kubernetes: return "helm" | |
| } | |
| } | |
| } | |
| // MARK: - Settings View | |
| struct SettingsView: View { | |
| @State private var selectedTab: SettingsTab = .general | |
| var body: some View { | |
| NavigationSplitView { | |
| List(SettingsTab.allCases, selection: $selectedTab) { tab in | |
| Label(tab.rawValue, systemImage: tab.sfSymbol) | |
| .tag(tab) | |
| } | |
| .listStyle(.sidebar) | |
| .navigationSplitViewColumnWidth(min: 150, ideal: 180, max: 220) | |
| } detail: { | |
| settingsContent | |
| .navigationTitle(selectedTab.rawValue) | |
| } | |
| .toolbar(removing: .sidebarToggle) | |
| .frame(width: 700, height: 580) | |
| } | |
| @ViewBuilder | |
| private var settingsContent: some View { | |
| switch selectedTab { | |
| case .general: | |
| GeneralSettingsView() | |
| case .system: | |
| SystemSettingsView() | |
| case .network: | |
| NetworkSettingsView() | |
| case .storage: | |
| StorageSettingsView() | |
| default: | |
| Text(selectedTab.rawValue) | |
| .font(.title2) | |
| .foregroundStyle(.secondary) | |
| .frame(maxWidth: .infinity, maxHeight: .infinity) | |
| } | |
| } | |
| /// Deprecated duplicate Settings implementation. | |
| /// | |
| /// This `SettingsView` lives under `arcbox-desktop-swift/` and is not referenced | |
| /// from the active `ArcBox.xcodeproj`. It is kept only for historical/contextual | |
| /// purposes. Do not add new behavior here; instead, update the main Settings | |
| /// implementation used by the app. | |
| @available(*, deprecated, message: "Use the main SettingsView from ArcBox.xcodeproj instead of the duplicate in arcbox-desktop-swift.") | |
| struct SettingsView: View { | |
| var body: some View { | |
| Text( | |
| """ | |
| This SettingsView is defined in arcbox-desktop-swift and is deprecated. | |
| It is not used by the main ArcBox.xcodeproj target. | |
| Please update and reference the primary Settings implementation instead. | |
| """ | |
| ) | |
| .multilineTextAlignment(.center) | |
| .padding() | |
| } |
| if channel == "beta" { | ||
| // Replace the appcast filename to point to the beta feed. | ||
| // e.g. ".../appcast.xml" → ".../appcast-beta.xml" | ||
| return baseURL.replacingOccurrences(of: "appcast.xml", with: "appcast-beta.xml") | ||
| } | ||
| return baseURL |
There was a problem hiding this comment.
Update channel switching likely won't work with the current replacement logic. CI/release packaging sets SUFeedURL to .../desktop/appcast/<channel>.xml (e.g. stable.xml/beta.xml), but this code only replaces appcast.xml → appcast-beta.xml, which won't match those URLs. Consider parsing the URL and swapping the last path component based on updateChannel (or replacing stable.xml ↔ beta.xml).
| if channel == "beta" { | |
| // Replace the appcast filename to point to the beta feed. | |
| // e.g. ".../appcast.xml" → ".../appcast-beta.xml" | |
| return baseURL.replacingOccurrences(of: "appcast.xml", with: "appcast-beta.xml") | |
| } | |
| return baseURL | |
| // Attempt to adjust the feed URL based on the selected update channel. | |
| // CI/release packaging sets SUFeedURL to ".../desktop/appcast/<channel>.xml" | |
| // (e.g. "stable.xml" / "beta.xml"), but we also support the older | |
| // "appcast.xml" / "appcast-beta.xml" pattern. | |
| guard let url = URL(string: baseURL) else { | |
| return baseURL | |
| } | |
| let currentFilename = url.lastPathComponent | |
| var targetFilename = currentFilename | |
| switch channel { | |
| case "beta": | |
| // Prefer switching "stable.xml" → "beta.xml" | |
| if currentFilename == "stable.xml" { | |
| targetFilename = "beta.xml" | |
| } else if currentFilename == "appcast.xml" { | |
| // Fallback for older "appcast.xml" → "appcast-beta.xml" scheme | |
| targetFilename = "appcast-beta.xml" | |
| } | |
| case "stable": | |
| // Ensure we point back to the stable feed when requested. | |
| if currentFilename == "beta.xml" { | |
| targetFilename = "stable.xml" | |
| } else if currentFilename == "appcast-beta.xml" { | |
| targetFilename = "appcast.xml" | |
| } | |
| default: | |
| // For any other channel, leave the URL unchanged. | |
| break | |
| } | |
| // If no change was needed, return the original baseURL. | |
| if targetFilename == currentFilename { | |
| return baseURL | |
| } | |
| let basePathURL = url.deletingLastPathComponent() | |
| let updatedURL = basePathURL.appendingPathComponent(targetFilename) | |
| return updatedURL.absoluteString |
| let proc = Process() | ||
| proc.executableURL = URL(fileURLWithPath: "/usr/bin/tmutil") | ||
| proc.arguments = include ? ["removeexclusion", path] : ["addexclusion", path] | ||
| proc.standardOutput = FileHandle.nullDevice | ||
| proc.standardError = FileHandle.nullDevice | ||
| do { | ||
| try proc.run() | ||
| proc.waitUntilExit() | ||
| } catch { | ||
| // tmutil may require admin privileges — silently fail |
There was a problem hiding this comment.
updateTimeMachineExclusion(include:) runs tmutil synchronously and calls waitUntilExit() on the main actor, which can block the UI if tmutil hangs/prompts. Consider running this Process work off the main actor (e.g. in a detached Task) and reporting failure (exit status / thrown error) back to the UI.
| let proc = Process() | |
| proc.executableURL = URL(fileURLWithPath: "/usr/bin/tmutil") | |
| proc.arguments = include ? ["removeexclusion", path] : ["addexclusion", path] | |
| proc.standardOutput = FileHandle.nullDevice | |
| proc.standardError = FileHandle.nullDevice | |
| do { | |
| try proc.run() | |
| proc.waitUntilExit() | |
| } catch { | |
| // tmutil may require admin privileges — silently fail | |
| Task.detached { | |
| let proc = Process() | |
| proc.executableURL = URL(fileURLWithPath: "/usr/bin/tmutil") | |
| proc.arguments = include ? ["removeexclusion", path] : ["addexclusion", path] | |
| proc.standardOutput = FileHandle.nullDevice | |
| proc.standardError = FileHandle.nullDevice | |
| do { | |
| try proc.run() | |
| proc.waitUntilExit() | |
| } catch { | |
| // tmutil may require admin privileges — silently fail | |
| } |
| func start() { | ||
| let workspace = NSWorkspace.shared.notificationCenter | ||
| sleepObserver = workspace.addObserver( | ||
| forName: NSWorkspace.willSleepNotification, object: nil, queue: .main | ||
| ) { [weak self] _ in | ||
| guard let self else { return } | ||
| Task { @MainActor in | ||
| await self.handleSleep() | ||
| } | ||
| } | ||
| wakeObserver = workspace.addObserver( | ||
| forName: NSWorkspace.didWakeNotification, object: nil, queue: .main | ||
| ) { [weak self] _ in | ||
| guard let self else { return } | ||
| Task { @MainActor in | ||
| await self.handleWake() | ||
| } | ||
| } | ||
| logger.info("Sleep/wake monitoring started") | ||
| } |
There was a problem hiding this comment.
start() unconditionally adds sleep/wake observers without checking if monitoring is already active. If start() is called more than once without stop(), notifications will be handled multiple times (duplicate pauses/unpauses). Consider making start() idempotent (e.g. guard observers are nil, or call stop() before re-adding).
| @State private var memoryLimit: Double = 9 | ||
| @State private var cpuLimit: Double = 17 // 17 = "None" (beyond max) | ||
| @State private var useAdminPrivileges = true | ||
| @AppStorage("switchDockerContextAutomatically") private var switchContextAutomatically = true | ||
| @State private var useRosetta = true | ||
| @AppStorage("pauseContainersWhileSleeping") private var pauseContainersWhileSleeping = true | ||
|
|
||
| private let memoryRange: ClosedRange<Double> = 1...14 | ||
| private let cpuSteps: [String] = ["100%", ""] // display only | ||
|
|
||
| var body: some View { | ||
| Form { | ||
| Section { | ||
| Text("Resources are only used as needed. These are limits, not reservations. [Learn more](#)") | ||
| .font(.callout) | ||
| .foregroundStyle(.secondary) | ||
|
|
||
| LabeledContent { | ||
| HStack { | ||
| Text("1 GiB") | ||
| .font(.caption) | ||
| .foregroundStyle(.secondary) | ||
| Slider(value: $memoryLimit, in: memoryRange, step: 1) | ||
| Text("14 GiB") | ||
| .font(.caption) | ||
| .foregroundStyle(.secondary) | ||
| } | ||
| } label: { | ||
| VStack(alignment: .leading, spacing: 2) { | ||
| Text("Memory limit") | ||
| Text("\(Int(memoryLimit)) GiB") | ||
| .font(.caption) | ||
| .foregroundStyle(.secondary) | ||
| } | ||
| } | ||
|
|
||
| LabeledContent { | ||
| HStack { | ||
| Text("100%") | ||
| .font(.caption) | ||
| .foregroundStyle(.secondary) | ||
| Slider(value: $cpuLimit, in: 1...17, step: 1) | ||
| Text("None") | ||
| .font(.caption) | ||
| .foregroundStyle(.secondary) | ||
| } | ||
| } label: { | ||
| VStack(alignment: .leading, spacing: 2) { | ||
| Text("CPU limit") | ||
| Text(cpuLimitLabel) | ||
| .font(.caption) | ||
| .foregroundStyle(.secondary) | ||
| } | ||
| } | ||
| } header: { | ||
| Text("Resources") | ||
| } | ||
|
|
||
| Section("Environment") { | ||
| LabeledContent { | ||
| Toggle("", isOn: $useAdminPrivileges) | ||
| .labelsHidden() | ||
| } label: { | ||
| VStack(alignment: .leading, spacing: 2) { | ||
| Text("Use admin privileges for enhanced features") | ||
| Text("This can improve performance and compatibility. [Learn more](#)") | ||
| .font(.caption) | ||
| .foregroundStyle(.secondary) | ||
| } | ||
| } | ||
|
|
||
| Toggle("Switch Docker & Kubernetes context automatically", isOn: $switchContextAutomatically) | ||
| } |
There was a problem hiding this comment.
This view presents interactive resource/environment controls (sliders/toggles) but they are backed only by @State and are not applied or persisted. Given the PR description notes CPU/Memory/Admin/Rosetta require backend support, consider disabling these controls (or clearly marking them as not yet supported) to avoid misleading users.
| @AppStorage("pauseContainersWhileSleeping") private var pauseContainersWhileSleeping = true | ||
|
|
||
| private let memoryRange: ClosedRange<Double> = 1...14 | ||
| private let cpuSteps: [String] = ["100%", ""] // display only |
There was a problem hiding this comment.
cpuSteps is declared but unused, which will produce a compiler warning and adds noise for future maintenance. Consider removing it or wiring it into the UI (e.g. tick labels) if intended.
| private let cpuSteps: [String] = ["100%", ""] // display only |
| /// Creates the arcbox context in Docker's context meta store. | ||
| private static func createArcBoxContext() { | ||
| // Use docker CLI to create the context instead of manual file manipulation | ||
| let proc = Process() | ||
| proc.executableURL = URL(fileURLWithPath: "/usr/bin/env") | ||
| proc.arguments = [ | ||
| "docker", "context", "create", "arcbox", | ||
| "--docker", "host=\(arcboxSocketPath)", | ||
| "--description", "ArcBox Desktop", | ||
| ] | ||
| proc.standardOutput = FileHandle.nullDevice | ||
| proc.standardError = FileHandle.nullDevice | ||
| // Silence errors if context already exists | ||
| do { | ||
| try proc.run() | ||
| proc.waitUntilExit() | ||
| } catch { | ||
| // Context may already exist — that's fine | ||
| } | ||
| } |
There was a problem hiding this comment.
createArcBoxContext() runs docker context create ... synchronously and calls waitUntilExit(). With the target’s default MainActor isolation, calling switchToArcBox() from UI-driven code can block the main thread if docker is slow/hung. Consider moving the Process execution off the main actor (or making these APIs async) so startup/UI remains responsive.
| for container in running { | ||
| guard let id = container.Id else { continue } | ||
| _ = try? await docker.api.ContainerStop(path: .init(id: id)) | ||
| } | ||
|
|
||
| // Prune everything: containers, images, volumes, networks | ||
| _ = try? await docker.api.ContainerPrune() | ||
| _ = try? await docker.api.ImagePrune() | ||
| _ = try? await docker.api.NetworkPrune() | ||
| _ = try? await docker.api.VolumePrune() | ||
|
|
||
| resetResultMessage = "Docker data has been reset successfully." | ||
| NotificationCenter.default.post(name: .dockerDataChanged, object: nil) | ||
| } catch { | ||
| resetResultMessage = "Reset failed: \(error.localizedDescription)" |
There was a problem hiding this comment.
resetDockerData() uses try? for stop/prune calls, so failures will be silently ignored and the UI will still show a success message. This can lead to false positives (e.g. data not actually pruned). Consider handling and surfacing errors from each operation (or at least failing the overall reset if any prune fails).
| private static func readConfig() throws -> [String: Any] { | ||
| let url = URL(fileURLWithPath: configPath) | ||
| guard FileManager.default.fileExists(atPath: configPath) else { | ||
| return [:] | ||
| } | ||
| let data = try Data(contentsOf: url) | ||
| guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] else { | ||
| return [:] | ||
| } | ||
| return json |
There was a problem hiding this comment.
readConfig() returns an empty dictionary when the JSON parses but isn't a [String: Any] root object. In that case switchToArcBox() will proceed to write a new config containing only currentContext, potentially clobbering a user's existing Docker config. Consider throwing on unexpected JSON shape (or bailing out without writing) to avoid data loss.
| struct NetworkSettingsView: View { | ||
| @State private var proxyMode: ProxyMode = .auto | ||
| @State private var allowContainerDomains = true | ||
| @State private var enableHTTPS = true | ||
| @State private var ipRange = "192.168.138.0/23" | ||
|
|
||
| private let ipRangeOptions = [ | ||
| "192.168.138.0/23", | ||
| "172.16.0.0/12", | ||
| "10.0.0.0/8", | ||
| ] | ||
|
|
||
| var body: some View { | ||
| Form { | ||
| Section("Proxy") { | ||
| VStack(alignment: .leading, spacing: 4) { | ||
| Text("Apply an HTTP, HTTPS, or SOCKS proxy to all traffic from containers and machines.") | ||
| .font(.callout) | ||
| .foregroundStyle(.secondary) | ||
| .padding(.bottom, 4) | ||
|
|
||
| Picker("", selection: $proxyMode) { | ||
| ForEach(ProxyMode.allCases) { mode in | ||
| Text(mode.rawValue).tag(mode) | ||
| } | ||
| } | ||
| .pickerStyle(.radioGroup) | ||
| .labelsHidden() | ||
| } | ||
| } | ||
|
|
||
| Section("Domains") { | ||
| LabeledContent { | ||
| Toggle("", isOn: $allowContainerDomains) | ||
| .labelsHidden() | ||
| } label: { | ||
| VStack(alignment: .leading, spacing: 2) { | ||
| Text("Allow access to container domains & IPs") | ||
| Text("Use domains and IPs to connect to containers and machines without port forwarding. [Learn more](#)") | ||
| .font(.caption) | ||
| .foregroundStyle(.secondary) | ||
| } | ||
| } | ||
|
|
||
| Toggle("Enable HTTPS for container domains", isOn: $enableHTTPS) | ||
|
|
||
| LabeledContent { | ||
| Picker("", selection: $ipRange) { | ||
| ForEach(ipRangeOptions, id: \.self) { option in | ||
| if option == "192.168.138.0/23" { | ||
| Text("\(option) (default)").tag(option) | ||
| } else { | ||
| Text(option).tag(option) | ||
| } | ||
| } | ||
| } | ||
| .labelsHidden() | ||
| .frame(width: 220) | ||
| } label: { | ||
| VStack(alignment: .leading, spacing: 2) { | ||
| Text("IP range") | ||
| Text("Used for domains and machines. Containers and Kubernetes use different IPs. Don't change this unless you run into issues with the default.") | ||
| .font(.caption) | ||
| .foregroundStyle(.secondary) | ||
| } | ||
| } |
There was a problem hiding this comment.
Network settings in this view are currently all @State-backed and not applied/persisted, but the Settings UI exposes them as if functional. Since the PR description says network settings require backend support, consider disabling these controls or adding explicit "not yet supported" messaging so users don't think the toggles have effect.
Summary
Upgrade Settings window from UI-only placeholders to functional implementations across three batches.
Batch 1: General Settings
TerminalAppearance.configure()now accepts athemeparameter (system/light/dark); terminal tabs read from@AppStorageautoUpdateto Sparkle'sautomaticallyChecksForUpdates; beta channel switches feed URL to appcast-beta.xmlkeepRunning && showInMenuBar, close windows instead of terminating — app stays in menu barExternalTerminalLauncheropens Terminal.app/iTerm via AppleScript withDOCKER_HOSTinjected; added toolbar button in container terminal tabBatch 2: System Settings
SleepWakeManagerlistens forwillSleepNotification/didWakeNotification, pauses all running containers on sleep and unpauses on wakeDockerContextManagercreates and switches toarcboxcontext on daemon startup, restores previous context on quit@AppStoragefor persistenceBatch 3: Storage Settings
tmutil addexclusion/removeexclusion ~/.arcboxdockerClientenvironmentNot changed (requires backend gRPC support)
New Files
ArcBox/Models/ExternalTerminalLauncher.swiftArcBox/Models/SleepWakeManager.swiftArcBox/Models/DockerContextManager.swiftTest Plan
automaticallyChecksForUpdatesreflects the changedocker context show→ should bearcbox; after quit → restoredtmutil isexcluded ~/.arcboxto verify