Skip to content

Commit 313d08a

Browse files
authored
Merge pull request #185 from LockInTime/feat/macos-settings-window
feat(macos): open Settings from the registry with Cmd-,
2 parents 478cff7 + 8275a1d commit 313d08a

13 files changed

Lines changed: 887 additions & 31 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,11 @@ Two versions travel independently, on purpose:
1818

1919
## [Unreleased]
2020

21-
No changes yet.
21+
### Added
22+
23+
- macOS Command-, opens one Settings window built from the typed registry and
24+
writing through the same backend as `headless config`. Linux has no Settings
25+
GUI.
2226

2327
## [1.1.0] — 2026-08-21
2428

‎README.md‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,9 @@ startup-presentation foreground` or restore background startup with `headless
100100
config set startup-presentation background`; inspect it with `headless config
101101
get startup-presentation`. Use `config list` to discover settings, `config
102102
describe KEY` for type and policy metadata, and `config reset KEY` to restore a
103-
built-in default. `headless start --foreground` and `headless start
103+
built-in default. On macOS, Command-, opens Settings for the same registry;
104+
`headless config` remains the portable path, and Linux has no Settings GUI.
105+
`headless start --foreground` and `headless start
104106
--background` are one-launch overrides. Settings and overrides apply only when
105107
launching a new host and do not reorder an already-running host.
106108

Lines changed: 292 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,292 @@
1+
import AppKit
2+
import HeadlessProtocol
3+
4+
private final class FlippedSettingsDocumentView: NSView {
5+
override var isFlipped: Bool { true }
6+
}
7+
8+
final class SettingsWindowController: NSWindowController, NSWindowDelegate, NSTextFieldDelegate {
9+
private static var sharedController: SettingsWindowController?
10+
11+
static func orderFrontShared(_ sender: Any?) {
12+
if sharedController == nil {
13+
do {
14+
let store = try SettingsStore.production()
15+
sharedController = try SettingsWindowController(store: store)
16+
} catch {
17+
presentOpenFailure(error)
18+
return
19+
}
20+
}
21+
sharedController?.showWindow(sender)
22+
sharedController?.window?.makeKeyAndOrderFront(sender)
23+
NSApp.activate(ignoringOtherApps: true)
24+
}
25+
26+
private static func presentOpenFailure(_ error: Error) {
27+
let alert = NSAlert()
28+
alert.alertStyle = .critical
29+
alert.messageText = "Couldn’t open Settings"
30+
alert.informativeText = (error as? SettingsError)?.description ?? error.localizedDescription
31+
alert.addButton(withTitle: "OK")
32+
alert.runModal()
33+
}
34+
35+
private let settings: SettingsController
36+
private let stack = NSStackView()
37+
private let scrollView = NSScrollView()
38+
private let documentView = FlippedSettingsDocumentView()
39+
private var isReloading = false
40+
private var firstEditor: NSView?
41+
42+
private init(store: SettingsStore) throws {
43+
settings = SettingsController(store: store)
44+
let window = NSWindow(
45+
contentRect: NSRect(x: 0, y: 0, width: 520, height: 320),
46+
styleMask: [.titled, .closable, .miniaturizable, .resizable],
47+
backing: .buffered,
48+
defer: false
49+
)
50+
window.title = "Settings"
51+
window.minSize = NSSize(width: 400, height: 240)
52+
window.isReleasedWhenClosed = false
53+
window.tabbingMode = .disallowed
54+
window.setFrameAutosaveName("HeadlessSettings")
55+
window.identifier = NSUserInterfaceItemIdentifier("headless-settings")
56+
window.autorecalculatesKeyViewLoop = true
57+
super.init(window: window)
58+
window.delegate = self
59+
buildContent()
60+
try reload()
61+
let fitted = max(stack.fittingSize.height + 8, 260)
62+
window.setContentSize(NSSize(width: 520, height: min(fitted, 640)))
63+
window.center()
64+
}
65+
66+
required init?(coder: NSCoder) { fatalError("not used") }
67+
68+
func windowDidBecomeKey(_ notification: Notification) {
69+
try? reload()
70+
}
71+
72+
private func buildContent() {
73+
guard let content = window?.contentView else { return }
74+
scrollView.drawsBackground = false
75+
scrollView.hasVerticalScroller = true
76+
scrollView.autohidesScrollers = true
77+
scrollView.translatesAutoresizingMaskIntoConstraints = false
78+
documentView.translatesAutoresizingMaskIntoConstraints = false
79+
scrollView.documentView = documentView
80+
content.addSubview(scrollView)
81+
82+
stack.orientation = .vertical
83+
stack.alignment = .leading
84+
stack.spacing = 18
85+
stack.edgeInsets = NSEdgeInsets(top: 20, left: 22, bottom: 20, right: 22)
86+
stack.translatesAutoresizingMaskIntoConstraints = false
87+
documentView.addSubview(stack)
88+
NSLayoutConstraint.activate([
89+
scrollView.leadingAnchor.constraint(equalTo: content.leadingAnchor),
90+
scrollView.trailingAnchor.constraint(equalTo: content.trailingAnchor),
91+
scrollView.topAnchor.constraint(equalTo: content.topAnchor),
92+
scrollView.bottomAnchor.constraint(equalTo: content.bottomAnchor),
93+
documentView.leadingAnchor.constraint(equalTo: scrollView.contentView.leadingAnchor),
94+
documentView.trailingAnchor.constraint(equalTo: scrollView.contentView.trailingAnchor),
95+
documentView.topAnchor.constraint(equalTo: scrollView.contentView.topAnchor),
96+
documentView.widthAnchor.constraint(equalTo: scrollView.contentView.widthAnchor),
97+
stack.leadingAnchor.constraint(equalTo: documentView.leadingAnchor),
98+
stack.trailingAnchor.constraint(equalTo: documentView.trailingAnchor),
99+
stack.topAnchor.constraint(equalTo: documentView.topAnchor),
100+
stack.bottomAnchor.constraint(equalTo: documentView.bottomAnchor),
101+
])
102+
}
103+
104+
private func reload() throws {
105+
isReloading = true
106+
defer { isReloading = false }
107+
for view in stack.arrangedSubviews {
108+
stack.removeArrangedSubview(view)
109+
view.removeFromSuperview()
110+
}
111+
firstEditor = nil
112+
let snapshots = try settings.snapshots()
113+
if snapshots.isEmpty {
114+
stack.addArrangedSubview(wrappingLabel("No settings are registered.", bold: false))
115+
return
116+
}
117+
for snapshot in snapshots {
118+
let section = makeSection(snapshot)
119+
stack.addArrangedSubview(section)
120+
section.widthAnchor.constraint(equalTo: documentView.widthAnchor, constant: -44).isActive = true
121+
}
122+
window?.initialFirstResponder = firstEditor
123+
window?.recalculateKeyViewLoop()
124+
if window?.isKeyWindow == true, let firstEditor {
125+
window?.makeFirstResponder(firstEditor)
126+
}
127+
}
128+
129+
private func makeSection(_ snapshot: SettingSnapshot) -> NSView {
130+
let section = NSStackView()
131+
section.orientation = .vertical
132+
section.alignment = .leading
133+
section.spacing = 6
134+
section.translatesAutoresizingMaskIntoConstraints = false
135+
section.setHuggingPriority(.defaultLow, for: .horizontal)
136+
137+
let title = wrappingLabel(snapshot.definition.key, bold: true)
138+
title.setAccessibilityLabel(snapshot.definition.key)
139+
section.addArrangedSubview(title)
140+
141+
let summary = wrappingLabel(snapshot.definition.summary, bold: false)
142+
summary.textColor = .secondaryLabelColor
143+
summary.setAccessibilityLabel("Summary")
144+
section.addArrangedSubview(summary)
145+
146+
let editor = makeEditor(snapshot)
147+
if firstEditor == nil { firstEditor = editor }
148+
let reset = NSButton(
149+
title: "Reset to Default",
150+
target: self,
151+
action: #selector(resetClicked(_:))
152+
)
153+
reset.bezelStyle = .rounded
154+
reset.identifier = NSUserInterfaceItemIdentifier("reset.\(snapshot.definition.key)")
155+
reset.setAccessibilityLabel("Reset \(snapshot.definition.key) to default")
156+
reset.isEnabled = snapshot.configured && snapshot.supportedOnCurrentPlatform
157+
158+
let valueCaption = NSTextField(labelWithString: "Value")
159+
valueCaption.setAccessibilityHidden(true)
160+
let valueRow = NSStackView(views: [valueCaption, editor, reset])
161+
valueRow.orientation = .horizontal
162+
valueRow.alignment = .centerY
163+
valueRow.spacing = 8
164+
section.addArrangedSubview(valueRow)
165+
166+
section.addArrangedSubview(metaLabel("Default", snapshot.displayedDefaultValue))
167+
section.addArrangedSubview(metaLabel("Platform", snapshot.platformSummary))
168+
section.addArrangedSubview(metaLabel("Takes effect", snapshot.definition.restartBehavior.rawValue))
169+
section.addArrangedSubview(
170+
metaLabel("Agents may modify", snapshot.agentsMayModify ? "yes" : "no")
171+
)
172+
return section
173+
}
174+
175+
private func makeEditor(_ snapshot: SettingSnapshot) -> NSView {
176+
let key = snapshot.definition.key
177+
if let values = snapshot.selectableValues {
178+
let popup = NSPopUpButton(frame: .zero, pullsDown: false)
179+
popup.autoenablesItems = false
180+
popup.addItems(withTitles: values)
181+
popup.selectItem(withTitle: snapshot.value)
182+
popup.target = self
183+
popup.action = #selector(valueChanged(_:))
184+
popup.identifier = NSUserInterfaceItemIdentifier(key)
185+
popup.setAccessibilityLabel(key)
186+
popup.setAccessibilityHelp(snapshot.definition.summary)
187+
popup.isEnabled = snapshot.supportedOnCurrentPlatform
188+
return popup
189+
}
190+
191+
let field: NSTextField
192+
if snapshot.usesSecureTextEntry {
193+
let secureField = NSSecureTextField(string: snapshot.value)
194+
secureField.setAccessibilityValue("Hidden")
195+
secureField.placeholderString = "Hidden"
196+
field = secureField
197+
} else {
198+
field = NSTextField(string: snapshot.value)
199+
field.placeholderString = snapshot.definition.defaultValue
200+
}
201+
field.identifier = NSUserInterfaceItemIdentifier(key)
202+
field.delegate = self
203+
field.target = self
204+
field.action = #selector(textCommitted(_:))
205+
field.setAccessibilityLabel(key)
206+
field.setAccessibilityHelp(snapshot.definition.summary)
207+
field.isEditable = snapshot.supportedOnCurrentPlatform
208+
field.isSelectable = true
209+
field.translatesAutoresizingMaskIntoConstraints = false
210+
field.widthAnchor.constraint(greaterThanOrEqualToConstant: 160).isActive = true
211+
return field
212+
}
213+
214+
private func wrappingLabel(_ text: String, bold: Bool) -> NSTextField {
215+
let label = NSTextField(wrappingLabelWithString: text)
216+
label.font = bold ? .boldSystemFont(ofSize: 13) : .systemFont(ofSize: 12)
217+
label.preferredMaxLayoutWidth = 476
218+
label.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
219+
return label
220+
}
221+
222+
private func metaLabel(_ name: String, _ value: String) -> NSTextField {
223+
let label = NSTextField(labelWithString: "\(name): \(value)")
224+
label.font = .systemFont(ofSize: 11)
225+
label.textColor = .secondaryLabelColor
226+
label.setAccessibilityLabel(name)
227+
label.setAccessibilityValue(value)
228+
return label
229+
}
230+
231+
@objc private func valueChanged(_ sender: NSPopUpButton) {
232+
guard !isReloading, let key = sender.identifier?.rawValue else { return }
233+
commit(key, sender.titleOfSelectedItem ?? "")
234+
}
235+
236+
@objc private func textCommitted(_ sender: NSTextField) {
237+
guard !isReloading, let key = sender.identifier?.rawValue else { return }
238+
commit(key, sender.stringValue)
239+
}
240+
241+
func controlTextDidEndEditing(_ obj: Notification) {
242+
guard let field = obj.object as? NSTextField else { return }
243+
textCommitted(field)
244+
}
245+
246+
@objc private func resetClicked(_ sender: NSButton) {
247+
guard let raw = sender.identifier?.rawValue, raw.hasPrefix("reset.") else { return }
248+
let key = String(raw.dropFirst("reset.".count))
249+
do {
250+
_ = try settings.reset(key)
251+
reloadAfterCurrentEvent()
252+
} catch {
253+
present(error)
254+
reloadAfterCurrentEvent()
255+
}
256+
}
257+
258+
private func commit(_ key: String, _ rawValue: String) {
259+
do {
260+
let current = try settings.store.snapshot(key, caller: .user)
261+
guard current.value != rawValue || !current.configured else { return }
262+
_ = try settings.set(key, rawValue: rawValue)
263+
reloadAfterCurrentEvent()
264+
} catch {
265+
present(error)
266+
reloadAfterCurrentEvent()
267+
}
268+
}
269+
270+
private func reloadAfterCurrentEvent() {
271+
DispatchQueue.main.async { [weak self] in
272+
do {
273+
try self?.reload()
274+
} catch {
275+
self?.present(error)
276+
}
277+
}
278+
}
279+
280+
private func present(_ error: Error) {
281+
let alert = NSAlert()
282+
alert.alertStyle = .warning
283+
alert.messageText = "Couldn’t update settings"
284+
alert.informativeText = (error as? SettingsError)?.description ?? error.localizedDescription
285+
alert.addButton(withTitle: "OK")
286+
if let window {
287+
alert.beginSheetModal(for: window) { _ in }
288+
} else {
289+
alert.runModal()
290+
}
291+
}
292+
}

‎apps/headless/Package.swift‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,10 @@ let package = Package(
8484
"LinuxHost", "Dockerfile.linux", "Headless.app", "build-linux.sh", "install.sh", "install-linux.sh", "benchmark.sh", ".dockerignore",
8585
"MCP", "CredentialBroker", "CredentialBrokerCore", "SecurePrompt", "node_modules",
8686
],
87-
sources: ["main.swift", "Host/AgentBridge.swift", "Host/QADiagnosticsBridge.swift"],
87+
sources: [
88+
"main.swift", "Host/AgentBridge.swift", "Host/QADiagnosticsBridge.swift",
89+
"Host/SettingsWindow.swift",
90+
],
8891
linkerSettings: [
8992
.linkedFramework("Cocoa", .when(platforms: [.macOS])),
9093
.linkedFramework("WebKit", .when(platforms: [.macOS])),

‎apps/headless/Sources/HeadlessProtocol/CLI.swift‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -865,5 +865,6 @@ Global options:
865865
-- stop parsing global options; quote multi-word fill values
866866
867867
Settings:
868+
macOS Command-, opens Settings; `config` is the portable path on every platform.
868869
\(SettingsRegistry.shared.helpLines.joined(separator: "\n"))
869870
"""

‎apps/headless/Sources/HeadlessProtocol/MenuShortcuts.swift‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,9 +57,13 @@ public struct MenuShortcutSpec: Equatable, Sendable {
5757
}
5858

5959
public let headlessMenuShortcuts: [MenuShortcutSpec] = [
60+
.init(
61+
menu: "Headless", title: "Settings…", key: ",", selector: "showSettings:",
62+
target: .appDelegate
63+
),
6064
.init(
6165
menu: "Headless", title: "Hide Headless", key: "h", selector: "hide:",
62-
target: .application
66+
target: .application, separatorBefore: true
6367
),
6468
.init(
6569
menu: "Headless", title: "Hide Others", key: "h", option: true,

0 commit comments

Comments
 (0)