Skip to content

Settings and the UI

Updated View as Markdown

Ambient’s settings live in one JSON file, reachable from a CLI verb and from a bundled web page rendered inside the app’s window. This chapter covers where that file lives, how it is resolved, how the page is built, and why five separate binaries exist purely to check that the assembled app is what it claims to be.

Every setting has code behind it, and nothing is stored that nothing reads. The full key-by-key list is in the config reference.

They live in ~/Library/Application Support/Ambient/config.json — deliberately not under the sessions folder, since that folder is itself a setting and a config inside the thing it configures cannot be found before it is read. Precedence runs environment → CLI flag → config file → default, so AMBIENT_HOME and --app still win and every existing harness is unaffected. A missing file is an ordinary first run; a corrupt one warns and falls back rather than failing a recording.

ambient config prints the resolved settings and the input devices it can see; ambient config <key> <value> sets one. That exists so every setting is verifiable without a GUI.

A named input device that has gone away warns and names the alternatives before falling back. Silent fallback is this project’s recurring failure and the one thing a settings layer must not reintroduce.

The window

src/window.rs creates one NSWindow whose content view is the WKWebView owned by SettingsPane. React renders Sessions, Settings and Welcome inside that page. AppKit retains the menu bar, activation policy and native file panels; the bridge forwards recording commands to the app delegate. See the UI implementation for routes, components and screenshots.

The page is embedded with include_str!: launched through LaunchServices the working directory is /, and that is the only launch with the audio-capture grant, so a relative path would break the one path that matters.

The bridge carries a JSON string each way. WKScriptMessage::body otherwise arrives as an NSDictionary that Rust would unpick a value at a time; a string goes straight to serde. The page never edits its own state — it sends a patch, Rust merges it onto the file and pushes the whole config back, so the file is the single source of truth rather than the DOM.

Building the page — ui/

React 19, TypeScript, shadcn/ui and Tailwind v4, bundled by Vite through vite-plugin-singlefile into one self-contained assets/settings.html; a WKWebView loaded via loadHTMLString has no origin to fetch siblings from, so nothing may stay external. Linting and formatting are oxlint and oxfmt.

cd ui && pnpm install
pnpm run build     # → ../assets/settings.html
pnpm run check     # tsc --noEmit
pnpm run lint      # oxlint

The built file is committed, so cargo build never needs node. make-app.sh refreshes it only when ui/node_modules is present.

The request/response bridge is typed in TypeScript and exercised with a FakeBridge in component tests. Rust dispatches shared methods through api::call; clipboard, native panels and recording actions remain bridge-only.

cargo run --bin symbolcheck asks macOS whether each menu bar icon actually resolves. imageWithSystemSymbolName returning nothing leaves the previous icon in place, so a symbol this OS lacks would show “recording” while merely armed — silent, and only catchable by asking. It runs in CI, and it caught waveform.badge.questionmark not existing.

cargo run --release --bin uicheck -- out.png drives the built page in a real WKWebView, pushes a config in, synthesises every click and prints what reaches the bridge. Compiling and type-checking prove the page says the right thing; only this proves it does anything.

Why there are five checking binaries

Each of the five asserts something a compiler cannot: that what the running OS resolves, or what the running app actually does, is what the code assumed. They differ in which artefact.

src/bin/symbolcheck.rs asks NSImage::imageWithSystemSymbolName for every menu bar symbol and exits non-zero if any is missing, printing “{missing} symbol(s) will silently leave the wrong icon showing”. The silent failure it exists to catch is named in its own header: “set_state only sets an image when imageWithSystemSymbolName returns one, so a symbol this OS does not have leaves the previous icon in place — the menu bar would then say ‘recording’ while armed, silently. That is this project’s recurring failure mode, so it gets a check rather than a hope.” It caught waveform.badge.questionmark not existing on this OS.

It walks PhaseKind::ALL rather than a list of its own, because a list here is a second source of truth and it had already drifted: adding the Failed phase gave the menu a fifth symbol, and this check went on validating four — the silent wrong-icon failure it exists to catch, in the check itself.

src/bin/iconcheck.rs takes a built .app and an output path, asks NSWorkspace::iconForFile what icon macOS resolves for the bundle, prints the size and every representation, and writes the result out as a PNG. Its header states the gap precisely: “The plist key and the file being present prove neither that the .icns parses nor that LaunchServices picks it up.” A wrong or unparsed icon is not an error anywhere — you simply get the generic one.

src/bin/uicheck.rs loads the built page into a real WKWebView with canned API replies. It selects a session, copies two export formats, injects a recording phase, reads back the live card and Settings controls, and changes a setting. It then scrolls 2,000 sessions, checks the rendered row count and elapsed work, and saves a light or dark PNG. Missing responses or a failed assertion produce a nonzero exit code.

src/bin/policycheck.rs drives the activation-policy flip and prints what the app becomes. None of it is visible to inspection: whether the process has a Dock icon is a property of the running process, and both guards on the demotion are about windows that only exist at run time — NSApp.windows() carries AppKit’s own furniture, the status item’s bar window and the carrier behind an open menu, and a guard counting those would mean the app never demoted at all. It opens the real MainWindow, closes it the way ⌘W does so windowWillClose: really runs, and asserts the seven states in its header: launch is Accessory, showing promotes, closing demotes, a modal session refuses the demotion, another visible window refuses it too, the refresh tick’s retry demotes once that window goes away, and minimising is not closing.

src/bin/windowcheck.rs opens the real MainWindow with disposable sessions and the real dispatcher. It asserts that the content view is a WKWebView, loads a transcript, injects capture-worker meter values through render, verifies selection survives the phase change, clicks Stop and checks that it reaches the app delegate, then opens Settings. It opens no audio device.

windowcheck creates its own scratch sessions folder. Give policycheck one with AMBIENT_HOME=<scratch> cargo run --bin policycheck.

Two worked examples of the class of bug that only a running check finds.

The menu bar’s refresh timer polls twice a second, and the first version scheduled it the obvious way. src/menubar.rs now registers it in the common run loop modes instead, with the reason in the code: “Added in the common modes rather than scheduled: scheduledTimer… registers only for NSDefaultRunLoopMode, and AppKit runs the loop in event-tracking mode for as long as a menu is open. The elapsed time therefore froze exactly while you were looking at it, and only moved when the menu was closed and reopened.” Nothing errors; the number is simply stale in the one moment anyone reads it.

The settings capture-mode selector keeps the user’s current choice separate from config.apps: “Selected apps, none picked yet” and “Everything” both have an empty list. Deriving the selector from that list would hide Add before the user could pick their first app. Component tests exercise that transition.

See ADR-0012.

Navigation

Type to search…

↑↓ navigate↵ selectEsc close