Linux desktop support - #570
Open
exbald wants to merge 5 commits into
Open
Conversation
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
…laude/openworker-linux-version-v77ztb
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Ships OpenWorker on Linux as a
.deband 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.platformbranch 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 ofsetup, 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
.debputs the binary in/usr/binand its resources in/usr/lib/OpenWorker/. Every candidate inserver_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 ownresource_dir, which is right by construction on every platform.ocw-sttdoesn'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 adictation_stubmodule 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
systemd-inhibit … cat), released by closing the pipe rather than killing the process — killingsystemd-inhibitorphans 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-inhibitexits immediately where it can't reach logind, and a successfulspawn()alone would mean reporting a hold nobody is holding.$APPIMAGE; a.debhas no such file and its paths belong to dpkg. A newcan_self_updatecommand lets Settings say "this install updates through your package manager" rather than answering "you're on the latest version" without having checked.~/.local/bin,~/.cargo/bin,~/go/bin,/snap/bin, Linuxbrew) and the login-shell probe falls back to bash where/bin/zshdoesn't exist.Packaging, CI, backend
packaging/build_linux.shmirrorsbuild_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.shtakes 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.debshippedDescription: … / (none)).release.ymlbuildslinux-x64andlinux-arm64on 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.jsongains the AppImage platform keys; the.debis deliberately absent from it.ci.ymljob compiles and tests the shell on Linux. Nothing did before, so a Linux break would first have surfaced at release time.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.shon a bare machine: 281 apt packages, fresh rustup install, existing Node detected and skipped — exit 0.build_linux.sh→OpenWorker_0.2.1_arm64.deb(85 MB); Rust compile 7m20s.apt installregistered the desktop entry and 32/128/256@2 icons; the app launches from the ChromeOS launcher./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.deband launched under Xvfb — sidecar found and started, SPA served live traffic (/v1/sessions,/v1/inbox,/v1/workspaces/recent,/v1/automationsall 200), no orphaned server after exit.Test suites:
pytest tests1847 passed; GUI typecheck + 134 unit tests green;cargo test --libgreen (4 new tests covering sidecar resolution, the Linux voice-support flag, and the keep-awake invariant). All four CI jobs green, including the newdesktop-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 theocw-sttdependency guard plus two apt packages. Linux artifacts are also unsigned; the platform has no notarization equivalent, and only the AppImage updater tarball is minisigned.