loadr Desktop went from an empty directory to a signed, multi-platform release in a tight burst on 19–20 June 2026. Seven feature PRs — labelled M1 through M7 — merged in that window, followed by the packaging shakeout that every native app pays for. The whole thing landed as a clean milestone series because it was designed as one: each PR was independently shippable, each ended with a green test suite, and none of them added a second engine. The desktop app is a front-end over the same CLI binary you already download — nothing more. This is the story of the seven milestones and the two genuinely hard problems: getting a Rust binary to run on both Mac architectures, and driving a real Electron window in CI.
The one architectural decision
The GUI does not reimplement loadr. It shells out to it. The main process resolves a version-pinned binary — bundled first, then $LOADR_BIN, then PATH in dev — and invokes it with array arguments only, never a shell string, so plan content can never be interpreted by a shell. That rule lives in desktop/src/main/loadr.ts and it is the spine of the app:
export function resolveLoadr(): string {
return bundledPath() ?? process.env.LOADR_BIN ?? 'loadr';
}
The renderer never spawns a process or touches the filesystem. It talks to the main process over a typed preload bridge with contextIsolation on, nodeIntegration off, and the sandbox enabled. Everything the UI knows about a plan — validation, the JSON Schema that shapes the forms, run summaries, plugin lists — comes back from a CLI invocation. That single decision is why the desktop app is never out of sync with the engine: there is only one engine.
M1 — scaffold, bridge, round-trip (PR #29, 12:23)
The first commit (1873899) set up an Electron + TypeScript app with a React 19 / Vite 6 / Tailwind 4 renderer via electron-vite, the loadr bridge described above, and the piece everything else depends on: a lossless YAML round-trip in src/shared/plan.ts. Parse keeps every key; serialize emits block YAML the CLI accepts. The proof was 71 property tests over the repo's own examples/ corpus — every plan survives parse → serialize → parse with no data loss and the serialized YAML passes loadr validate. The renderer at this point only opened a file and rendered it read-only with a validation badge. That was deliberate: prove the data model before building UI on it.
M2 — forms and Monaco, in sync both ways (PR #30, 12:28)
Five minutes of wall-clock later, 909bd87 added the editing experience. The centrepiece is usePlanDoc: a single editing document that keeps the structured model and the raw YAML text in sync in both directions — form edits re-serialize, YAML edits re-parse — with debounced CLI-backed validation. Underneath it sits a pure, immutable edit engine (src/shared/edit.ts) doing path get/set/delete and scenario/executor/flow-step operations. The raw-edit view is a self-hosted Monaco editor — no CDN, so it stays CSP-safe inside Electron. Test count reached 86.
M3 — drag-and-drop and tabs (PR #31, 14:23)
92e6d28 made the flow editor physical: drag-and-drop step composition with dnd-kit, wired with both Pointer and Keyboard sensors so reordering is keyboard-accessible, not mouse-only. The workspace became tabbed via a pure, tested reducer — open re-focuses an already-open file, close activates a sensible neighbour, save renames the tab — and each tab keeps its editor mounted so switching preserves in-flight edits. Import arrived here too: loadr convert turns a .jmx, .k6 or .har file into a new tab. 95 tests.
M4 — actually running plans (PR #32, 15:03)
This is the milestone that turns a plan editor into a load-testing app. dced2a2 parses two different loadr outputs: parseSummary over a loadr run --summary-export document (built against a fixture captured from a real run) for the results view, and parseProgressLine over the live one-line stream for the running dashboard — VUs, RPS, p95, failed. Both are pure and unit-tested. A run-history store (capped per plan, newest-first, persisted to userData) feeds a compareResults function that computes p95/p99/error-rate/requests/iterations deltas with lower-is-better flags. Live metrics, results, history and A/B compare — all from parsing CLI text.
M5 — plugins (PR #33, 15:41)
loadr's protocol plugins (mongo, sql) are installed at runtime, not baked into the binary. 633899b surfaced that in the GUI: a panel over loadr plugin list / install / remove, install by index name / directory / URL with an optional --allow-untrusted, all through the same array-args bridge. 109 tests.
M6 — packaging and the CI problem (PR #34, 16:05)
a2bb049 added electron-builder config (mac dmg/zip for x64 and arm64, Windows nsis/portable, Linux AppImage/deb), a stage-loadr.mjs script to bundle the matching binary, and the thing that made all the earlier "verified by the M6 e2e" promises come due: a Playwright-for-Electron acceptance suite. It launches the built app headlessly and drives the real DOM — a smoke test of the shell, a compose test (add a scenario, add a request, reorder by keyboard, reshape an executor, assert the YAML), and a run test against a throwaway local server all the way through to history.
Getting that green in CI was the first hard part. Electron needs a display, so the e2e job runs on Node 22 under xvfb — a bare sandbox segfaults without it, and Node 24 trips a Playwright loader bug. Both landmines are documented in the desktop README so nobody rediscovers them. Desktop CI grew to three jobs: check (typecheck/lint/unit), e2e (xvfb), and a package matrix across macOS, Windows and Ubuntu that uploads installers.
M7 — release automation (PR #38, next morning 08:45)
3a33041 wired semantic-release, scoped to desktop/ via semantic-release-monorepo, so Conventional Commits decide the next version, tag it desktop-v*, and publish a GitHub release with installers for all three platforms plus SLSA build-provenance attestation. Two loadr-specific wrinkles: writes use PAT_TOKEN because the org forces GITHUB_TOKEN read-only, and the release was recreated cleanly on top of main rather than cherry-picked, which also fixed three inline YAML flow-maps that GitHub's parser rejected.
The part the milestones don't show: shipping a working Mac binary
M7 tagged a release. It did not, at first, produce Macs that could run it. Two follow-up fixes tell the real story.
The symptom was spawn Unknown system error -86 — EBADARCH. The cause (fixed in d5537eb, PR #50) was that CI staged one native-arch loadr into both the x64 and arm64 DMGs, so one of them always shipped a wrong-CPU binary. That same PR overhauled error messaging so a spawn failure reads as plain English with a concrete fix instead of -86, and added a startup doctor() health check that diagnoses a broken engine up front.
The first fix — a beforePack hook staging the arch-matching binary per pack — looked right and was still wrong, which is the more interesting lesson. electron-builder packages x64 and arm64 concurrently, both reading the single resources/bin/loadr that the hook mutated. The arm64 pass overwrote the source mid-build and the x64 DMG still shipped an arm64 binary — confirmed by inspecting the desktop-v1.2.1 x64 zip and finding a Mach-O arm64 inside it. The real fix (710f222, PR #51) removed the race entirely: cross-build both apple-darwin targets and lipo them into one universal2 fat binary, staged once for every Mac pack. A fat binary runs natively on Intel and Apple Silicon (and under Rosetta), so there is nothing to race and no wrong-arch outcome.
One release-train hazard is worth flagging: the desktop workflow initially stole releases/latest from the CLI, which would have broken curl | sh installs. It was caught and pinned with makeLatest: false (PRs #42, #44) before it reached users.
What the shape was worth
Seven PRs, each with a green suite, growing from 71 tests at M1 to 125 at M7 and 159 after the Mac fixes. The reason it moved this fast is the one architectural constraint held the whole way: the desktop app is a front-end over the CLI, and there is exactly one engine. No duplicated protocol code, no second parser, no drift. Every feature is a thin, tested layer over a CLI invocation. The genuinely hard problems weren't in the features — they were in the two places where "it works on my machine" and "it works in the release" diverge: a cross-compiled binary that has to match the user's CPU, and a windowed app that has to run in a headless CI box. Both are now boring, which is the goal.