hraness
Theme
Appearance

a desktop app around a headless rust core

the retired menu bar was a shell too

by hraness · drafted with ai assistance

get new lessons by email. it’s free.

Most desktop applications are built UI-first: a window, a framework, then enough logic behind it to make the buttons work. The interesting test of a headless core is the opposite: can you put a real interface on top of a system that was designed with no interface at all, without the interface becoming a second place where policy lives?

In the Valhalla workspace Hraness maintains, the desktop story gave two different answers to that question. One shipped and has since been retired: vhalla-menubar, a macOS status-item companion that was deliberately too thin to have opinions. One is still being qualified: a shared Dioxus UI intended to run the same Rust on web and desktop. Both treat the interface as an adapter over authorities it does not own, and both had to earn the right to exist by proving they hold no authority themselves.

Valhalla removed the menu bar on 2026-09-29 (#217). Today vhalla tui shows the same state in a terminal, vhalla status --json gives it to scripts and agents, and the menu bar to command line map lists the command that replaced each menu action. The next section is history: it describes the menu bar as it stood at commit 8b7b351, and dcf1a9e is the last commit that had it.

the menu bar was a shell

At 8b7b351, desktop/menubar was 174 lines of main.rs. Its doc comment said what it was and, more important, what it was not: “a disposable client… Valhalla identities, stores, and rooms remain the authorities; this binary holds no privilege of its own and reads only the outputs directory.”

That was the whole contract. Agents dropped descriptively named files into an outputs directory (vhalla outputs creates it under the platform state directory); the menu bar listed them newest-first with thumbnails and open/reveal actions; a “Support” item opened a fixed account URL in the browser; a Quit item quit. The binary never opened an identity file, never read a store, never saw a key. It held one flock on menubar.lock so two instances didn’t double-register a status item, and that was the extent of its state.

Two details made it a shell rather than a program. It sat in desktop/, a separate Cargo workspace: desktop/AGENTS.md was explicit that Tauri’s build dependencies must never enter the protocol crates’ Linux or WASM checks. And it was built on desktop-foundation, a shared Hraness crate pinned by immutable tag (v0.7.0), so the adapter added no product state to the menu model. It implemented snapshot() and dispatch_result() and let the foundation own validation, cancellation, and refresh coalescing. The 174 lines were what was left when every decision belonged to something else. Later versions added room status on desktop-foundation v0.8.0 and grew main.rs to 446 lines, still with no identity or store access.

Even installation kept the shell thin. vhalla menubar install copied a release build into bin/ under the state directory and registered a per-user LaunchAgent (com.hraness.valhalla.menubar, RunAtLoad): no system daemon, no root, and uninstall booted the agent out and removed both files. The one outbound handoff (a “Support” menu item that opened a fixed account.hraness.com URL) passed no identity, email, session, or store data to the browser; the AGENTS.md required it. The binary also held a single-instance flock and exited quietly on a second launch, because a status item is a singleton and a second one is a bug, not a feature.

The thinness is what made retirement cheap. Every menu row was a projection of state the CLI could already read, so the replacement commands (vhalla status, vhalla tui, vhalla outputs, vhalla doctor) needed no new authority, and nothing now stays running in the background. Current releases answer vhalla menubar with a valhalla.retired error that names vhalla status; the old vhalla menubar refresh spelling still runs vhalla status refresh.

a replica, not a client

The heavier answer is vhalla-rooms-app. It is the renderer-agnostic service that a desktop or browser UI would actually talk to, and it is built as a read replica of a running node rather than a client of one.

The doc comment describes the split: a Service absorbs committed journal bundles out of the node’s home directory (immutable, content-addressed files safe to read while the node runs) into its own stores under replica_home, verifies every certificate against the trusted validator sets, and answers typed Screen projections. Submissions go the other direction: a *.body file dropped into the node’s intake/ directory, where the node assembles the batch against its own frontier. The service “never fabricates parent or result claims,” and signing stays outside entirely: callers pass canonical signed record bytes, and “no private key enters the service.”

That is the headless-core pattern at the application layer. The node’s journal is the authority on what committed. The replica derives a view of it. The UI, when it exists, reads projections of the replica. At no point does anything with a screen hold a key, assemble a batch, or decide what committed. The chain of custody is: consensus decides, the journal records, the replica projects, the interface renders.

The projection types are the interface contract: Screen, Projection, RoomRow, Pending, PendingState, Error are all platform-neutral on purpose, so “browser and native renderers share them.” A screen is a request for a named view of committed state; the service answers with data, not a promise about the node. And because the replica reads the node’s journal rather than trusting a live channel, it can verify every certificate it absorbs against the trusted validator set; the replica holds the node to the same standard a voter did, using verify_canonical_certificate from the portable cert module.

The PendingState enum is the same honesty applied to submissions. A dropped body resolves to Queued (still in the node’s intake), Submitted (in flight toward a decision), Committed (the registry cites the record), Collision (a different record claimed the slug first), or Rejected (intake refused the drop). There is no “success”: the UI watches the committed record arrive through the journal, and a collision is a first-class outcome, not an exception. The submission is a marker; the resolution is read back from authority.

the renderer is interchangeable

vhalla-rooms-tui shows what that buys. The terminal UI is built Elm-style: App is a pure model, key events fold through App::key, and a view module renders the model. The model talks to a Source trait (sync(), project(screen), pending(), submit(...)) which mirrors the vhalla-rooms-app service boundary exactly. The doc comment notes this was deliberate: “the same contract the Dioxus spike defined, so renderers stay interchangeable.” Terminal setup and identity custody are cfg(unix); signing happens “in the CLI’s own trust domain, never inside the service.”

Because the model is pure, the TUI is fully testable without a terminal: feed KeyEvents through App::key, assert on the model. Because Source is a trait, the same model runs over a real replica or a fixture. And because the projections come from vhalla-rooms-app, the terminal shows exactly what a future Dioxus app would show. The UI changed, the evidence did not.

what the shell cannot do

The design plan’s Dioxus section is unusually explicit about the boundaries a UI must respect, and reading it is a reminder that “the UI is a shell” is a security statement, not an aesthetic one. The proposed ownership table puts the pure vhalla-* crates on one side (no Dioxus, DOM, filesystem, or platform-clock dependency) and layered shells on the other: a vhalla-client-api of inert presentation data, a vhalla-client with injected storage/transport/clock/identity interfaces, a vhalla-ui of Dioxus components, and separate vhalla-web/vhalla-desktop launchers.

The hard rules: untrusted message text renders as text; remote content cannot supply component code, raw HTML, scripts, or IPC method names; view code emits bounded typed intentions and “never mints execution capabilities”; keys and private reader data never go in route parameters. And the caveat that keeps it honest: “Compiler boundaries prevent UI code from accidentally importing signing or generic effect APIs; they do not isolate a compromised native renderer in the same process.” A WebView is a dependency to be threat-modeled, not a sandbox.

the unfinished parts

The honesty matters because the desktop story is genuinely partial. The menu bar shipped, did what it said, and was retired once the command line covered every action. The Dioxus work is an accepted framework decision with shared prototypes (the plan’s own words), not a delivered application. The spikes found real problems (a stock WebView loader that opens external links before the navigation handler runs, injected interpreter scripts, no default CSP) and the qualification gates in the plan (shared screens, platform services, renderer boundary, packaging, integration) are tracked as evidence, several still in progress. The pure crates that a UI would sit on are tested and compile to wasm32-unknown-unknown; the shared UI itself is still being proven on both renderers.

That is the shape worth copying: the interface is allowed to be unfinished because the authorities it fronts are not. The menu bar could be 174 lines and disposable because everything it displayed was owned by something that was not, and it could be deleted for the same reason.

sources