Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
30 changes: 21 additions & 9 deletions app/backend.rkt
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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).
Expand Down
10 changes: 8 additions & 2 deletions linux/src/main.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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 (...) {
Expand Down Expand Up @@ -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);
Expand Down
2 changes: 1 addition & 1 deletion macos-host/Sources/RivetHost/RivetHostApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
142 changes: 118 additions & 24 deletions macos-host/Sources/RivetHost/SettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
}
Expand Down Expand Up @@ -255,6 +262,11 @@ final class HotkeyRecorder {
}
}

func cancel() {
stop()
finish(nil)
}

private func stop() {
if let monitor { NSEvent.removeMonitor(monitor) }
monitor = nil
Expand All @@ -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.
Expand All @@ -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))
Expand All @@ -304,7 +398,7 @@ struct SettingsView: View {
.padding(.bottom, 10)
}
}
.frame(width: 560, height: 470)
.frame(width: 720, height: 520)
.task { await model.load() }
}

Expand All @@ -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)",
Expand Down Expand Up @@ -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)
Expand Down
46 changes: 45 additions & 1 deletion scripts/e2e/macos-ax.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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) } }
Expand Down Expand Up @@ -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")) }
Expand Down
14 changes: 14 additions & 0 deletions tests/backend-wire-test.rkt
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
12 changes: 2 additions & 10 deletions windows/SettingsWindow.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -253,16 +253,8 @@
</Grid>
<TextBlock Style="{StaticResource Hint}"
Text="A folder a file sync service replicates (OneDrive, Dropbox, Syncthing); settings and snippets mirror there." />
<Grid Style="{StaticResource Row}">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<TextBlock Text="Update feed base URL" Style="{StaticResource RowLabel}" />
<TextBox x:Name="UpdateUrlBox" Grid.Column="1" Tag="update-base-url" MinWidth="280"
VerticalAlignment="Center" PlaceholderText="https://…"
LostFocus="Text_LostFocus" KeyDown="Text_KeyDown" />
</Grid>
<TextBlock Style="{StaticResource Hint}"
Text="Fulcrum checks signed releases automatically. Use Check for Updates from the tray menu to check now." />
</StackPanel>
</Border>

Expand Down
Loading
Loading