diff --git a/CHANGELOG.md b/CHANGELOG.md
index 8bb54ce..39c8530 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -13,6 +13,14 @@ independently per platform packaging run.
they return with the next release, alongside the AppImage update
payload in the signed feed.
+### Fixed
+
+- **Usable, private native settings.** macOS now keeps every settings page
+ visible in a fixed sidebar and records the summon shortcut instead of
+ exposing the backend grammar. All three settings hosts hide internal
+ updater transport fields, and rejected values no longer leak Racket
+ contract errors into the UI.
+
## [0.13.3] - 2026-10-22
### Added
diff --git a/app/backend.rkt b/app/backend.rkt
index 191d094..ce439ab 100644
--- a/app/backend.rkt
+++ b/app/backend.rkt
@@ -1358,8 +1358,16 @@
[else (raise-argument-error 'settings-set "boolean string" value)])]
[else value]))
+(define private-setting-keys
+ '(update-base-url update-last-check update-rollout-bucket))
+
(define-rpc (settings-list : (List (List String)))
- (for/list ([entry (in-list (core:settings-list (settings!)))])
+ ;; Update transport and rollout bookkeeping are product internals, not
+ ;; user preferences. Keep them out of the public settings surface so no
+ ;; native host can accidentally render or edit them.
+ (for/list ([entry (in-list (core:settings-list (settings!)))]
+ #:unless (member (string->symbol (settings-entry-key entry))
+ private-setting-keys))
(list (settings-entry-key entry)
(settings-entry-value entry)
(settings-entry-description entry)
@@ -1370,14 +1378,18 @@
"")))
(define-rpc (settings-set [key String] [value String] : Bool)
- (define sym (string->symbol key))
- (settings-set! (settings!) sym (coerce-setting-value sym value))
- ;; Reflect the host-rendered keys immediately across every open surface.
- (case sym
- [(theme) (state-set! theme (settings-get (settings!) 'theme))]
- [(hotkey) (state-set! hotkey (settings-get (settings!) 'hotkey))]
- [(language) (state-set! language (settings-get (settings!) 'language))])
- #t)
+ ;; A settings form is an untrusted user-input boundary. Schema rejection
+ ;; is a normal false result, not an internal Racket contract failure that
+ ;; should leak through RVT1 into a native error label.
+ (with-handlers ([exn:fail:contract? (lambda (_) #f)])
+ (define sym (string->symbol key))
+ (settings-set! (settings!) sym (coerce-setting-value sym value))
+ ;; Reflect the host-rendered keys immediately across every open surface.
+ (case sym
+ [(theme) (state-set! theme (settings-get (settings!) 'theme))]
+ [(hotkey) (state-set! hotkey (settings-get (settings!) 'hotkey))]
+ [(language) (state-set! language (settings-get (settings!) 'language))])
+ #t))
;; Readiness of the configured AI provider, for the settings window's AI
;; section: "ready", or the honest reason it is not (no provider, no key).
diff --git a/linux/src/main.cpp b/linux/src/main.cpp
index ad22f9c..43e05c6 100644
--- a/linux/src/main.cpp
+++ b/linux/src/main.cpp
@@ -2038,7 +2038,11 @@ void settings_write(std::string const& key, std::string const& value) {
std::thread([api_raw, key, value]() {
std::string error;
try {
- api_raw->settings_set(key, value).get();
+ if (!api_raw->settings_set(key, value).get()) {
+ error = key == "hotkey"
+ ? "that shortcut is not supported"
+ : "that value is not supported";
+ }
} catch (std::exception const& e) {
error = e.what();
} catch (...) {
@@ -2357,7 +2361,9 @@ void settings_build() {
settings_hint(sync,
"A folder a file sync service replicates (Syncthing, Nextcloud, "
"Dropbox); settings and snippets mirror there.");
- settings_add_text(sync, "Update feed base URL", "update-base-url", "https://…");
+ settings_hint(sync,
+ "Fulcrum checks signed releases automatically. Use Check for "
+ "Updates from the tray menu to check now.");
auto* error = gtk_label_new("");
gtk_label_set_xalign(GTK_LABEL(error), 0.0);
diff --git a/macos-host/Sources/RivetHost/RivetHostApp.swift b/macos-host/Sources/RivetHost/RivetHostApp.swift
index fca095f..4d12350 100644
--- a/macos-host/Sources/RivetHost/RivetHostApp.swift
+++ b/macos-host/Sources/RivetHost/RivetHostApp.swift
@@ -360,7 +360,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
}
private static func makeSettingsWindow(model: SettingsModel) -> NSWindow {
- let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 560, height: 470),
+ let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 720, height: 520),
styleMask: [.titled, .closable, .miniaturizable],
backing: .buffered, defer: false)
window.title = "Fulcrum Settings"
diff --git a/macos-host/Sources/RivetHost/SettingsView.swift b/macos-host/Sources/RivetHost/SettingsView.swift
index d206f9c..25a0dab 100644
--- a/macos-host/Sources/RivetHost/SettingsView.swift
+++ b/macos-host/Sources/RivetHost/SettingsView.swift
@@ -53,14 +53,21 @@ final class SettingsModel: ObservableObject {
values[key] = value
Task {
do {
- _ = try await api.settingsSet(key: key, value: value)
- error = nil
- onChanged(key, value)
- if key == "ai-provider" {
- await refreshAiStatus()
+ let accepted = try await api.settingsSet(key: key, value: value)
+ if accepted {
+ error = nil
+ onChanged(key, value)
+ if key == "ai-provider" {
+ await refreshAiStatus()
+ }
+ } else {
+ self.error = key == "hotkey"
+ ? "That shortcut is not supported. Record another key combination."
+ : "\(key): that value is not supported."
+ await load()
}
} catch {
- self.error = "\(key): \(error)"
+ self.error = "\(key): the setting could not be saved."
await load()
}
}
@@ -255,6 +262,11 @@ final class HotkeyRecorder {
}
}
+ func cancel() {
+ stop()
+ finish(nil)
+ }
+
private func stop() {
if let monitor { NSEvent.removeMonitor(monitor) }
monitor = nil
@@ -270,8 +282,71 @@ final class HotkeyRecorder {
}
}
+/// Global summon shortcut editor. Recording produces only canonical values
+/// accepted by the shared backend grammar, so macOS users never need to know
+/// or type the transport spelling (for example, `alt+space`).
+private struct GlobalHotkeySetting: View {
+ let title: String
+ @ObservedObject var model: SettingsModel
+ @State private var recorder: HotkeyRecorder?
+ @State private var isRecording = false
+
+ var body: some View {
+ LabeledContent(title) {
+ if isRecording {
+ Button("Press keys… (Esc cancels)") {}
+ .disabled(true)
+ } else {
+ Button(Hotkey.display(model.values["hotkey"] ?? "alt+space")) {
+ isRecording = true
+ recorder = HotkeyRecorder { spec in
+ isRecording = false
+ if let spec, !spec.isEmpty {
+ model.set("hotkey", spec)
+ }
+ recorder = nil
+ }
+ recorder?.start()
+ }
+ .help("Record a new global shortcut")
+ .accessibilityIdentifier("settings-record-hotkey")
+ }
+ }
+ .onDisappear {
+ if isRecording { recorder?.cancel() }
+ recorder = nil
+ isRecording = false
+ }
+ }
+}
+
+private enum SettingsSection: String, CaseIterable, Identifiable {
+ case general, features, ai, commands, sync
+
+ var id: Self { self }
+ var title: String {
+ switch self {
+ case .general: return "General"
+ case .features: return "Features"
+ case .ai: return "AI"
+ case .commands: return "Commands"
+ case .sync: return "Sync & Updates"
+ }
+ }
+ var icon: String {
+ switch self {
+ case .general: return "gearshape"
+ case .features: return "square.grid.2x2"
+ case .ai: return "sparkles"
+ case .commands: return "keyboard"
+ case .sync: return "arrow.triangle.2.circlepath"
+ }
+ }
+}
+
struct SettingsView: View {
@ObservedObject var model: SettingsModel
+ @State private var selection: SettingsSection = .general
// Translation reads the observable backend setting, so switching language
// immediately re-renders the visible settings surface.
@@ -281,19 +356,38 @@ struct SettingsView: View {
var body: some View {
VStack(spacing: 0) {
- TabView {
- generalTab
- .tabItem { Label("General", systemImage: "gearshape") }
- featuresTab
- .tabItem { Label("Features", systemImage: "square.grid.2x2") }
- aiTab
- .tabItem { Label("AI", systemImage: "sparkles") }
- commandsTab
- .tabItem { Label("Commands", systemImage: "keyboard") }
- syncTab
- .tabItem { Label("Sync & Updates", systemImage: "arrow.triangle.2.circlepath") }
+ HStack(spacing: 0) {
+ List(SettingsSection.allCases) { section in
+ Button {
+ selection = section
+ } label: {
+ Label(section.title, systemImage: section.icon)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .contentShape(Rectangle())
+ }
+ .buttonStyle(.plain)
+ .accessibilityIdentifier("settings-sidebar-\(section.rawValue)")
+ .listRowBackground(
+ selection == section
+ ? Color.accentColor.opacity(0.16)
+ : Color.clear)
+ }
+ .listStyle(.sidebar)
+ .frame(width: 170)
+
+ Divider()
+
+ Group {
+ switch selection {
+ case .general: generalTab
+ case .features: featuresTab
+ case .ai: aiTab
+ case .commands: commandsTab
+ case .sync: syncTab
+ }
+ }
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
}
- .padding(.top, 10)
if let error = model.error {
Text(error)
.font(.system(size: 11))
@@ -304,7 +398,7 @@ struct SettingsView: View {
.padding(.bottom, 10)
}
}
- .frame(width: 560, height: 470)
+ .frame(width: 720, height: 520)
.task { await model.load() }
}
@@ -322,9 +416,8 @@ struct SettingsView: View {
}
}
Section {
- SettingField(title: t("setting.hotkey"), key: "hotkey",
- placeholder: "alt+space", model: model)
- Text("Modifiers alt / ctrl / shift / cmd plus a key: space, a–z, 0–9, f1–f12, comma, period, minus, equal, tab. Applies immediately; a taken combo falls back to ⌃⌥Space.")
+ GlobalHotkeySetting(title: t("setting.hotkey"), model: model)
+ Text("Select the current shortcut, then press the new modifier and key combination. Applies immediately; a taken combo falls back to ⌃⌥Space.")
.font(.system(size: 11))
.foregroundStyle(.secondary)
Stepper("\(t("setting.max-results")): \(model.number("max-results", default: 12).wrappedValue)",
@@ -441,8 +534,9 @@ struct SettingsView: View {
.foregroundStyle(.secondary)
}
Section("Updates") {
- SettingField(title: "Feed base URL", key: "update-base-url",
- placeholder: "https://…", model: model)
+ Text("Fulcrum checks signed releases automatically. You can also choose Check for Updates from the app menu.")
+ .font(.system(size: 11))
+ .foregroundStyle(.secondary)
}
}
.formStyle(.grouped)
diff --git a/scripts/e2e/macos-ax.swift b/scripts/e2e/macos-ax.swift
index 8478a6f..5697547 100644
--- a/scripts/e2e/macos-ax.swift
+++ b/scripts/e2e/macos-ax.swift
@@ -79,6 +79,17 @@ func pressReturn(pid: pid_t) throws {
down.postToPid(pid)
up.postToPid(pid)
}
+func pressCommandComma(pid: pid_t) throws {
+ guard let source = CGEventSource(stateID: .hidSystemState),
+ let down = CGEvent(keyboardEventSource: source, virtualKey: 43, keyDown: true),
+ let up = CGEvent(keyboardEventSource: source, virtualKey: 43, keyDown: false) else {
+ throw DriverError.failure("could not create Command-comma key event")
+ }
+ down.flags = .maskCommand
+ up.flags = .maskCommand
+ down.postToPid(pid)
+ up.postToPid(pid)
+}
func snapshot(_ element: AXUIElement, depth: Int = 0) -> [String: Any] {
var result: [String: Any] = ["role": stringAttribute(element, kAXRoleAttribute), "identifier": stringAttribute(element, kAXIdentifierAttribute), "title": stringAttribute(element, kAXTitleAttribute), "label": stringAttribute(element, kAXDescriptionAttribute), "value": stringAttribute(element, kAXValueAttribute)]
if depth < 8 { result["children"] = children(element).map { snapshot($0, depth: depth + 1) } }
@@ -121,7 +132,40 @@ do {
_ = try waitFor("calculator action clipboard event") {
NSPasteboard.general.string(forType: .string) == "144" ? true : nil
}
- try writeJSON(["platform": "macos", "actions": ["set:FulcrumQuery=12*12", "press:Return"], "assertions": ["packaged SwiftUI window activated", "FulcrumQuery exposed through Accessibility", "query crossed RVT1 into the embedded backend", "native result list contains 144", "run-action emitted 144 to the native clipboard"]], to: output.appendingPathComponent("interaction-trace.json"))
+
+ // Settings is a separate AppKit window. Exercise the real macOS Settings
+ // command after the launcher has hidden, then prove every destination is
+ // directly discoverable without opening a toolbar overflow menu.
+ try pressCommandComma(pid: pid)
+ let settingsWindow: AXUIElement = try waitFor("Fulcrum Settings window") {
+ (attribute(application, kAXWindowsAttribute) as? [AXUIElement])?.first {
+ stringAttribute($0, kAXTitleAttribute) == "Fulcrum Settings"
+ }
+ }
+ for identifier in ["settings-sidebar-general", "settings-sidebar-features",
+ "settings-sidebar-ai", "settings-sidebar-commands",
+ "settings-sidebar-sync"] {
+ guard findByIdentifier(settingsWindow, identifier) != nil else {
+ throw DriverError.failure("settings sidebar is missing \(identifier)")
+ }
+ }
+ guard findByIdentifier(settingsWindow, "settings-record-hotkey") != nil else {
+ throw DriverError.failure("settings hotkey recorder is unavailable")
+ }
+ guard !contains(settingsWindow, fragment: "Feed base URL"),
+ !contains(settingsWindow, fragment: "update-base-url"),
+ !contains(settingsWindow, fragment: "releases/latest/download") else {
+ throw DriverError.failure("settings exposes the internal update feed")
+ }
+ guard let syncRow = findByIdentifier(settingsWindow, "settings-sidebar-sync"),
+ AXUIElementPerformAction(syncRow, kAXPressAction as CFString) == .success else {
+ throw DriverError.failure("could not select Sync & Updates directly")
+ }
+ _ = try waitFor("Sync & Updates page") {
+ contains(settingsWindow, fragment: "Fulcrum checks signed releases automatically") ? true : nil
+ }
+ try writeJSON(snapshot(application), to: output.appendingPathComponent("settings-accessibility.json"))
+ try writeJSON(["platform": "macos", "actions": ["set:FulcrumQuery=12*12", "press:Return", "press:CommandComma", "press:settings-sidebar-sync"], "assertions": ["packaged SwiftUI window activated", "FulcrumQuery exposed through Accessibility", "query crossed RVT1 into the embedded backend", "native result list contains 144", "run-action emitted 144 to the native clipboard", "all five settings destinations are directly accessible", "global hotkey uses a recorder control", "internal update feed is absent", "Sync & Updates opens without a toolbar overflow menu"]], to: output.appendingPathComponent("interaction-trace.json"))
guard let running = NSRunningApplication(processIdentifier: pid), running.terminate() else { throw DriverError.failure("native application rejected termination") }
} catch {
if let application = diagnosticApplication, let output = diagnosticOutput { try? writeJSON(snapshot(application), to: output.appendingPathComponent("accessibility-failure.json")) }
diff --git a/tests/backend-wire-test.rkt b/tests/backend-wire-test.rkt
index 95d2b2e..e412ab1 100644
--- a/tests/backend-wire-test.rkt
+++ b/tests/backend-wire-test.rkt
@@ -328,10 +328,24 @@
(check! "settings-list no error (2)" sl2e #f)
(check! "settings-list booleans read on/off"
(setting-value sl2 "expansion-enabled") "on")
+(check! "settings-list hides updater transport internals"
+ (for/or ([key (in-list '("update-base-url"
+ "update-last-check"
+ "update-rollout-bucket"))])
+ (setting-value sl2 key))
+ #f)
(define-values (ssv sse _ssev)
(call-rpc 51 (list "settings-set" "expansion-enabled" "off")))
(check! "settings-set accepts off" sse #f)
(check! "settings-set returns true" ssv #t)
+(define-values (bad-hotkey bad-hotkey-error _bad-hotkey-events)
+ (call-rpc 65 (list "settings-set" "hotkey" "Option-Space")))
+(check! "invalid hotkey is a normal rejected write"
+ (list bad-hotkey bad-hotkey-error) '(#f #f))
+(define-values (after-bad-hotkey _after-bad-hotkey-error _after-bad-hotkey-events)
+ (call-rpc 66 (list "settings-list")))
+(check! "rejected hotkey leaves state unchanged"
+ (setting-value after-bad-hotkey "hotkey") "alt+space")
(define-values (sl3 _sl3e _sl3ev) (call-rpc 52 (list "settings-list")))
(check! "settings-set off round-trips through settings-list"
(setting-value sl3 "expansion-enabled") "off")
diff --git a/windows/SettingsWindow.xaml b/windows/SettingsWindow.xaml
index 721874e..aae350d 100644
--- a/windows/SettingsWindow.xaml
+++ b/windows/SettingsWindow.xaml
@@ -253,16 +253,8 @@
-
-
-
-
-
-
-
-
+
diff --git a/windows/SettingsWindow.xaml.cpp b/windows/SettingsWindow.xaml.cpp
index 78e19a8..6cb1b82 100644
--- a/windows/SettingsWindow.xaml.cpp
+++ b/windows/SettingsWindow.xaml.cpp
@@ -178,7 +178,6 @@ void SettingsWindow::Apply(std::map values,
BaseUrlBox().Text(to_wide(get("ai-base-url")));
PersonaBox().Text(to_wide(get("ai-persona")));
SyncRootBox().Text(to_wide(get("sync-root")));
- UpdateUrlBox().Text(to_wide(get("update-base-url")));
AiStatusText().Text(
to_wide(ai_status == "ready" ? std::string("Ready") : ai_status));
loading_ = false;
@@ -196,7 +195,11 @@ void SettingsWindow::Write(std::string key, std::string value) {
std::thread([weak, api, dispatcher, key, value]() mutable {
std::string error;
try {
- api->settings_set(key, value).get();
+ if (!api->settings_set(key, value).get()) {
+ error = key == "hotkey"
+ ? "that shortcut is not supported"
+ : "that value is not supported";
+ }
} catch (std::exception const& e) {
error = e.what();
} catch (...) {