Skip to content

Linux desktop support - #570

Open
exbald wants to merge 5 commits into
andrewyng:mainfrom
exbald:linux-desktop-support
Open

Linux desktop support#570
exbald wants to merge 5 commits into
andrewyng:mainfrom
exbald:linux-desktop-support

Conversation

@exbald

@exbald exbald commented Aug 26, 2026

Copy link
Copy Markdown

Ships OpenWorker on Linux as a .deb and an .AppImage, with ChromeOS Crostini as the specific target (a Debian 12 container with no status area).

The Python server was already portable — every sys.platform branch had a working POSIX path. The desktop shell was the gap: it had never been built for Linux, and three of its behaviours would have kept the packaged app from working at all.

Screenshots

The contributing guide asks for before/after. There is no "before" to photograph here — before this PR there was no Linux build to run at all. So these are the "after": OpenWorker built from this branch, installed via apt, running on an arm64 Chromebook under Crostini. (The ChromeOS shelf is visible along the bottom of each.)

Running as a native app from the ChromeOS launcher.

A scheduled automation that fired, searched the web, and wrote its artifact — the scheduler, web search, and artifact pipeline all working on Linux.

Connectors live — the backend is the same Python server as on macOS/Windows.

What was actually broken

The app refused to start without a system tray. TrayIconBuilder::build(app)? propagated its error out of setup, so a session with no StatusNotifier host — Crostini has none — aborted launch over a tray icon. Now it logs and continues, and close-to-tray is gated on a tray existing; without one, closing the window quits instead of hiding it behind nothing.

A packaged install could never find its sidecar. A .deb puts the binary in /usr/bin and its resources in /usr/lib/OpenWorker/. Every candidate in server_bin() was relative to the executable's own directory, so resolution fell all the way through to the dev-venv path and the server never came up. Resolution now starts from Tauri's own resource_dir, which is right by construction on every platform.

ocw-stt doesn't belong in a Linux build. It compiles whisper.cpp (cmake + a C++ toolchain) and links ALSA — build-time cost on every checkout for a feature the Linux artifacts don't offer. It's now scoped to non-Linux targets and replaced by a dictation_stub module with the same API, so there is still exactly one set of Tauri commands. Voice Input reports unsupported, as it already did on Linux.

Rest of the shell

  • Keep-awake holds a logind inhibitor (systemd-inhibit … cat), released by closing the pipe rather than killing the process — killing systemd-inhibit orphans its child, and closing the pipe also releases the lock if the app is hard-killed. It waits to confirm the inhibitor actually survives: systemd-inhibit exits immediately where it can't reach logind, and a successful spawn() alone would mean reporting a hold nobody is holding.
  • Self-update is AppImage-only. Tauri rewrites $APPIMAGE; a .deb has no such file and its paths belong to dpkg. A new can_self_update command lets Settings say "this install updates through your package manager" rather than answering "you're on the latest version" without having checked.
  • Crostini blank-window fix. WebKitGTK's DMABuf renderer draws nothing through virtio-gpu/Sommelier — the app looks hung with nothing in the log. The shell detects Crostini (ChromeOS-only markers) and disables that renderer unless the user set the variable themselves.
  • PATH repair learned the Linux install dirs (~/.local/bin, ~/.cargo/bin, ~/go/bin, /snap/bin, Linuxbrew) and the login-shell probe falls back to bash where /bin/zsh doesn't exist.

Packaging, CI, backend

  • packaging/build_linux.sh mirrors build_dmg.sh — freeze the sidecar, stage it, bundle. Checks the system libraries up front so a missing one fails with the apt line, not a pkg-config error 400 lines into a cargo build.
  • packaging/bootstrap_linux.sh takes a bare machine to a runnable checkout in one command: system libraries, Rust, Node 20 (Debian 12 ships 18), the venv, npm deps — skipping whatever is already present, and asking before it touches anything.
  • tauri.conf.json: Linux bundle config plus real package descriptions (the .deb shipped Description: … / (none)).
  • release.yml builds linux-x64 and linux-arm64 on ubuntu-22.04, not the newest image: glibc is forward- but not backward-compatible, and a 24.04 build (glibc 2.39) will not start on Debian 12 (glibc 2.36) — which is what Crostini runs. latest.json gains the AppImage platform keys; the .deb is deliberately absent from it.
  • New ci.yml job compiles and tests the shell on Linux. Nothing did before, so a Linux break would first have surfaced at release time.
  • The server-side folder picker tries zenity → kdialog → qarma instead of assuming zenity, and stops at the first one installed.
  • docs/linux.md: install, Crostini setup, the platform-difference table, build-from-source, troubleshooting.

Verification

On real hardware — the arm64 Chromebook in the screenshots above:

  • bootstrap_linux.sh on a bare machine: 281 apt packages, fresh rustup install, existing Node detected and skipped — exit 0.
  • build_linux.shOpenWorker_0.2.1_arm64.deb (85 MB); Rust compile 7m20s.
  • apt install registered the desktop entry and 32/128/256@2 icons; the app launches from the ChromeOS launcher.
  • The shell spawned the frozen sidecar from /usr/lib/OpenWorker/sidecar/openworker-server. Unauthenticated requests to its port get HTTP 401 — the shell holds the launch token, so the server correctly refuses everyone else.

On x86_64 Ubuntu (headless): .deb (71 MB) + .AppImage (136 MB); installed the .deb and launched under Xvfb — sidecar found and started, SPA served live traffic (/v1/sessions, /v1/inbox, /v1/workspaces/recent, /v1/automations all 200), no orphaned server after exit.

Test suites: pytest tests 1847 passed; GUI typecheck + 134 unit tests green; cargo test --lib green (4 new tests covering sidecar resolution, the Linux voice-support flag, and the keep-awake invariant). All four CI jobs green, including the new desktop-shell-linux.

Not included

Voice Input on Linux — the one deliberate gap, documented in docs/linux.md; enabling it later is a one-line change to the ocw-stt dependency guard plus two apt packages. Linux artifacts are also unsigned; the platform has no notarization equivalent, and only the AppImage updater tarball is minisigned.
Screenshot 2026-08-27 03 05 08
Screenshot 2026-08-27 03 07 19
Screenshot 2026-08-27 03 07 56

claude added 5 commits August 26, 2026 16:46
The Python server already ran on Linux; the desktop shell did not ship there
and had gaps that would have kept it from starting. Targets ChromeOS Crostini
in particular, since that is a Debian 12 container with no status area.

Shell (surfaces/gui/src-tauri):
- Tray creation is no longer fatal. `TrayIconBuilder::build` fails on any
  session without a StatusNotifier host (Crostini has none), and the `?` on it
  aborted `setup`, so the whole app refused to start. It now logs and carries
  on, and the window close handler only hides to the tray when one exists —
  otherwise closing quits, instead of stranding an invisible window over a
  running sidecar.
- The sidecar is resolved through Tauri's `resource_dir` first. A .deb puts the
  binary in /usr/bin and its resources in /usr/lib/OpenWorker, which none of the
  exe-relative guesses reach; the packaged app fell through to the dev venv path
  and never started its server.
- ocw-stt is scoped to non-Linux targets and replaced by a `dictation_stub`
  module with the same API. Linking it would add whisper.cpp (cmake, a C++
  toolchain) and ALSA headers to every Linux build for a feature the Linux
  artifacts don't offer. Voice Input reports unsupported, as it already did.
- Keep-awake holds a logind inhibitor via `systemd-inhibit ... cat`, released by
  closing the pipe so nothing is orphaned. No inhibitor available → the toggle
  reports off rather than claiming a hold, and Settings says why.
- Self-update is AppImage-only on Linux (a .deb's files belong to dpkg). A new
  `can_self_update` command lets Settings say so instead of answering "you're on
  the latest version" without having looked.
- Crostini renders a blank white window under WebKitGTK's DMABuf renderer; the
  shell detects Crostini and disables it unless the user set the variable.
- Sidecar PATH repair learned the Linux install dirs (~/.local/bin, ~/.cargo/bin,
  ~/go/bin, /snap/bin, Linuxbrew) and the login-shell probe falls back to bash.

Packaging and CI:
- packaging/build_linux.sh mirrors build_dmg.sh: freeze the sidecar, stage it,
  bundle .deb + .AppImage. Checks for the system libraries up front.
- tauri.conf.json gains the Linux bundle config and real package descriptions.
- release.yml builds linux-x64 and linux-arm64 on ubuntu-22.04 (glibc 2.35, so
  the artifacts also run on Debian 12); latest.json gains the AppImage entries.
- New ci.yml job compiles and tests the shell on Linux, which nothing did before.

Backend: the server-side folder picker tries zenity, then kdialog, then qarma
rather than assuming zenity, and stops at the first one installed.

Verified end to end on Ubuntu: the built .deb installs, launches under Xvfb,
finds and starts its sidecar, and serves the UI (200s across the API). Full
pytest suite unchanged (1847 passed; the 15 failures are pre-existing in this
container — missing boto3 extra and Slack socket timeouts), GUI unit tests and
typecheck green, cargo test green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019b8eay3F9BvB2emcd19GvF
Building from source on Linux meant assembling five things by hand — the
WebKit/GTK system libraries, Rust, a Node newer than what Debian 12 ships, the
Python venv, and the npm packages — with a wrong or missing one showing up
somewhere in the middle of a half-hour compile.

The script installs each, skipping whatever is already present, and prints the
plan before it touches anything (--yes skips the prompt; --packaging adds the
pyinstaller/typer deps build_linux.sh needs). Rust and Node go through the
official rustup/nvm installers, which is why consent is explicit and why an
existing install is left alone.

Two things it catches that a README list doesn't: python3-venv, which is a
separate package on Debian/Ubuntu and fails at the last step of an otherwise
fine setup, and free disk — a full build reaches ~7 GB, and Crostini's disk is
small by default and resized in ChromeOS settings.

Verified by running it in this container: it skipped the already-present Rust
and Node, installed the genuinely-missing python3-venv, rebuilt the venv, and
left a working checkout (aisuite/coworker import, pyinstaller 6.22.2).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019b8eay3F9BvB2emcd19GvF
… install

Step 2 sources $HOME/.cargo/env into the script so the remaining steps can use
cargo. That also makes `have cargo` true from then on — so the closing hint,
which tested `! have cargo`, was suppressed on precisely the case that needs it:
a machine where rustup just installed Rust for the first time, whose current
shell has no cargo on PATH.

Capture the answer before step 2 instead, the way NODE_MAJOR already is.

Found on the first real run of this script — a fresh ChromeOS Crostini machine,
where rustup installed cleanly and the follow-up instructions then omitted the
one line the user needed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019b8eay3F9BvB2emcd19GvF
Command::spawn() only proves the binary ran. systemd-inhibit itself fails
whenever it cannot reach logind — containers, non-systemd sessions, no session
bus — printing "Failed to connect to bus" to the stderr we discard and exiting 1
immediately. Treating a successful spawn as a successful lock left us holding a
dead child and reporting keep-awake as ON, which is precisely the lie this
function was written to avoid.

Wait for it to fail instead: a working inhibitor runs `cat` until we close its
stdin, so it lives indefinitely, while a broken one is gone in milliseconds.
Poll try_wait() for up to 400ms and return the moment it dies. The cost is
bounded and the happy path never pays it in a way a settings toggle notices.

The new test encodes the invariant on either kind of machine: whatever
start_keep_awake() returns must be a LIVE hold, never a corpse. It reproduced
the bug in a container where systemd-inhibit is installed but exits 1 — and note
it has to sleep before asserting, because try_wait() immediately after spawn
cannot distinguish the two cases at all: the child has not been scheduled yet.

Reported by the Codex review bot on the PR.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019b8eay3F9BvB2emcd19GvF
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.

2 participants