diff --git a/CHANGELOG.md b/CHANGELOG.md index b91e2c8..95bb710 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,7 +18,11 @@ Two versions travel independently, on purpose: ## [Unreleased] -No changes yet. +### Added + +- macOS Command-, opens one Settings window built from the typed registry and + writing through the same backend as `headless config`. Linux has no Settings + GUI. ## [1.1.0] — 2026-08-21 diff --git a/README.md b/README.md index be9b6d3..e4e4bea 100644 --- a/README.md +++ b/README.md @@ -100,7 +100,9 @@ startup-presentation foreground` or restore background startup with `headless config set startup-presentation background`; inspect it with `headless config get startup-presentation`. Use `config list` to discover settings, `config describe KEY` for type and policy metadata, and `config reset KEY` to restore a -built-in default. `headless start --foreground` and `headless start +built-in default. On macOS, Command-, opens Settings for the same registry; +`headless config` remains the portable path, and Linux has no Settings GUI. +`headless start --foreground` and `headless start --background` are one-launch overrides. Settings and overrides apply only when launching a new host and do not reorder an already-running host. diff --git a/apps/headless/Host/SettingsWindow.swift b/apps/headless/Host/SettingsWindow.swift new file mode 100644 index 0000000..ad28d89 --- /dev/null +++ b/apps/headless/Host/SettingsWindow.swift @@ -0,0 +1,292 @@ +import AppKit +import HeadlessProtocol + +private final class FlippedSettingsDocumentView: NSView { + override var isFlipped: Bool { true } +} + +final class SettingsWindowController: NSWindowController, NSWindowDelegate, NSTextFieldDelegate { + private static var sharedController: SettingsWindowController? + + static func orderFrontShared(_ sender: Any?) { + if sharedController == nil { + do { + let store = try SettingsStore.production() + sharedController = try SettingsWindowController(store: store) + } catch { + presentOpenFailure(error) + return + } + } + sharedController?.showWindow(sender) + sharedController?.window?.makeKeyAndOrderFront(sender) + NSApp.activate(ignoringOtherApps: true) + } + + private static func presentOpenFailure(_ error: Error) { + let alert = NSAlert() + alert.alertStyle = .critical + alert.messageText = "Couldn’t open Settings" + alert.informativeText = (error as? SettingsError)?.description ?? error.localizedDescription + alert.addButton(withTitle: "OK") + alert.runModal() + } + + private let settings: SettingsController + private let stack = NSStackView() + private let scrollView = NSScrollView() + private let documentView = FlippedSettingsDocumentView() + private var isReloading = false + private var firstEditor: NSView? + + private init(store: SettingsStore) throws { + settings = SettingsController(store: store) + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 520, height: 320), + styleMask: [.titled, .closable, .miniaturizable, .resizable], + backing: .buffered, + defer: false + ) + window.title = "Settings" + window.minSize = NSSize(width: 400, height: 240) + window.isReleasedWhenClosed = false + window.tabbingMode = .disallowed + window.setFrameAutosaveName("HeadlessSettings") + window.identifier = NSUserInterfaceItemIdentifier("headless-settings") + window.autorecalculatesKeyViewLoop = true + super.init(window: window) + window.delegate = self + buildContent() + try reload() + let fitted = max(stack.fittingSize.height + 8, 260) + window.setContentSize(NSSize(width: 520, height: min(fitted, 640))) + window.center() + } + + required init?(coder: NSCoder) { fatalError("not used") } + + func windowDidBecomeKey(_ notification: Notification) { + try? reload() + } + + private func buildContent() { + guard let content = window?.contentView else { return } + scrollView.drawsBackground = false + scrollView.hasVerticalScroller = true + scrollView.autohidesScrollers = true + scrollView.translatesAutoresizingMaskIntoConstraints = false + documentView.translatesAutoresizingMaskIntoConstraints = false + scrollView.documentView = documentView + content.addSubview(scrollView) + + stack.orientation = .vertical + stack.alignment = .leading + stack.spacing = 18 + stack.edgeInsets = NSEdgeInsets(top: 20, left: 22, bottom: 20, right: 22) + stack.translatesAutoresizingMaskIntoConstraints = false + documentView.addSubview(stack) + NSLayoutConstraint.activate([ + scrollView.leadingAnchor.constraint(equalTo: content.leadingAnchor), + scrollView.trailingAnchor.constraint(equalTo: content.trailingAnchor), + scrollView.topAnchor.constraint(equalTo: content.topAnchor), + scrollView.bottomAnchor.constraint(equalTo: content.bottomAnchor), + documentView.leadingAnchor.constraint(equalTo: scrollView.contentView.leadingAnchor), + documentView.trailingAnchor.constraint(equalTo: scrollView.contentView.trailingAnchor), + documentView.topAnchor.constraint(equalTo: scrollView.contentView.topAnchor), + documentView.widthAnchor.constraint(equalTo: scrollView.contentView.widthAnchor), + stack.leadingAnchor.constraint(equalTo: documentView.leadingAnchor), + stack.trailingAnchor.constraint(equalTo: documentView.trailingAnchor), + stack.topAnchor.constraint(equalTo: documentView.topAnchor), + stack.bottomAnchor.constraint(equalTo: documentView.bottomAnchor), + ]) + } + + private func reload() throws { + isReloading = true + defer { isReloading = false } + for view in stack.arrangedSubviews { + stack.removeArrangedSubview(view) + view.removeFromSuperview() + } + firstEditor = nil + let snapshots = try settings.snapshots() + if snapshots.isEmpty { + stack.addArrangedSubview(wrappingLabel("No settings are registered.", bold: false)) + return + } + for snapshot in snapshots { + let section = makeSection(snapshot) + stack.addArrangedSubview(section) + section.widthAnchor.constraint(equalTo: documentView.widthAnchor, constant: -44).isActive = true + } + window?.initialFirstResponder = firstEditor + window?.recalculateKeyViewLoop() + if window?.isKeyWindow == true, let firstEditor { + window?.makeFirstResponder(firstEditor) + } + } + + private func makeSection(_ snapshot: SettingSnapshot) -> NSView { + let section = NSStackView() + section.orientation = .vertical + section.alignment = .leading + section.spacing = 6 + section.translatesAutoresizingMaskIntoConstraints = false + section.setHuggingPriority(.defaultLow, for: .horizontal) + + let title = wrappingLabel(snapshot.definition.key, bold: true) + title.setAccessibilityLabel(snapshot.definition.key) + section.addArrangedSubview(title) + + let summary = wrappingLabel(snapshot.definition.summary, bold: false) + summary.textColor = .secondaryLabelColor + summary.setAccessibilityLabel("Summary") + section.addArrangedSubview(summary) + + let editor = makeEditor(snapshot) + if firstEditor == nil { firstEditor = editor } + let reset = NSButton( + title: "Reset to Default", + target: self, + action: #selector(resetClicked(_:)) + ) + reset.bezelStyle = .rounded + reset.identifier = NSUserInterfaceItemIdentifier("reset.\(snapshot.definition.key)") + reset.setAccessibilityLabel("Reset \(snapshot.definition.key) to default") + reset.isEnabled = snapshot.configured && snapshot.supportedOnCurrentPlatform + + let valueCaption = NSTextField(labelWithString: "Value") + valueCaption.setAccessibilityHidden(true) + let valueRow = NSStackView(views: [valueCaption, editor, reset]) + valueRow.orientation = .horizontal + valueRow.alignment = .centerY + valueRow.spacing = 8 + section.addArrangedSubview(valueRow) + + section.addArrangedSubview(metaLabel("Default", snapshot.displayedDefaultValue)) + section.addArrangedSubview(metaLabel("Platform", snapshot.platformSummary)) + section.addArrangedSubview(metaLabel("Takes effect", snapshot.definition.restartBehavior.rawValue)) + section.addArrangedSubview( + metaLabel("Agents may modify", snapshot.agentsMayModify ? "yes" : "no") + ) + return section + } + + private func makeEditor(_ snapshot: SettingSnapshot) -> NSView { + let key = snapshot.definition.key + if let values = snapshot.selectableValues { + let popup = NSPopUpButton(frame: .zero, pullsDown: false) + popup.autoenablesItems = false + popup.addItems(withTitles: values) + popup.selectItem(withTitle: snapshot.value) + popup.target = self + popup.action = #selector(valueChanged(_:)) + popup.identifier = NSUserInterfaceItemIdentifier(key) + popup.setAccessibilityLabel(key) + popup.setAccessibilityHelp(snapshot.definition.summary) + popup.isEnabled = snapshot.supportedOnCurrentPlatform + return popup + } + + let field: NSTextField + if snapshot.usesSecureTextEntry { + let secureField = NSSecureTextField(string: snapshot.value) + secureField.setAccessibilityValue("Hidden") + secureField.placeholderString = "Hidden" + field = secureField + } else { + field = NSTextField(string: snapshot.value) + field.placeholderString = snapshot.definition.defaultValue + } + field.identifier = NSUserInterfaceItemIdentifier(key) + field.delegate = self + field.target = self + field.action = #selector(textCommitted(_:)) + field.setAccessibilityLabel(key) + field.setAccessibilityHelp(snapshot.definition.summary) + field.isEditable = snapshot.supportedOnCurrentPlatform + field.isSelectable = true + field.translatesAutoresizingMaskIntoConstraints = false + field.widthAnchor.constraint(greaterThanOrEqualToConstant: 160).isActive = true + return field + } + + private func wrappingLabel(_ text: String, bold: Bool) -> NSTextField { + let label = NSTextField(wrappingLabelWithString: text) + label.font = bold ? .boldSystemFont(ofSize: 13) : .systemFont(ofSize: 12) + label.preferredMaxLayoutWidth = 476 + label.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + return label + } + + private func metaLabel(_ name: String, _ value: String) -> NSTextField { + let label = NSTextField(labelWithString: "\(name): \(value)") + label.font = .systemFont(ofSize: 11) + label.textColor = .secondaryLabelColor + label.setAccessibilityLabel(name) + label.setAccessibilityValue(value) + return label + } + + @objc private func valueChanged(_ sender: NSPopUpButton) { + guard !isReloading, let key = sender.identifier?.rawValue else { return } + commit(key, sender.titleOfSelectedItem ?? "") + } + + @objc private func textCommitted(_ sender: NSTextField) { + guard !isReloading, let key = sender.identifier?.rawValue else { return } + commit(key, sender.stringValue) + } + + func controlTextDidEndEditing(_ obj: Notification) { + guard let field = obj.object as? NSTextField else { return } + textCommitted(field) + } + + @objc private func resetClicked(_ sender: NSButton) { + guard let raw = sender.identifier?.rawValue, raw.hasPrefix("reset.") else { return } + let key = String(raw.dropFirst("reset.".count)) + do { + _ = try settings.reset(key) + reloadAfterCurrentEvent() + } catch { + present(error) + reloadAfterCurrentEvent() + } + } + + private func commit(_ key: String, _ rawValue: String) { + do { + let current = try settings.store.snapshot(key, caller: .user) + guard current.value != rawValue || !current.configured else { return } + _ = try settings.set(key, rawValue: rawValue) + reloadAfterCurrentEvent() + } catch { + present(error) + reloadAfterCurrentEvent() + } + } + + private func reloadAfterCurrentEvent() { + DispatchQueue.main.async { [weak self] in + do { + try self?.reload() + } catch { + self?.present(error) + } + } + } + + private func present(_ error: Error) { + let alert = NSAlert() + alert.alertStyle = .warning + alert.messageText = "Couldn’t update settings" + alert.informativeText = (error as? SettingsError)?.description ?? error.localizedDescription + alert.addButton(withTitle: "OK") + if let window { + alert.beginSheetModal(for: window) { _ in } + } else { + alert.runModal() + } + } +} diff --git a/apps/headless/Package.swift b/apps/headless/Package.swift index ce8f0a1..28f97bc 100644 --- a/apps/headless/Package.swift +++ b/apps/headless/Package.swift @@ -84,7 +84,10 @@ let package = Package( "LinuxHost", "Dockerfile.linux", "Headless.app", "build-linux.sh", "install.sh", "install-linux.sh", "benchmark.sh", ".dockerignore", "MCP", "CredentialBroker", "CredentialBrokerCore", "SecurePrompt", "node_modules", ], - sources: ["main.swift", "Host/AgentBridge.swift", "Host/QADiagnosticsBridge.swift"], + sources: [ + "main.swift", "Host/AgentBridge.swift", "Host/QADiagnosticsBridge.swift", + "Host/SettingsWindow.swift", + ], linkerSettings: [ .linkedFramework("Cocoa", .when(platforms: [.macOS])), .linkedFramework("WebKit", .when(platforms: [.macOS])), diff --git a/apps/headless/Sources/HeadlessProtocol/CLI.swift b/apps/headless/Sources/HeadlessProtocol/CLI.swift index d550e37..da71878 100644 --- a/apps/headless/Sources/HeadlessProtocol/CLI.swift +++ b/apps/headless/Sources/HeadlessProtocol/CLI.swift @@ -865,5 +865,6 @@ Global options: -- stop parsing global options; quote multi-word fill values Settings: + macOS Command-, opens Settings; `config` is the portable path on every platform. \(SettingsRegistry.shared.helpLines.joined(separator: "\n")) """ diff --git a/apps/headless/Sources/HeadlessProtocol/MenuShortcuts.swift b/apps/headless/Sources/HeadlessProtocol/MenuShortcuts.swift index d8cbaab..e0678c9 100644 --- a/apps/headless/Sources/HeadlessProtocol/MenuShortcuts.swift +++ b/apps/headless/Sources/HeadlessProtocol/MenuShortcuts.swift @@ -57,9 +57,13 @@ public struct MenuShortcutSpec: Equatable, Sendable { } public let headlessMenuShortcuts: [MenuShortcutSpec] = [ + .init( + menu: "Headless", title: "Settings…", key: ",", selector: "showSettings:", + target: .appDelegate + ), .init( menu: "Headless", title: "Hide Headless", key: "h", selector: "hide:", - target: .application + target: .application, separatorBefore: true ), .init( menu: "Headless", title: "Hide Others", key: "h", option: true, diff --git a/apps/headless/Sources/HeadlessProtocol/Settings.swift b/apps/headless/Sources/HeadlessProtocol/Settings.swift index d3e883c..a92d547 100644 --- a/apps/headless/Sources/HeadlessProtocol/Settings.swift +++ b/apps/headless/Sources/HeadlessProtocol/Settings.swift @@ -285,6 +285,27 @@ public final class SettingsStore: @unchecked Sendable { return mutationDocument(definition, value: definition.defaultValue, configured: false) } + public func snapshots(caller: SettingsCaller = .agent) throws -> [SettingSnapshot] { + try registry.definitions.compactMap { definition in + guard canRead(definition, caller: caller) else { return nil } + return try snapshot(of: definition) + } + } + + public func snapshot(_ key: String, caller: SettingsCaller = .agent) throws -> SettingSnapshot { + try snapshot(of: try visibleDefinition(key, caller: caller)) + } + + private func snapshot(of definition: SettingDefinition) throws -> SettingSnapshot { + let configured = try configuredRawValue(definition) + return SettingSnapshot( + definition: definition, + value: configured ?? definition.defaultValue, + configured: configured != nil, + supportedOnCurrentPlatform: definition.platforms.contains(platform) + ) + } + private func accessibleDefinition( _ key: String, caller: SettingsCaller, write: Bool ) throws -> SettingDefinition { @@ -364,19 +385,91 @@ public final class SettingsStore: @unchecked Sendable { } } +public struct SettingSnapshot: Equatable, Sendable { + public let definition: SettingDefinition + public let value: String + public let configured: Bool + public let supportedOnCurrentPlatform: Bool + + public var agentsMayModify: Bool { definition.access == .agentWritable } + + /// User-only strings are rendered conservatively because the settings + /// registry has no secret-bearing value type. Credential values remain + /// outside this store, but an accidentally added user-only string must not + /// become plain, Accessibility-readable text. + public var usesSecureTextEntry: Bool { + guard definition.access == .userOnly else { return false } + if case .string = definition.valueType { return true } + return false + } + + public var displayedDefaultValue: String { + usesSecureTextEntry ? "Hidden" : definition.defaultValue + } + + public var selectableValues: [String]? { + switch definition.valueType { + case .boolean: + return ["false", "true"] + case .enumeration(let values): + return values + case .integer, .string: + return nil + } + } + + public var platformSummary: String { + definition.platforms.sorted { $0.rawValue < $1.rawValue }.map(\.rawValue).joined(separator: ", ") + } +} + +/// Trusted native surface over `SettingsStore`. Always acts as the user so +/// CLI `config` and the macOS Settings window share validation and storage. +public final class SettingsController: @unchecked Sendable { + public let store: SettingsStore + + public init(store: SettingsStore) { + self.store = store + } + + public func snapshots() throws -> [SettingSnapshot] { + try store.snapshots(caller: .user) + } + + @discardableResult + public func set(_ key: String, rawValue: String) throws -> SettingSnapshot { + _ = try store.set(key, rawValue: rawValue, caller: .user) + return try store.snapshot(key, caller: .user) + } + + @discardableResult + public func reset(_ key: String) throws -> SettingSnapshot { + _ = try store.reset(key, caller: .user) + return try store.snapshot(key, caller: .user) + } +} + public final class UserDefaultsSettingsBackend: @unchecked Sendable, SettingsBackend { private static let domain = "com.headless.app" private static let canonicalPrefix = "HeadlessSetting." private let defaults: UserDefaults public convenience init() throws { - try self.init(suiteName: Self.domain) + if Bundle.main.bundleIdentifier == Self.domain { + self.init(defaults: .standard) + } else { + try self.init(suiteName: Self.domain) + } } - public init(suiteName: String) throws { + public convenience init(suiteName: String) throws { guard let defaults = UserDefaults(suiteName: suiteName) else { throw SettingsError.operationFailed("preferences access") } + self.init(defaults: defaults) + } + + public init(defaults: UserDefaults) { self.defaults = defaults } diff --git a/apps/headless/Tests/HeadlessProtocolTests/ProtocolTests.swift b/apps/headless/Tests/HeadlessProtocolTests/ProtocolTests.swift index 26f1c61..9fadb82 100644 --- a/apps/headless/Tests/HeadlessProtocolTests/ProtocolTests.swift +++ b/apps/headless/Tests/HeadlessProtocolTests/ProtocolTests.swift @@ -1783,6 +1783,140 @@ struct ProtocolTests { } } + static func settingsControllerUsesSharedBackend() throws { + let root = URL(fileURLWithPath: "/tmp/headless-settings-ui-\(UUID().uuidString)") + defer { try? FileManager.default.removeItem(at: root) } + let ui = SettingsController( + store: SettingsStore(platform: .macOS, backend: try FileSettingsBackend(rootURL: root)) + ) + let cli = SettingsStore(platform: .macOS, backend: try FileSettingsBackend(rootURL: root)) + let rows = try ui.snapshots() + try expect( + rows.map(\.definition.key) == SettingsRegistry.shared.definitions.map(\.key), + "the settings window must render registry definitions, not a second key list" + ) + let startup = rows.first { $0.definition.key == "startup-presentation" } + try expect(startup?.value == "background", "snapshots should start at the registry default") + try expect(startup?.definition.summary.isEmpty == false, "snapshots should carry the registry summary") + try expect(startup?.agentsMayModify == true, "startup-presentation is agent-writable") + try expect(startup?.platformSummary == "macos", "snapshots should expose platform scope") + try expect( + startup?.definition.restartBehavior == .nextHostStart, + "snapshots should expose restart behavior" + ) + try expect(startup?.selectableValues == ["background", "foreground"], "enum values should come from the registry") + try expect(startup?.usesSecureTextEntry == false, "agent-writable enums should use ordinary controls") + try expect(startup?.displayedDefaultValue == "background", "ordinary defaults should remain visible") + + _ = try ui.set("startup-presentation", rawValue: "foreground") + try expect( + try cli.effectiveRawValue("startup-presentation") == "foreground", + "CLI get should observe Settings window writes" + ) + _ = try cli.set("startup-presentation", rawValue: "background") + try expect( + try ui.snapshots().first { $0.definition.key == "startup-presentation" }?.value == "background", + "Settings window should observe CLI writes through the same backend" + ) + _ = try ui.reset("startup-presentation") + try expect( + try cli.effectiveRawValue("startup-presentation") == "background", + "reset from the window should restore the registry default" + ) + try expectSettingsError(.invalidValue("automatic"), "the window must use store validation") { + _ = try ui.set("startup-presentation", rawValue: "automatic") + } + + let linux = SettingsController( + store: SettingsStore(platform: .linux, backend: TestSettingsBackend()) + ) + let unsupported = try linux.snapshots().first { $0.definition.key == "startup-presentation" } + try expect( + unsupported?.supportedOnCurrentPlatform == false, + "Linux snapshots must not pretend startup-presentation is writable" + ) + try expectSettingsError( + .unsupportedPlatform("startup-presentation"), + "Linux must reject settings-window writes for macOS-only keys" + ) { + _ = try linux.set("startup-presentation", rawValue: "foreground") + } + + let suite = "com.headless.tests.settings.ui.\(UUID().uuidString)" + guard let defaults = UserDefaults(suiteName: suite) else { + throw TestFailure(description: "isolated UserDefaults suite should be available") + } + defaults.removePersistentDomain(forName: suite) + defer { + defaults.removePersistentDomain(forName: suite) + _ = defaults.synchronize() + } + let defaultsBackend = try UserDefaultsSettingsBackend(suiteName: suite) + let defaultsUI = SettingsController( + store: SettingsStore(platform: .macOS, backend: defaultsBackend) + ) + let defaultsCLI = SettingsStore( + platform: .macOS, backend: try UserDefaultsSettingsBackend(suiteName: suite) + ) + _ = try defaultsUI.set("startup-presentation", rawValue: "foreground") + try expect( + try defaultsCLI.effectiveRawValue("startup-presentation") == "foreground", + "UserDefaults-backed window writes should match CLI config set" + ) + _ = try defaultsCLI.reset("startup-presentation") + try expect( + try defaultsUI.snapshots().first { $0.definition.key == "startup-presentation" }?.configured == false, + "UserDefaults-backed reset should match CLI config reset" + ) + + let definitions = [ + SettingDefinition( + key: "private-policy", valueType: .boolean, defaultValue: "false", + platforms: [.macOS, .linux], restartBehavior: .immediate, access: .userOnly, + summary: "User-only policy" + ), + SettingDefinition( + key: "shared-flag", valueType: .boolean, defaultValue: "false", + platforms: [.macOS, .linux], restartBehavior: .immediate, access: .agentWritable, + summary: "Shared flag" + ), + SettingDefinition( + key: "user-secret", valueType: .string(maximumLength: 64), defaultValue: "unset", + platforms: [.macOS], restartBehavior: .immediate, access: .userOnly, + summary: "User-only secret" + ), + ] + let registry = SettingsRegistry(definitions: definitions) + let trusted = SettingsController( + store: SettingsStore(registry: registry, platform: .macOS, backend: TestSettingsBackend()) + ) + try expect( + try trusted.snapshots().map(\.definition.key) == ["private-policy", "shared-flag", "user-secret"], + "the native surface should include user-only definitions" + ) + let secret = try trusted.store.snapshot("user-secret", caller: .user) + try expect(secret.usesSecureTextEntry, "user-only strings must fail closed to secure text entry") + try expect(secret.displayedDefaultValue == "Hidden", "secure defaults must not be displayed") + _ = try trusted.set("user-secret", rawValue: "test-secret") + try expectSettingsError( + .unknownKey("user-secret"), + "agent callers must not see user-only string values" + ) { + _ = try trusted.store.get("user-secret", caller: .agent) + } + _ = try trusted.set("private-policy", rawValue: "true") + try expectSettingsError( + .unknownKey("private-policy"), + "agent callers must still not see user-only keys" + ) { + _ = try trusted.store.get("private-policy", caller: .agent) + } + try expect( + try trusted.store.effectiveRawValue("private-policy", caller: .user) == "true", + "the native surface should write user-only keys as the user" + ) + } + static func credentialCommandSecurity() throws { try expect( try CredentialOrigin(rawValue: "HTTPS://EXAMPLE.COM:443/").rawValue == "https://example.com", @@ -3389,10 +3523,24 @@ struct ProtocolTests { let pin = headlessMenuShortcuts.first { $0.title == "Pin on Top" } try expect(pin?.key == "p" && pin?.command == true && pin?.option == true && pin?.shift == false, "Pin on Top should be Cmd-Option-P, not Cmd-P") + let settings = headlessMenuShortcuts.first { $0.title == "Settings…" } + try expect( + settings?.key == "," && settings?.command == true && settings?.shift == false + && settings?.option == false && settings?.control == false, + "Settings should be Command-, with no shift or option" + ) try expect( - !headlessMenuShortcuts.contains { $0.key == "," }, - "Cmd-, is reserved for a future Settings window" + settings?.selector == "showSettings:" && settings?.target == .appDelegate + && settings?.menu == "Headless", + "Settings must live in the Headless menu and target the app delegate" ) + let appMenuTitles = headlessMenuShortcuts.filter { $0.menu == "Headless" }.map(\.title) + if let settingsIndex = appMenuTitles.firstIndex(of: "Settings…"), + let hideIndex = appMenuTitles.firstIndex(of: "Hide Headless") { + try expect(settingsIndex < hideIndex, "Settings should sit above Hide Headless") + } else { + throw TestFailure(description: "Headless menu is missing Settings… or Hide Headless") + } let snapshot = headlessMenuShortcuts.first { $0.title == "Save Snapshot to Desktop" } try expect(snapshot?.key == "s" && snapshot?.shift == true, "snapshot capture should stay Cmd-Shift-S") @@ -3435,10 +3583,16 @@ struct ProtocolTests { ) let p0 = try String(contentsOfFile: "docs/P0.md", encoding: .utf8) try expect( - p0.contains("Cmd-Option-P") && p0.contains("Cmd-Shift-S"), - "P0 should document the Pin and snapshot chords" + p0.contains("Cmd-Option-P") && p0.contains("Cmd-Shift-S") && p0.contains("Cmd-,"), + "P0 should document the Pin, snapshot, and Settings chords" + ) + try expect( + !p0.contains("reserved for a future Settings window"), + "P0 should document that Cmd-, opens Settings" ) let host = try String(contentsOfFile: "main.swift", encoding: .utf8) + let settingsWindow = try String(contentsOfFile: "Host/SettingsWindow.swift", encoding: .utf8) + let hostSource = host + "\n" + settingsWindow try expect( host.contains("⌥⌘ P"), "start page should advertise Option-Command-P for pin" @@ -3451,6 +3605,27 @@ struct ProtocolTests { host.contains("NSSelectorFromString(spec.selector)"), "menu items must take their actions from the catalog" ) + try expect( + hostSource.contains("func showSettings("), + "host must implement the Settings catalog selector" + ) + try expect( + hostSource.contains("NSSecureTextField") && hostSource.contains("NSScrollView"), + "Settings must protect user-only strings and keep long content reachable" + ) + let linuxHost = try String(contentsOfFile: "LinuxHost/main.swift", encoding: .utf8) + try expect( + !linuxHost.contains("showSettings") && !linuxHost.contains("SettingsWindow"), + "Linux must not ship a Settings GUI" + ) + try expect( + agentHelp.contains("macOS Command-,"), + "agent help should mention the macOS Settings shortcut" + ) + try expect( + !agentHelp.lowercased().contains("linux command-,"), + "agent help must not claim a Linux Settings shortcut" + ) } static func authenticationProtocolAndChallengeLifecycle() throws { @@ -4104,6 +4279,7 @@ struct ProtocolTests { ("UserDefaults settings compatibility", userDefaultsSettingsCompatibility), ("file settings backend security and persistence", fileSettingsBackendSecurityAndPersistence), ("file settings backend concurrent writers", fileSettingsBackendConcurrentWriters), + ("settings controller uses shared backend", settingsControllerUsesSharedBackend), ("credential command security", credentialCommandSecurity), ("credential vault lifecycle", credentialVaultLifecycle), ("credential confirmation", credentialVaultRejectsMismatchedConfirmation), diff --git a/apps/headless/Tests/macos-e2e.sh b/apps/headless/Tests/macos-e2e.sh index ecdbfbc..4816878 100755 --- a/apps/headless/Tests/macos-e2e.sh +++ b/apps/headless/Tests/macos-e2e.sh @@ -197,6 +197,125 @@ end run APPLESCRIPT } +ax_named_window_count() { + local pid="$1" window_title="$2" + osascript_with_timeout - "$pid" "$window_title" <<'APPLESCRIPT' +on run argv + set targetPID to item 1 of argv as integer + set windowTitle to item 2 of argv + tell application "System Events" + set targetProcesses to every application process whose unix id is targetPID + if (count of targetProcesses) is not 1 then error "Headless accessibility process was not found" + tell item 1 of targetProcesses + return count of (every window whose name is windowTitle) + end tell + end tell +end run +APPLESCRIPT +} + +ax_settings_value() { + local pid="$1" + osascript_with_timeout - "$pid" <<'APPLESCRIPT' +on run argv + set targetPID to item 1 of argv as integer + tell application "System Events" + set targetProcesses to every application process whose unix id is targetPID + if (count of targetProcesses) is not 1 then error "Headless accessibility process was not found" + tell item 1 of targetProcesses + tell window "Settings" + if (count of scroll areas) is not 1 then error "Settings scroll area was not found" + tell first scroll area + if (count of pop up buttons) is not 1 then error "Settings value control was not found" + return value of first pop up button as text + end tell + end tell + end tell + end tell +end run +APPLESCRIPT +} + +ax_select_settings_value() { + local pid="$1" selected_value="$2" + osascript_with_timeout - "$pid" "$selected_value" <<'APPLESCRIPT' +on run argv + set targetPID to item 1 of argv as integer + set selectedValue to item 2 of argv + tell application "System Events" + set targetProcesses to every application process whose unix id is targetPID + if (count of targetProcesses) is not 1 then error "Headless accessibility process was not found" + tell item 1 of targetProcesses + tell window "Settings" + tell first pop up button of first scroll area + perform action "AXPress" + delay 0.1 + perform action "AXPress" of menu item selectedValue of menu 1 + end tell + end tell + end tell + end tell +end run +APPLESCRIPT +} + +ax_reset_settings() { + local pid="$1" + osascript_with_timeout - "$pid" <<'APPLESCRIPT' +on run argv + set targetPID to item 1 of argv as integer + tell application "System Events" + set targetProcesses to every application process whose unix id is targetPID + if (count of targetProcesses) is not 1 then error "Headless accessibility process was not found" + tell item 1 of targetProcesses + perform action "AXPress" of button "Reset to Default" of first scroll area of window "Settings" + end tell + end tell +end run +APPLESCRIPT +} + +ax_settings_layout() { + local pid="$1" + osascript_with_timeout - "$pid" <<'APPLESCRIPT' +on run argv + set targetPID to item 1 of argv as integer + tell application "System Events" + set targetProcesses to every application process whose unix id is targetPID + if (count of targetProcesses) is not 1 then error "Headless accessibility process was not found" + tell item 1 of targetProcesses + tell window "Settings" + set size to {400, 240} + set scrollCount to count of scroll areas + if scrollCount is not 1 then return "0" & tab & "0" & tab & (scrollCount as text) + tell first scroll area + set popupCount to count of pop up buttons + set resetCount to count of buttons + end tell + return (popupCount as text) & tab & (resetCount as text) & tab & (scrollCount as text) + end tell + end tell + end tell +end run +APPLESCRIPT +} + +ax_settings_reset_enabled() { + local pid="$1" + osascript_with_timeout - "$pid" <<'APPLESCRIPT' +on run argv + set targetPID to item 1 of argv as integer + tell application "System Events" + set targetProcesses to every application process whose unix id is targetPID + if (count of targetProcesses) is not 1 then error "Headless accessibility process was not found" + tell item 1 of targetProcesses + return enabled of button "Reset to Default" of first scroll area of window "Settings" + end tell + end tell +end run +APPLESCRIPT +} + ax_focused_element() { local pid="$1" osascript_with_timeout - "$pid" <<'APPLESCRIPT' @@ -340,6 +459,20 @@ fixture_request_count() { print -r -- "$count" } +wait_for_auth_state() { + local expected_cookie="$1" expected_storage="$2" snapshot="" + for _ in {1..100}; do + if snapshot="$("$CLI" inspect --text 2>/dev/null)" && + echo "$snapshot" | grep -q "Cookie state: $expected_cookie" && + echo "$snapshot" | grep -q "Storage state: $expected_storage"; then + return 0 + fi + sleep 0.05 + done + print -r -u2 -- "Authentication state did not settle: $snapshot" + return 1 +} + assert_menu_shortcut() { local pid="$1" menu_title="$2" item_title="$3" expected_key="$4" expected_modifiers="$5" local actual_key actual_modifiers @@ -462,6 +595,17 @@ cleanup() { } trap cleanup EXIT INT TERM +STEP="settings-window-source" +grep -q 'func showSettings' main.swift +grep -q 'orderFrontShared' Host/SettingsWindow.swift +grep -q 'title: "Settings…"' Sources/HeadlessProtocol/MenuShortcuts.swift +grep -q 'selector: "showSettings:"' Sources/HeadlessProtocol/MenuShortcuts.swift +grep -q 'key: ","' Sources/HeadlessProtocol/MenuShortcuts.swift +if grep -q 'showSettings' LinuxHost/main.swift || grep -q 'SettingsWindow' LinuxHost/main.swift; then + echo "Linux host must not ship a Settings GUI" >&2 + exit 1 +fi + STEP="fixture-server" for _ in {1..100}; do curl -fsS "http://127.0.0.1:$PORT/designers/dashboard" >/dev/null 2>&1 && break @@ -573,6 +717,7 @@ SUPERVISED_FIFO="" SUPERVISED_OUTPUT="" STEP="start-host" +SETTINGS_PREVIOUS_FRONTMOST_PID="$(frontmost_pid)" START_RESULT="$("$CLI" start)" || { print -r -u2 -- "headless start failed:" print -r -u2 -- "$START_RESULT" @@ -583,6 +728,124 @@ echo "$START_RESULT" | grep -q '"ready":true' || { fail } echo "▸ host ready" +HOST_PID="$(echo "$START_RESULT" | sed -n 's/.*"pid":\([0-9][0-9]*\).*/\1/p')" +test -n "$HOST_PID" +if [[ "$(frontmost_pid)" == "$HOST_PID" ]]; then + echo "default agent startup stole focus" >&2 + fail +fi + +STEP="settings-window-workflow" +ax_press_menu_item "$HOST_PID" Headless "Settings…" +for _ in {1..100}; do + [[ "$(ax_named_window_count "$HOST_PID" Settings 2>/dev/null)" == 1 ]] && break + sleep 0.05 +done +if [[ "$(ax_named_window_count "$HOST_PID" Settings)" != 1 ]]; then + echo "Settings menu did not open exactly one Settings window" >&2 + fail +fi +if [[ "$(ax_front_window_attribute "$HOST_PID" AXTitle)" != Settings ]]; then + echo "Settings window did not become the front window" >&2 + fail +fi +if [[ "$(ax_settings_value "$HOST_PID")" != background ]]; then + echo "Settings window did not load the configured background value" >&2 + fail +fi +if [[ "$(ax_focused_element "$HOST_PID")" != AXPopUpButton$'\t'background ]]; then + echo "Settings window did not focus its first editor for keyboard navigation" >&2 + fail +fi +SETTINGS_LAYOUT="$(ax_settings_layout "$HOST_PID")" +if [[ "$SETTINGS_LAYOUT" != $'1\t1\t1' ]]; then + echo "Settings controls were not reachable in the minimum-size scrolling layout: $SETTINGS_LAYOUT" >&2 + fail +fi +ax_press_menu_item "$HOST_PID" Headless "Settings…" +if [[ "$(ax_named_window_count "$HOST_PID" Settings)" != 1 ]]; then + echo "reopening Settings created a duplicate window" >&2 + fail +fi +ax_select_settings_value "$HOST_PID" foreground +for _ in {1..100}; do + UI_PRESENTATION="$("$CLI" config get startup-presentation)" + echo "$UI_PRESENTATION" | grep -q '"configured":"foreground"' && break + sleep 0.05 +done +echo "$UI_PRESENTATION" | grep -q '"configured":"foreground"' +test "$(defaults read "$DEFAULTS_DOMAIN" "$PRESENTATION_KEY")" = "foreground" +ax_keystroke "$HOST_PID" w command +for _ in {1..100}; do + [[ "$(ax_named_window_count "$HOST_PID" Settings 2>/dev/null)" == 0 ]] && break + sleep 0.05 +done +if [[ "$(ax_named_window_count "$HOST_PID" Settings)" != 0 ]]; then + echo "Settings window did not close" >&2 + fail +fi + +"$CLI" stop >/dev/null +for _ in {1..100}; do + ! kill -0 "$HOST_PID" >/dev/null 2>&1 && break + sleep 0.05 +done +if kill -0 "$HOST_PID" >/dev/null 2>&1; then + echo "host did not stop for Settings persistence check" >&2 + fail +fi +activate_pid "$SETTINGS_PREVIOUS_FRONTMOST_PID" +START_RESULT="$("$CLI" start)" +HOST_PID="$(echo "$START_RESULT" | sed -n 's/.*"pid":\([0-9][0-9]*\).*/\1/p')" +test -n "$HOST_PID" +for _ in {1..100}; do + [[ "$(frontmost_pid)" == "$HOST_PID" ]] && break + sleep 0.05 +done +if [[ "$(frontmost_pid)" != "$HOST_PID" ]]; then + echo "foreground Settings value did not take effect on the next host start" >&2 + fail +fi +ax_press_menu_item "$HOST_PID" Headless "Settings…" +if [[ "$(ax_settings_value "$HOST_PID")" != foreground ]]; then + echo "Settings value did not persist across host restart" >&2 + fail +fi +ax_reset_settings "$HOST_PID" +for _ in {1..100}; do + UI_PRESENTATION="$("$CLI" config get startup-presentation)" + echo "$UI_PRESENTATION" | grep -q '"configured":null' && break + sleep 0.05 +done +echo "$UI_PRESENTATION" | grep -q '"configured":null' +echo "$UI_PRESENTATION" | grep -q '"startupPresentation":"background"' +if [[ "$(ax_settings_value "$HOST_PID")" != background ]]; then + echo "Settings reset did not restore the default value" >&2 + fail +fi +if [[ "$(ax_settings_reset_enabled "$HOST_PID")" != false ]]; then + echo "Settings reset remained enabled after restoring the default" >&2 + fail +fi +ax_keystroke "$HOST_PID" w command + +"$CLI" stop >/dev/null +for _ in {1..100}; do + ! kill -0 "$HOST_PID" >/dev/null 2>&1 && break + sleep 0.05 +done +if kill -0 "$HOST_PID" >/dev/null 2>&1; then + echo "foreground host did not stop after Settings reset" >&2 + fail +fi +activate_pid "$SETTINGS_PREVIOUS_FRONTMOST_PID" +START_RESULT="$("$CLI" start)" +HOST_PID="$(echo "$START_RESULT" | sed -n 's/.*"pid":\([0-9][0-9]*\).*/\1/p')" +test -n "$HOST_PID" +if [[ "$(frontmost_pid)" == "$HOST_PID" ]]; then + echo "reset startup presentation did not restore background launch" >&2 + fail +fi if AUTH_REQUIRED="$("$CLI" visit "http://127.0.0.1:$PORT/auth-login" 2>&1)"; then print -r -u2 -- "confirmed login form did not require authentication" @@ -598,8 +861,6 @@ echo "$AUTH_REQUIRED" | grep -q '"credentialUseAvailable":true' "$CLI" click @e3 | grep -q '"clicked"' "$CLI" wait --text 'Signed in' | grep -q 'Signed in' STEP="tcp-check" -HOST_PID="$(echo "$START_RESULT" | sed -n 's/.*"pid":\([0-9][0-9]*\).*/\1/p')" -test -n "$HOST_PID" if [[ "$(frontmost_pid)" == "$HOST_PID" ]]; then echo "default agent startup stole focus" >&2 fail @@ -621,6 +882,7 @@ STEP="menu-shortcut-inventory" while IFS=$'\t' read -r menu_title item_title expected_key expected_modifiers; do assert_menu_shortcut "$HOST_PID" "$menu_title" "$item_title" "$expected_key" "$expected_modifiers" done <<'SHORTCUTS' +Headless Settings… , 0 Headless Hide Headless h 0 Headless Hide Others h 2 Headless Quit Headless q 0 @@ -647,9 +909,13 @@ Window Pin on Top p 2 Help Headless Help / 1 SHORTCUTS assert_system_full_screen_shortcut "$HOST_PID" -for settings_title in "Settings" "Settings…" "Preferences" "Preferences…"; do +if [[ "$(ax_menu_exists "$HOST_PID" Headless "Settings…")" != true ]]; then + echo "Headless > Settings… was not exposed through Accessibility" >&2 + fail +fi +for settings_title in "Settings" "Preferences" "Preferences…"; do if [[ "$(ax_menu_exists "$HOST_PID" Headless "$settings_title")" == true ]]; then - echo "$settings_title is shipped but has no Cmd-, coverage" >&2 + echo "$settings_title should not duplicate Settings…" >&2 fail fi done @@ -1142,8 +1408,7 @@ STEP="durable-authentication-profile" "$CLI" start --background | grep -q '"ready":true' STEP="durable-authentication-login" "$CLI" visit "http://127.0.0.1:$PORT/auth-state?action=login" | grep -q 'Authentication State' -"$CLI" inspect --text | grep -q 'Cookie state: signed-in' -"$CLI" inspect --text | grep -q 'Storage state: signed-in' +wait_for_auth_state signed-in signed-in STEP="durable-authentication-first-stop" PROFILE_RESTART_PID="$("$CLI" status | sed -n 's/.*"pid":\([0-9][0-9]*\).*/\1/p')" test -n "$PROFILE_RESTART_PID" @@ -1159,12 +1424,10 @@ fi STEP="durable-authentication-persisted-state" "$CLI" start --background | grep -q '"ready":true' "$CLI" visit "http://127.0.0.1:$PORT/auth-state?action=check" | grep -q 'Authentication State' -"$CLI" inspect --text | grep -q 'Cookie state: signed-in' -"$CLI" inspect --text | grep -q 'Storage state: signed-in' +wait_for_auth_state signed-in signed-in STEP="durable-authentication-logout" "$CLI" visit "http://127.0.0.1:$PORT/auth-state?action=logout" >/dev/null -"$CLI" inspect --text | grep -q 'Cookie state: missing' -"$CLI" inspect --text | grep -q 'Storage state: missing' +wait_for_auth_state missing missing STEP="durable-authentication-second-stop" LOGOUT_RESTART_PID="$("$CLI" status | sed -n 's/.*"pid":\([0-9][0-9]*\).*/\1/p')" test -n "$LOGOUT_RESTART_PID" @@ -1180,8 +1443,7 @@ fi STEP="durable-authentication-persisted-logout" "$CLI" start --background >/dev/null "$CLI" visit "http://127.0.0.1:$PORT/auth-state?action=check" >/dev/null -"$CLI" inspect --text | grep -q 'Cookie state: missing' -"$CLI" inspect --text | grep -q 'Storage state: missing' +wait_for_auth_state missing missing STEP="durable-authentication-profile-clear" "$CLI" visit "http://127.0.0.1:$PORT/auth-state?action=login" >/dev/null "$CLI" profile clear | grep -q '"cleared":true' diff --git a/apps/headless/docs/COMMANDS.md b/apps/headless/docs/COMMANDS.md index dbd8375..6aba677 100644 --- a/apps/headless/docs/COMMANDS.md +++ b/apps/headless/docs/COMMANDS.md @@ -76,8 +76,15 @@ effect timing, and one access class: - `agent-readable` is visible to agent callers but cannot be changed by them. - `agent-writable` is visible and mutable by agent callers. - `user-only` is omitted from `list` and rejected as unknown by `describe`, - `get`, `set`, and `reset` through the agent CLI. A future trusted native or - OS-authenticated surface is required to access it. + `get`, `set`, and `reset` through the agent CLI. The macOS Settings window + (Command-,) operates as the user against the same registry. Linux has no + Settings GUI. + +On macOS, Command-, opens or focuses one Settings window built from the +registry. It shows each setting's current value, default, platform, restart +behavior, and whether agents may modify it, and it writes through the same +backend as `headless config set`. `headless config` remains the portable path +on every platform. On macOS, the registry uses the `com.headless.app` preferences domain and keeps the existing `AgentStartupPresentation` storage key, avoiding migration or diff --git a/apps/headless/docs/P0.md b/apps/headless/docs/P0.md index 0656c2a..bf83a6c 100644 --- a/apps/headless/docs/P0.md +++ b/apps/headless/docs/P0.md @@ -54,7 +54,8 @@ configuration. Menu chords live in `MenuShortcuts.swift` and are tested for uniqueness. Pin on Top is Cmd-Option-P so it does not take Cmd-P (Print). Snapshot is -Cmd-Shift-S. Cmd-, is reserved for a future Settings window and is not wired. +Cmd-Shift-S. Cmd-, opens the Settings window, which is built from the typed +registry and writes through the same backend as `headless config`. ## Security boundaries @@ -85,8 +86,9 @@ recorder, OBS/FFmpeg, CI recorder, or a later built-in recorder. - Shared protocol suite: codec bounds, URL/identifier/parameter allowlists, CLI parsing, private socket permissions, live-socket replacement protection. -- macOS E2E: typed settings discovery and mutation compatibility, named - session, semantic snapshot, isolated-world tamper test, +- macOS E2E: typed settings discovery and mutation compatibility, Settings + window Cmd-, wiring, named session, semantic snapshot, isolated-world + tamper test, bounded hostile-page output, fill/press, external-scheme rejection, full-page tours, click/wait, back/reload, and recorder metadata. - Linux E2E: settings discovery with unsupported mutation checks, the same diff --git a/apps/headless/main.swift b/apps/headless/main.swift index 6671431..69bfa70 100644 --- a/apps/headless/main.swift +++ b/apps/headless/main.swift @@ -1073,6 +1073,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate { @objc func newWindow(_ sender: Any?) { openWindow(url: nil) } + @objc func showSettings(_ sender: Any?) { + SettingsWindowController.orderFrontShared(sender) + } + // Agent hosts must stay alive when the last session window closes; the CLI // owns process lifetime via `headless stop` / shutdown. func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { !isAgentHost } @@ -1128,6 +1132,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { action: #selector(NSApplication.orderFrontStandardAboutPanel(_:)), keyEquivalent: "") appMenu.addItem(.separator()) for spec in headlessMenuShortcuts where spec.menu == "Headless" && spec.title != "Quit Headless" { + if spec.separatorBefore { appMenu.addItem(.separator()) } appMenu.addItem(menuItem(from: spec)) } appMenu.addItem(withTitle: "Show All", action: #selector(NSApplication.unhideAllApplications(_:)), keyEquivalent: "") diff --git a/docs/roadmap/architecture-decisions.md b/docs/roadmap/architecture-decisions.md index 6cd4fb8..88776f8 100644 --- a/docs/roadmap/architecture-decisions.md +++ b/docs/roadmap/architecture-decisions.md @@ -719,9 +719,13 @@ each key's type, default, platform scope, effect timing, access class, storage identity, and validation. The local CLI provides `config list`, `describe`, `get`, `set`, and `reset`; these commands never enter the browser protocol or MCP surface. Agent callers cannot discover user-only keys, cannot mutate -agent-readable keys, and can mutate only agent-writable keys. A future trusted -native surface may operate as the user, but ordinary CLI or PTY presence is not -proof of a human. +agent-readable keys, and can mutate only agent-writable keys. The macOS +Settings window (Command-,) is the trusted native surface: it is built from the +same registry and writes through the same backends as `headless config`. Linux +has no Settings GUI. User-only string preferences render as secure text and +redact their defaults from Accessibility; credential values and approvals +remain outside the settings store. Ordinary CLI or PTY presence is not proof +of a human. macOS stores preferences in the existing `com.headless.app` UserDefaults domain. The initial `startup-presentation` definition deliberately retains its @@ -732,7 +736,8 @@ lock and data file, descriptor-relative no-follow operations, strict decoding, locking, atomic replacement, and file plus directory synchronization. **Status:** implemented 2026-09-12 by -[#153](https://github.com/LockInTime/headless/issues/153). +[#153](https://github.com/LockInTime/headless/issues/153). The macOS Settings +window landed in [#158](https://github.com/LockInTime/headless/issues/158). **Rationale:** settings need one discoverable contract before more preferences arrive, but moving host security boundaries into a writable preference would