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 (...) {