Skip to content

Ship CodeTrial as a desktop app - #349

Open
jserv wants to merge 8 commits into
mainfrom
desktop
Open

jserv wants to merge 8 commits into
mainfrom
desktop

Conversation

@jserv

@jserv jserv commented Oct 18, 2026 •

Copy link
Copy Markdown
Contributor

CodeTrial now ships as a desktop app for macOS, Windows and Linux. The app is a Tauri 2 shell in desktop/ that runs the same codetrial web binary as a child process and shows it in one window on a fixed 127.0.0.1:3101, so no browser has to be opened by hand. Running the server as a child rather than linking it in keeps src/main.rs close to unchanged: the server gains three launcher options instead (--allowed-hosts, --data-dir, CODETRIAL_EXIT_WITH_STDIN), each an ordinary CLI option a non-desktop install can use too. The LiveKit and Google keys are asked for once, and come from whichever of three places has them. Exported LIVEKIT_URL, LIVEKIT_API_KEY and LIVEKIT_API_SECRET (and optionally GOOGLE_API_KEY or GOOGLE_API_KEYS) are written to the app's own config, checked as Setup checks them, and Setup is skipped. Otherwise the Setup page asks, and offers two shortcuts. When the app was started near a CLI config (config/codetrial.env.local or codetrial.env.local in the start folder or above, so a run from a checkout's desktop/ finds its config/), the server's new --import-config names that file on the page with the scheme and host of its LiveKit URL, and "Use these keys" has the server read it and save its Setup keys, checked as typed ones are. The file is never used unasked, because one found near the start folder could be any project's, and no key, no other part of its URL and no line it could not parse reaches the page, because any account on the machine can reach the port while Setup serves. And the "Import from a config file" button fills the form from a file the user picks, read in the page and checked on save like typed keys. Every later launch reads the saved config. docs/desktop.md has the design, the file locations and the qualification list.

The page gets no native access at all; navigation, camera and microphone permission, downloads and the close check are all decided from outside the page. Where WebKitGTK is built without WebRTC (Ubuntu 24.04, Debian 12 and Arch as packaged), the app opens the interview in the default browser and keeps the server running. The app's own starting page uses the visual system the interview pages now share: the lobby's palette on a light system and the warm dark one otherwise, the violet accent, and the same radii and type steps.

The app window with the interview in the browser, light and dark

The first-run Setup page in the browser, when no keys are exported, light and dark

Setup offering a CLI config found near where the app started, light and dark

The app window when port 3101 is already taken, light and dark

Rebased onto main at 65cee24. Verified with scripts/test.sh at the tip: 1947 Rust tests pass, and the gate fails only on skill-refs, for an untracked local skill folder on this host, and on two camera-preview browser tests from #448 that time out on this host on main as well and pass in CI. The desktop lane skipped here for want of WebKitGTK; in a Debian 12 container with WebKitGTK 4.1, desktop fmt, clippy with -D warnings and the desktop unit tests pass, and scripts/desktop-smoke.sh passes against a release build under xvfb (Setup answers, a foreign Host is refused, and SIGKILL of the app takes the server with it). Every commit builds and passes clippy on its own. The desktop gate runs in the rust lane on every run, since under CI a skipped gate fails the run, and the CI dependency audit reads desktop/Cargo.lock beside the root lockfile. The screenshots come from that container at the app's 1280x860 window, light and dark from the system scheme. The Setup screenshot shows the import button; the page is the server's, so the CLI gets it too. Checked end to end in that container: the app started from a checkout's desktop/ served Setup offering ../config/codetrial.env.local with its LiveKit host, wrote nothing, and sent no key to the page.

Left out: GitHub sign-in and recording inside the window, signed installers, and automatic updates. An interview has not yet been run on real hardware in WKWebView, WebView2 or a WebRTC-enabled WebKitGTK; the checklist in docs/desktop.md is what a release needs before it is called more than a preview.

Closes #289


Summary by cubic

CodeTrial now ships as a desktop app for macOS, Windows, and Linux. It starts the existing server in a child process and shows it in one window, so users don't need to launch a terminal or open a browser themselves.

Server and security

  • Adds --data-dir (a launch's config, database, and session key all live in one folder; without a SESSION_SECRET there the server generates its own key and reads no configuration from the environment, except the LiveKit and Google keys it writes to the config on a cold start, skipping Setup), --allowed-hosts (serves only the Host names listed; empty answers any, so a proxy deployment is unaffected), and CODETRIAL_EXIT_WITH_STDIN (the server exits when its launcher's pipe closes, freeing its fixed port even if the app crashes).
  • A cold start also imports keys from a CLI config via --import-config, or from the Setup page: its new import button reads a chosen file in the browser and fills the form, and a launched app offers the nearest CLI config—the page names the file and its LiveKit URL, read only when you tell it to.
  • The app limits navigation and permissions; on WebKitGTK builds without WebRTC, interviews open in the default browser while the server stays running.
  • Bounds CLI test runs so a server that hangs fails the test instead of timing out.

Build and releases

  • Adds cross-platform installer builds and smoke tests; desktop prerelease tags publish separately from the CLI's rolling release.
  • CI builds and tests the desktop crate when its files or the server launch contract change.

Written for commit 543f260. Summary will update on new commits.

View guided diff Turn on auto-fix

cubic-dev-ai[bot]

This comment was marked as resolved.

cubic-dev-ai[bot]

This comment was marked as resolved.

@jserv
jserv force-pushed the desktop branch 2 times, most recently from 8ad4c22 to a158ef6 Compare October 18, 2026 14:19
cubic-dev-ai[bot]

This comment was marked as resolved.

@jserv

This comment has been minimized.

1 similar comment
@jserv

jserv commented Oct 20, 2026

Copy link
Copy Markdown
Contributor Author

Rebase latest main branch and resolve conflicts

@jserv
jserv force-pushed the desktop branch 5 times, most recently from 5937688 to eb25365 Compare October 24, 2026 00:12
cubic-dev-ai[bot]

This comment was marked as resolved.

jserv added 2 commits October 25, 2026 09:55
Without its arguments the binary falls back to web, which serves Setup
until it is killed. A mutant that drops them, such as parse_agent_args
returning nothing, then hangs whichever test ran the binary without a
bound, and cargo mutants scores the caught mutant as a timeout, which
fails the job. Only the refusal tests had a bound.

Every run now goes through the bounded runner, which every caller
already expected to exit, and so does the one test that started the
binary itself.
A loopback bind keeps the network out but not a web page: any origin
can repoint its own name at 127.0.0.1 and then read the server as same
origin, including the reports behind it. The Host header is the one
thing that still carries the foreign name.

CODETRIAL_ALLOWED_HOSTS, or --allowed-hosts, names the Host values the
server answers; anything else gets 421 before any route runs. It is
empty by default, and the check is not installed at all then, because
a reverse proxy forwarding the public name is an ordinary deployment.
A list that names no host is refused at startup instead of read as
empty, so it cannot switch the check off unnoticed.
jserv added 6 commits October 25, 2026 10:47
A program that starts the server for someone, rather than an operator
in a shell, chooses neither the working directory nor the folder the
executable sits in, and both decide where the CLI looks for its config
and writes its database, cold-start config and error log. An installed
app's executable folder is read-only or inside a bundle.

--data-dir DIR puts all of that in DIR and searches nowhere else. Such
a launch also reads no configuration from the environment, so a shell
that exported CODETRIAL_DB_PATH or NODE_ENV for a CLI deployment cannot
move the install's reports depending on how it was started, and with
no SESSION_SECRET in its config it makes and keeps its own key in DIR
instead of signing cookies with the published default. A first launch
into a folder that does not exist yet creates it for the error log, so
the reason it failed is where its owner will look. One thing is taken
from the environment: a first launch with the three LiveKit keys
exported writes them, and the Google keys, into DIR's config as Setup
would, and skips Setup. Without the flag nothing changes.

That file and the key are written by the helper Setup now shares,
owner-only and never over an existing name, which also removes a file a
failed write cut short. The CLI tests start the server with an empty
environment instead of a list of keys that had already fallen behind.
A program that starts the server as its child keeps it on a fixed
port, and when that program crashes the server outlives it and holds
the port, so the next launch cannot bind it. Parent-death signals and
job objects differ on every platform; a pipe is closed by the kernel
when its writer dies, however it dies, everywhere.

With CODETRIAL_EXIT_WITH_STDIN set, the server exits once its standard
input reaches end of file. It is read from the process environment even
under --data-dir, because it describes the pipe the launcher handed over
rather than configuring the server, and it has to hold while Setup
serves. The exit does not print with eprintln, which would panic on the
dead parent's stderr and end the watching thread instead.
Running CodeTrial means starting a server from a terminal and opening
its address in a browser. desktop/ is a Tauri 2 app for macOS, Windows
and Linux that starts the codetrial server as its child, in its own data
folder on a fixed loopback port that only it may name, and shows the
page in its own window. The server stays a separate program, so the
CLI's startup is unchanged and the child is the binary its tests cover.

The page gets no native access: only the server's origin and the app's
starting page load in the window, the camera and microphone go to the
server's page alone, and a close asks the page's leave-page guard
first. Where WebKitGTK is built without WebRTC, which the interview's
call needs, the interview opens in the default browser instead. The
starting page takes the lobby's palette, accent and radii, copied since
it loads before any server can serve them.

The crate builds into target/desktop, and the gate builds and tests it
where WebKitGTK is installed. CI's Rust lane installs it on every run,
since the crate's tests read the server's side of the launch from
src/main.rs and a gate that skips there fails the run. CI's dependency
audit reads the crate's lockfile beside the root's.
The desktop app has to reach people as installers, and the gate's unit
tests never start a real server inside a real window. The desktop
workflow builds the server and the app on Linux, macOS and Windows,
packages them, and runs scripts/desktop-smoke.sh against each build:
the server must answer, refuse a foreign Host, and exit when the app is
killed outright. A desktop-vX.Y.Z tag whose version matches
desktop/Cargo.toml publishes the installers as a prerelease, apart from
the CLI's rolling latest release.

Close #289
Setup asked for every key by hand, even from someone whose keys were
already saved in a config file, a CLI install's or another machine's.
An import button on the page reads a chosen file in the browser, by the
rules the server reads config files with, and fills the form. Nothing
is sent until the form is saved, so the keys are checked as typed ones
are. The form saves one Google key, so a GOOGLE_API_KEYS list gives its
first and the page says so.

A file input rather than a native dialog: the page is the server's and
has no native access, and it opens in a browser too, where the desktop
app's web view cannot run the interview.
A machine that already runs the CLI keeps its keys in the CLI's config,
and the desktop app reads only its own data folder, so its first launch
asked for them again. The app now passes the CLI config in the folder
it starts in, and nowhere above it, and Setup offers it: the page names
the file and its LiveKit URL, and Use these keys has the server read
the file and save its Setup keys, checked as typed ones are.

Applied only when the page asks, since a file found where the app
started could be any project's and its LiveKit project is where
interviews would go, and never shown to the page, since any account on
the machine can reach the port while Setup serves. A malformed line in
a config is named by number, not quoted, because it can hold a secret
and the message reaches a window and a log. Exported keys are still
written without asking.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Evaluate a Tauri desktop app, gated on measured value over browser auto-open

1 participant