From 9fc42b9690f52303bcf6891cd52c3f8f06c7f673 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 26 Aug 2026 16:46:42 +0000 Subject: [PATCH 1/4] Add a Linux build of the desktop app (.deb + AppImage) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_019b8eay3F9BvB2emcd19GvF --- .github/workflows/ci.yml | 33 +- .github/workflows/release.yml | 43 +- README.md | 9 +- coworker/server/manager.py | 55 ++- docs/linux.md | 174 ++++++++ packaging/build_linux.sh | 126 ++++++ packaging/make_update_manifest.py | 15 +- surfaces/gui/src-tauri/Cargo.toml | 10 +- surfaces/gui/src-tauri/src/lib.rs | 395 +++++++++++++++++-- surfaces/gui/src-tauri/tauri.conf.json | 12 + surfaces/gui/src/components/SettingsView.tsx | 34 +- surfaces/gui/src/tauri.ts | 8 +- tests/test_server.py | 46 +++ 13 files changed, 897 insertions(+), 63 deletions(-) create mode 100644 docs/linux.md create mode 100755 packaging/build_linux.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bacffd6ed6..a679bbc68b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,5 +1,6 @@ -# App CI — the coworker Python suite, the GUI unit tests, and the hermetic -# Playwright e2e suite (mocked /v1 + WS; no model or network needed). +# App CI — the coworker Python suite, the GUI unit tests, the hermetic Playwright e2e +# suite (mocked /v1 + WS; no model or network needed), and a compile of the Linux +# desktop shell. name: CI @@ -57,3 +58,31 @@ jobs: - name: e2e working-directory: surfaces/gui run: npm run e2e + + desktop-shell-linux: + # The Tauri shell carries real Linux-only code — the dictation stub that stands in for + # ocw-stt, the tray fallback, sidecar resolution through the resource dir — and no other + # job compiles any of it. Without this, a break in the Linux build would first surface at + # release time. ubuntu-22.04 to match packaging/build_linux.sh's glibc floor. + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v4 + + - uses: dtolnay/rust-toolchain@stable + + - uses: Swatinem/rust-cache@v2 + with: + workspaces: surfaces/gui/src-tauri + + - name: Tauri system libraries + run: | + sudo apt-get update + sudo apt-get install -y \ + libwebkit2gtk-4.1-dev libgtk-3-dev librsvg2-dev libayatana-appindicator3-dev + + - name: cargo test + working-directory: surfaces/gui/src-tauri + # No frontend build first: without `tauri/custom-protocol` (which only `tauri build` + # passes) the shell serves the UI from devUrl and embeds nothing, so `frontendDist` + # never has to exist. The Vite build stays in the gui-unit job where it belongs. + run: cargo test --lib diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6b3e96d2fe..8c4d2c958c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,4 +1,5 @@ -# Desktop release builds — macOS (.dmg, arm64 + Intel) and Windows (.msi + NSIS .exe). +# Desktop release builds — macOS (.dmg, arm64 + Intel), Windows (.msi + NSIS .exe), and +# Linux (.deb + .AppImage, x64 + arm64). # # CI calls the SAME scripts developers run locally (packaging/build_dmg.sh and # build_windows.ps1); this file only provisions the toolchain (Node, Rust, a Python venv at @@ -25,6 +26,8 @@ # APPLE_API_ISSUER the API issuer id # When the secrets are absent (forks, scratch runs) the build degrades to unsigned — # installable via `xattr -cr`. Windows remains unsigned (Authenticode is a later step). +# Linux artifacts are not code-signed at all (the platform has no equivalent); only the +# AppImage updater tarball is minisigned, which is what the in-app updater verifies. name: Release @@ -52,6 +55,13 @@ jobs: slug: macos-x64 - os: windows-latest slug: windows + # Linux builds on ubuntu-22.04 (glibc 2.35), NOT the newest image: glibc is forward- + # but not backward-compatible, so a 24.04 build would refuse to start on Debian 12 + # (glibc 2.36) — exactly what ChromeOS Crostini ships. See packaging/build_linux.sh. + - os: ubuntu-22.04 + slug: linux-x64 + - os: ubuntu-22.04-arm + slug: linux-arm64 runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v4 @@ -68,6 +78,16 @@ jobs: with: workspaces: surfaces/gui/src-tauri + - name: Tauri system libraries (Linux) + if: runner.os == 'Linux' + # webkit2gtk/gtk are what the shell links against; patchelf + file are what the + # AppImage bundler shells out to; librsvg renders the icon. + run: | + sudo apt-get update + sudo apt-get install -y \ + libwebkit2gtk-4.1-dev libgtk-3-dev librsvg2-dev \ + libayatana-appindicator3-dev patchelf file + - uses: actions/setup-python@v5 with: python-version: "3.12" @@ -114,6 +134,13 @@ jobs: unset APPLE_API_KEY_CONTENT bash packaging/build_dmg.sh + - name: Build .deb + .AppImage (Linux) + if: runner.os == 'Linux' + env: + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} + run: bash packaging/build_linux.sh + - name: Build .msi + NSIS .exe (Windows) if: runner.os == 'Windows' shell: pwsh @@ -126,7 +153,19 @@ jobs: run: | mkdir -p out BUNDLE=surfaces/gui/src-tauri/target/release/bundle - if [ "$RUNNER_OS" = "Windows" ]; then + if [ "$RUNNER_OS" = "Linux" ]; then + cp "$BUNDLE"/deb/*.deb out/ + cp "$BUNDLE"/deb/*.deb out/OpenWorker-${{ matrix.slug }}.deb + cp "$BUNDLE"/appimage/*.AppImage out/ + cp "$BUNDLE"/appimage/*.AppImage out/OpenWorker-${{ matrix.slug }}.AppImage + # Updater artifact: the signed AppImage tarball an AppImage install swaps in. + # A .deb install never sees an update prompt (the package manager owns those + # paths), so there is deliberately no updater artifact for the .deb. + if ls "$BUNDLE"/appimage/*.AppImage.tar.gz >/dev/null 2>&1; then + cp "$BUNDLE"/appimage/*.AppImage.tar.gz out/OpenWorker-${{ matrix.slug }}.AppImage.tar.gz + cp "$BUNDLE"/appimage/*.AppImage.tar.gz.sig out/OpenWorker-${{ matrix.slug }}.AppImage.tar.gz.sig + fi + elif [ "$RUNNER_OS" = "Windows" ]; then cp "$BUNDLE"/nsis/*.exe out/ cp "$BUNDLE"/nsis/*.exe out/OpenWorker-windows-setup.exe cp "$BUNDLE"/msi/*.msi out/ diff --git a/README.md b/README.md index 72547b427f..d4932c6254 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,9 @@ It runs on your machine and doesn't lock you into any model: bring your own API [**⬇ Windows 10/11 (x64)**](https://download.openworker.com/windows) builds are not yet code-signed, so SmartScreen will warn; signing is in progress +[**⬇ Linux (.deb · AppImage, x64 + arm64)**](https://github.com/andrewyng/openworker/releases/latest) +Debian 12+ / Ubuntu 22.04+ · runs on ChromeOS Crostini · [setup and caveats](docs/linux.md) + Open the app, add a model key (or point it at Ollama), and ask for something real. ## How it works @@ -64,7 +67,7 @@ OpenWorker is local-first. Everything lives on your machine: the agent loop, you ## Run from source -Prerequisites: Python 3.10+, Node 20+, and (for the desktop shell) the Rust toolchain via [rustup](https://rustup.rs/). +Prerequisites: Python 3.10+, Node 20+, and (for the desktop shell) the Rust toolchain via [rustup](https://rustup.rs/). On Linux the shell also needs the WebKit/GTK development packages — see [docs/linux.md](docs/linux.md). ```shell git clone https://github.com/andrewyng/openworker @@ -91,7 +94,7 @@ desktop app uses an in-memory launch token instead and never writes it to disk. To run the full desktop app instead of the browser UI, replace step 3 with `npm run tauri dev` (from `surfaces/gui/`) - the Tauri shell launches the window and supervises the server itself. -Tests: `.venv/bin/pytest` (server), `npm test` and `npm run e2e` in `surfaces/gui` (GUI unit + hermetic end-to-end). Desktop bundles are built with `packaging/build_dmg.sh` / `packaging/build_windows.ps1`. +Tests: `.venv/bin/pytest` (server), `npm test` and `npm run e2e` in `surfaces/gui` (GUI unit + hermetic end-to-end). Desktop bundles are built with `packaging/build_dmg.sh` / `packaging/build_windows.ps1` / `packaging/build_linux.sh`. ## Repository layout @@ -100,7 +103,7 @@ Tests: `.venv/bin/pytest` (server), `npm test` and `npm run e2e` in `surfaces/gu | `coworker/` | Python backend - agent engine, model providers, connectors, MCP client, memory, automations | | `surfaces/gui/` | Desktop app - React UI + Tauri shell that supervises the server | | `stt/` | Speech-to-text sidecar (Rust) for voice input | -| `packaging/` | Installer builds (macOS DMG, Windows), auto-update manifest, dev bootstrap | +| `packaging/` | Installer builds (macOS DMG, Windows, Linux .deb/AppImage), auto-update manifest, dev bootstrap | | `docs/` | Design specs and decision logs | | `tests/` | Backend test suite | diff --git a/coworker/server/manager.py b/coworker/server/manager.py index 1486d9064b..c9bd295c92 100644 --- a/coworker/server/manager.py +++ b/coworker/server/manager.py @@ -2895,16 +2895,20 @@ def pick_native_folder(self) -> dict[str, Any]: paths from web file dialogs, but the sidecar is local and can (the desktop shell uses Tauri's own picker instead). Blocking until pick/cancel; callers run it off-thread. """ + import os import subprocess import sys + prompt = "Give the coworker access to a folder" if sys.platform == "darwin": - cmd = [ - "osascript", - "-e", - 'tell application "System Events" to activate', - "-e", - 'POSIX path of (choose folder with prompt "Give the coworker access to a folder")', + candidates = [ + [ + "osascript", + "-e", + 'tell application "System Events" to activate', + "-e", + f'POSIX path of (choose folder with prompt "{prompt}")', + ] ] elif sys.platform == "win32": # WinForms folder dialog via PowerShell — no extra deps. -STA is required @@ -2912,22 +2916,37 @@ def pick_native_folder(self) -> dict[str, Any]: ps = ( "Add-Type -AssemblyName System.Windows.Forms; " "$f = New-Object System.Windows.Forms.FolderBrowserDialog; " - "$f.Description = 'Give the coworker access to a folder'; " + f"$f.Description = '{prompt}'; " "if ($f.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) " "{ [Console]::Out.Write($f.SelectedPath) }" ) - cmd = ["powershell.exe", "-NoProfile", "-STA", "-Command", ps] + candidates = [["powershell.exe", "-NoProfile", "-STA", "-Command", ps]] else: - # Linux: zenity when present; otherwise the GUI's paste-a-path input remains. - cmd = ["zenity", "--file-selection", "--directory"] - try: - out = subprocess.run(cmd, capture_output=True, text=True, timeout=300) - except (OSError, subprocess.TimeoutExpired): - return {"ok": False, "error": "no native folder picker available"} - path = (out.stdout or "").strip() - if out.returncode != 0 or not path: - return {"ok": False, "canceled": True} - return {"ok": True, "path": path} + # Linux/BSD: no single blessed dialog, and none is guaranteed present — a minimal + # desktop (ChromeOS Crostini's container ships neither) may have none at all. Try + # the two that come with the big desktops, then zenity's Qt clone; the first one + # actually installed wins. All missing → the GUI's paste-a-path input remains the + # way in, which is why this returns an error rather than raising. + home = os.path.expanduser("~") + candidates = [ + ["zenity", "--file-selection", "--directory", f"--title={prompt}"], + ["kdialog", "--getexistingdirectory", home, "--title", prompt], + ["qarma", "--file-selection", "--directory", f"--title={prompt}"], + ] + for cmd in candidates: + try: + out = subprocess.run(cmd, capture_output=True, text=True, timeout=300) + except FileNotFoundError: + continue # dialog not installed — try the next one + except (OSError, subprocess.TimeoutExpired): + break + path = (out.stdout or "").strip() + if out.returncode != 0 or not path: + # The dialog ran and the user dismissed it. Never fall through to another + # picker here — a second window popping up after a cancel is worse than none. + return {"ok": False, "canceled": True} + return {"ok": True, "path": path} + return {"ok": False, "error": "no native folder picker available"} def _note_provider_use(self, name: str) -> None: """Router on_use hook: remember when a provider last served a completion. Persisted diff --git a/docs/linux.md b/docs/linux.md new file mode 100644 index 0000000000..b14aa9dc72 --- /dev/null +++ b/docs/linux.md @@ -0,0 +1,174 @@ +# OpenWorker on Linux + +The desktop app runs on Linux the same way it runs on macOS and Windows: a Tauri shell that +supervises the Python agent server as a bundled sidecar. Two formats are produced — + +| Format | For | Updates | +|---|---|---| +| `.deb` | Debian, Ubuntu, and **ChromeOS Crostini** | your package manager | +| `.AppImage` | any distro, no root needed | in-app auto-update | + +Everything the agent actually does — the engine, connectors, MCP, the terminal and file tools, +automations, Slack — is the same Python server on every platform, so it behaves identically +here. The differences are all in the desktop shell, and they are listed under +[Platform differences](#platform-differences) below. + +## Install + +### .deb (Debian / Ubuntu / Crostini) + +```shell +sudo apt install ./OpenWorker__.deb +``` + +`apt` (rather than `dpkg -i`) so the WebKit/GTK dependencies come in with it. On ChromeOS you +can also just double-click the file in the Files app. + +### AppImage + +```shell +chmod +x OpenWorker__.AppImage +./OpenWorker__.AppImage +``` + +AppImages need FUSE, which minimal systems (Crostini included) don't ship. Either install it +(`sudo apt install libfuse2`) or skip it entirely: + +```shell +./OpenWorker__.AppImage --appimage-extract-and-run +``` + +Only the AppImage self-updates. A `.deb` install never shows an update prompt, because the app +must not write into paths `dpkg` owns — upgrade it by installing the newer `.deb`. + +## ChromeOS (Crostini) + +Crostini is a Debian 12 container, so the `.deb` is the path of least resistance. + +1. **Turn Linux on**: Settings → About ChromeOS → Developers → *Linux development environment*. +2. **Pick the right architecture**: run `dpkg --print-architecture` in the Linux terminal — + `amd64` on Intel/AMD Chromebooks, `arm64` on most MediaTek/Qualcomm ones. +3. **Install**: download the `.deb`, then double-click it in the Files app (or + `sudo apt install ./OpenWorker_*.deb` in the terminal). +4. **Launch**: OpenWorker appears in the ChromeOS launcher under *Linux apps*. + +### Giving it access to your files + +The Linux container has its own home directory — the one you see in Files under *Linux files*. +That is what OpenWorker can reach by default. ChromeOS folders (Downloads, Google Drive, +external drives) are invisible to it until you share them: right-click the folder in Files → +**Share with Linux**. It then shows up in the container at `/mnt/chromeos/MyFiles/`, +and you can point OpenWorker's workspace there. + +This is a ChromeOS security boundary, not an OpenWorker limitation — every Linux app on the +device sees exactly the same thing. + +### What to expect on ChromeOS specifically + +- **No system tray.** ChromeOS has no status area for Linux apps, so closing the window quits + the app instead of hiding it (elsewhere on Linux, if a tray is available, close hides to it). + Scheduled automations only run while the app is open. +- **Blank white window?** Fixed automatically. WebKitGTK's default renderer draws nothing + through Crostini's virtualized GPU; the shell detects Crostini at startup and turns that + renderer off (`WEBKIT_DISABLE_DMABUF_RENDERER`). Set the variable yourself to override. +- **"Keep this system awake" won't stick.** ChromeOS decides when the device sleeps, and a + suspended Chromebook suspends the whole Linux VM with it. The toggle reports off rather than + claiming a hold it can't take. +- **Voice Input is absent** — see below. +- It is a VM on modest hardware: expect the app to feel heavier than it does on a laptop. + +## Platform differences + +| | macOS / Windows | Linux | +|---|---|---| +| Agent server, connectors, MCP, tools | ✅ | ✅ identical | +| Automations / scheduler | ✅ | ✅ (while the app runs) | +| System tray, close-to-tray | ✅ | where the desktop provides one | +| Open at login | ✅ | ✅ (XDG autostart) | +| Keep system awake | caffeinate / Win32 | `systemd-inhibit` when present | +| Native folder picker | ✅ | ✅ (GTK; the server-side picker needs `zenity` or `kdialog`) | +| In-app auto-update | ✅ | AppImage only | +| **Voice Input (dictation)** | ✅ | ❌ not built | + +**Why no Voice Input.** The engine (`stt/`) compiles whisper.cpp and links ALSA, which would +add `cmake`, a C++ toolchain and ALSA headers to every Linux build — a real cost on every +checkout for a feature the released Linux artifacts don't carry. The Tauri shell links a stub +instead (`dictation_stub` in `surfaces/gui/src-tauri/src/lib.rs`) and the mic button stays +disabled. Nothing else in the app is affected. Turning it on later is a small change: drop the +`cfg(not(target_os = "linux"))` guard on the `ocw-stt` dependency in `Cargo.toml`. + +## Build from source + +Prerequisites: Python 3.10+, Node 20+, the Rust toolchain via [rustup](https://rustup.rs/), and +the Tauri system libraries. + +```shell +# Debian 12 / Ubuntu 22.04+ (Crostini included) +sudo apt install libwebkit2gtk-4.1-dev libgtk-3-dev librsvg2-dev \ + libayatana-appindicator3-dev patchelf file \ + build-essential curl wget file python3-venv + +# Fedora +sudo dnf install webkit2gtk4.1-devel gtk3-devel librsvg2-devel patchelf +``` + +Then the same three steps as every other platform: + +```shell +git clone https://github.com/andrewyng/openworker +cd openworker + +bash packaging/setup_dev_env.sh # 1. Python venv at .venv + +.venv/bin/openworker-server --cwd ~/some/project --port 8765 # 2. the server + +cd surfaces/gui && npm install && npm run dev # 3. the UI +``` + +`npm run tauri dev` (from `surfaces/gui/`) runs the real desktop shell instead of the browser +UI — it launches the window and starts the server itself. + +### Installable packages + +```shell +bash packaging/build_linux.sh +``` + +Produces `.deb` and `.AppImage` under `surfaces/gui/src-tauri/target/release/bundle/`. It needs +the build-only Python deps in the venv first: + +```shell +.venv/bin/pip install -e '.[bedrock]' pyinstaller typer +``` + +Set `OCW_LINUX_BUNDLES` to change the formats (`deb`, `appimage`, `rpm`, comma-separated). + +**Build on the oldest distro you want to support.** The frozen sidecar and the Rust binary both +link the build machine's glibc, and glibc is forward- but not backward-compatible: artifacts +built on Ubuntu 24.04 (glibc 2.39) will not start on Debian 12 (glibc 2.36), which is what +Crostini runs. Release CI builds on ubuntu-22.04 (glibc 2.35) for that reason. + +## Troubleshooting + +**The window is blank / white.** WebKitGTK's DMABuf renderer against a virtualized or unusual +GPU. Crostini is handled automatically; elsewhere, run with +`WEBKIT_DISABLE_DMABUF_RENDERER=1 openworker` (or `WEBKIT_DISABLE_COMPOSITING_MODE=1`). + +**Nothing happens after the splash / "Starting coworker…" never finishes.** The sidecar failed +to start. Its log is at `~/.config/coworker/logs/openworker-server.log` (previous run: +`.log.old`). + +**"Choose folder" does nothing when running the browser UI.** The server-side folder picker +shells out to `zenity` or `kdialog`; with neither installed, paste the path into the workspace +field instead. `sudo apt install zenity` restores the dialog. The desktop app uses GTK's own +picker and is unaffected. + +**The app installs but doesn't appear in the launcher.** Log out and back in (or restart the +Crostini container: `sudo systemctl reboot` in the Linux terminal) — the desktop file is picked +up on session start. + +**Tools the agent installed aren't found.** A desktop-launched app doesn't inherit your shell's +`PATH`. The shell probes your login shell at startup and merges its environment in, and adds +the usual install dirs (`~/.local/bin`, `~/.cargo/bin`, `~/go/bin`, `/snap/bin`, Linuxbrew) on +top. If a tool still isn't visible, launch the app from a terminal once to confirm it's a `PATH` +problem rather than a missing install. diff --git a/packaging/build_linux.sh b/packaging/build_linux.sh new file mode 100755 index 0000000000..2244e0284f --- /dev/null +++ b/packaging/build_linux.sh @@ -0,0 +1,126 @@ +#!/usr/bin/env bash +# Build the Linux desktop app: a .deb and an .AppImage. +# +# 1. PyInstaller-bundle the server into a standalone onedir folder (no venv at runtime). +# 2. Stage it at binaries/sidecar/ for Tauri's `resources` slot. +# 3. `tauri build --bundles deb,appimage` → OpenWorker__.deb + .AppImage. +# +# Two formats because they answer different questions: +# .deb — what to install on Debian/Ubuntu and on ChromeOS Crostini (double-click in +# the Files app). Upgrades come from the package manager, so the in-app updater +# stays quiet (see `self_update_supported` in src-tauri/src/lib.rs). +# .AppImage — one portable file, no root, self-updating. Needs FUSE; where that is missing +# (Crostini included) it still runs via `--appimage-extract-and-run`. +# +# Prerequisites (mirrors build_dmg.sh's header): +# - Rust (rustup) + Node/npm, and the GUI deps installed (npm ci in surfaces/gui). +# - The Tauri system libraries. On Debian/Ubuntu: +# sudo apt install libwebkit2gtk-4.1-dev libgtk-3-dev librsvg2-dev \ +# libayatana-appindicator3-dev patchelf file +# - A Python venv at .venv (repo root) with this package installed editable, plus the +# build-only deps: +# python3 -m venv .venv +# .venv/bin/pip install -e '.[bedrock]' pyinstaller typer +# `typer` is needed only at BUILD time: PyInstaller walks the `mcp` package and +# `mcp.cli` calls sys.exit() at import if typer is absent, which aborts the freeze. +# +# GLIBC REACH: a frozen sidecar and a Rust binary both link the build machine's glibc, and +# glibc is forward- but not backward-compatible. Build on the OLDEST distro you intend to +# support — release CI uses ubuntu-22.04 (glibc 2.35) so the artifacts also run on Debian 12 +# (glibc 2.36), which is what Crostini ships. Building on Ubuntu 24.04 produces artifacts +# that will NOT start there. +# +# AUTO-UPDATE: the .AppImage updater artifacts (.AppImage.tar.gz + minisign .sig) are produced +# only when the updater signing key is available — from the env (CI secret +# TAURI_SIGNING_PRIVATE_KEY), or from `.ocw-updater.env` one directory above the repo (same +# convention as build_dmg.sh). Keyless builds skip them so fork/dev builds keep working. +# +# There is no code signing on Linux; nothing here is the equivalent of Apple notarization. +# +# Experimental (use-at-your-own-risk) connectors are EXCLUDED from this build by default — +# the spec strips coworker.connectors.experimental. Self-builders can opt in with: +# COWORKER_EXPERIMENTAL=1 ./build_linux.sh +# +# Bundle selection: OCW_LINUX_BUNDLES=deb ./build_linux.sh (default "deb,appimage"; "rpm" also +# works — Tauri's rpm bundler is pure Rust and needs no rpmbuild). +set -euo pipefail + +HERE="$(cd "$(dirname "$0")" && pwd)" +PLATFORM="$(cd "$HERE/.." && pwd)" +GUI="$PLATFORM/surfaces/gui" +# Single source of truth for the version: tauri.conf.json (also stamps the bundle). +VERSION="$(node -p "require('$GUI/src-tauri/tauri.conf.json').version")" +TRIPLE="$(rustc -vV | sed -n 's/host: //p')" # e.g. x86_64-unknown-linux-gnu +BUNDLES="${OCW_LINUX_BUNDLES:-deb,appimage}" + +echo "==> [0/4] checking the Tauri system libraries" +# Fail here with the apt line rather than 400 lines into a cargo build with a pkg-config error. +MISSING=() +for pc in webkit2gtk-4.1 gtk+-3.0; do + pkg-config --exists "$pc" || MISSING+=("$pc") +done +if [ ${#MISSING[@]} -gt 0 ]; then + echo "ERROR: missing development libraries: ${MISSING[*]}" >&2 + echo " Debian/Ubuntu: sudo apt install libwebkit2gtk-4.1-dev libgtk-3-dev librsvg2-dev \\" >&2 + echo " libayatana-appindicator3-dev patchelf file" >&2 + echo " Fedora: sudo dnf install webkit2gtk4.1-devel gtk3-devel librsvg2-devel patchelf" >&2 + exit 1 +fi +# The AppImage bundler shells out to both; a missing one fails deep inside the bundler. +if [[ ",$BUNDLES," == *",appimage,"* ]]; then + for tool in patchelf file; do + command -v "$tool" >/dev/null || { + echo "ERROR: '$tool' is required for the AppImage bundle (sudo apt install $tool)" >&2 + exit 1 + } + done +fi + +echo "==> [1/4] PyInstaller: bundling openworker-server ($TRIPLE)" +"$PLATFORM/.venv/bin/pyinstaller" --noconfirm --clean \ + --distpath "$HERE/dist" --workpath "$HERE/build" "$HERE/openworker-server.spec" + +echo "==> [2/4] staging sidecar resources" +# Onedir bundle (exe + _internal/) ships via Tauri `resources`, landing next to the app binary +# in /usr/lib/OpenWorker/ (.deb) or inside the AppImage's AppDir. rm -rf first: cp WRITES +# THROUGH a symlink at the destination, and this also clears any stale bundle from an earlier +# run whose file set differed. +mkdir -p "$GUI/src-tauri/binaries" +rm -rf "$GUI/src-tauri/binaries/sidecar" +# -L (dereference), same as build_dmg.sh: Tauri's resource bundler flattens symlinks into +# duplicate real files anyway, so resolving them here means what we test is what ships. +cp -RL "$HERE/dist/openworker-server" "$GUI/src-tauri/binaries/sidecar" +chmod +x "$GUI/src-tauri/binaries/sidecar/openworker-server" + +echo "==> [3/4] tauri build ($BUNDLES)" +UPDATER_ENV="${OCW_UPDATER_ENV:-$PLATFORM/../.ocw-updater.env}" +if [ -z "${TAURI_SIGNING_PRIVATE_KEY:-}" ] && [ -f "$UPDATER_ENV" ]; then + # shellcheck disable=SC1090 + source "$UPDATER_ENV" +fi +UPDATER_OVERLAY=() +if [ -n "${TAURI_SIGNING_PRIVATE_KEY:-}" ]; then + UPDATER_OVERLAY=(--config '{"bundle":{"createUpdaterArtifacts":true}}') +else + echo " WARNING: no updater signing key — building WITHOUT auto-update artifacts (not releasable)." +fi +# NO_STRIP: linuxdeploy strips the binaries it processes by default. A stripped PyInstaller +# executable can no longer locate its embedded archive, which fails as an app that opens +# normally and never brings its server up — so keep this set rather than find out. +( cd "$GUI" && NO_STRIP=true npm run tauri build -- --bundles "$BUNDLES" \ + ${UPDATER_OVERLAY[@]+"${UPDATER_OVERLAY[@]}"} ) + +echo "==> [4/4] artifacts" +BUNDLE="$GUI/src-tauri/target/release/bundle" +found=0 +for f in "$BUNDLE"/deb/*.deb "$BUNDLE"/appimage/*.AppImage "$BUNDLE"/rpm/*.rpm; do + [ -e "$f" ] || continue + found=1 + printf ' %s (%s)\n' "$f" "$(du -h "$f" | cut -f1)" +done +[ "$found" = 1 ] || { echo "ERROR: tauri produced no Linux bundles" >&2; exit 1; } + +echo "" +echo "Done → OpenWorker $VERSION ($TRIPLE)" +echo " install the .deb: sudo apt install $BUNDLE/deb/*.deb" +echo " run the AppImage: chmod +x .AppImage && ./.AppImage" diff --git a/packaging/make_update_manifest.py b/packaging/make_update_manifest.py index 84c4f2951c..cd8b60341d 100644 --- a/packaging/make_update_manifest.py +++ b/packaging/make_update_manifest.py @@ -9,9 +9,16 @@ Looks for the updater artifacts by their STABLE names (the same names release.yml uploads): - OpenWorker-macos-arm64.app.tar.gz(.sig) -> platforms["darwin-aarch64"] - OpenWorker-macos-x64.app.tar.gz(.sig) -> platforms["darwin-x86_64"] - OpenWorker-windows-setup.exe(.sig) -> platforms["windows-x86_64"] + OpenWorker-macos-arm64.app.tar.gz(.sig) -> platforms["darwin-aarch64"] + OpenWorker-macos-x64.app.tar.gz(.sig) -> platforms["darwin-x86_64"] + OpenWorker-windows-setup.exe(.sig) -> platforms["windows-x86_64"] + OpenWorker-linux-x64.AppImage.tar.gz(.sig) -> platforms["linux-x86_64"] + OpenWorker-linux-arm64.AppImage.tar.gz(.sig) -> platforms["linux-aarch64"] + +On Linux only the AppImage self-updates — Tauri rewrites the file $APPIMAGE points at, and a +.deb has no such file (its paths belong to the package manager). The .deb is therefore absent +from this manifest ON PURPOSE, and the app never offers those installs an update; see +`self_update_supported` in surfaces/gui/src-tauri/src/lib.rs. URLs point at the TAG-pinned GitHub download path (releases/download//), never at `latest/` — a manifest must reference exactly the artifacts it shipped with, @@ -37,6 +44,8 @@ "OpenWorker-macos-arm64.app.tar.gz": "darwin-aarch64", "OpenWorker-macos-x64.app.tar.gz": "darwin-x86_64", "OpenWorker-windows-setup.exe": "windows-x86_64", + "OpenWorker-linux-x64.AppImage.tar.gz": "linux-x86_64", + "OpenWorker-linux-arm64.AppImage.tar.gz": "linux-aarch64", } diff --git a/surfaces/gui/src-tauri/Cargo.toml b/surfaces/gui/src-tauri/Cargo.toml index e8e721abc9..6dfac7cf3e 100644 --- a/surfaces/gui/src-tauri/Cargo.toml +++ b/surfaces/gui/src-tauri/Cargo.toml @@ -21,5 +21,13 @@ tauri-plugin-updater = "2" serde = { version = "1", features = ["derive"] } serde_json = "1" uuid = { version = "1", features = ["v4"] } -# Kept outside the Tauri shell so another product can depend on the same local STT engine. + +# Local speech-to-text. Kept outside the Tauri shell so another product can depend on the same +# engine — and kept OFF Linux, where it is replaced by the `dictation_stub` module in src/lib.rs. +# ocw-stt pulls in whisper-rs (compiles whisper.cpp: cmake + a C++ toolchain) and cpal (ALSA +# headers). On macOS/Windows those come with the platform SDK; on Linux they would be two extra +# apt packages and minutes of build time on every checkout, for a feature the Linux build does +# not offer. Target-scoped rather than a cargo feature so `tauri dev` and `tauri build` need no +# extra flags on Linux — one clone, one command, on every platform. +[target.'cfg(not(target_os = "linux"))'.dependencies] ocw-stt = { path = "../../../stt" } diff --git a/surfaces/gui/src-tauri/src/lib.rs b/surfaces/gui/src-tauri/src/lib.rs index d96c3c6f4c..d50d210ad9 100644 --- a/surfaces/gui/src-tauri/src/lib.rs +++ b/surfaces/gui/src-tauri/src/lib.rs @@ -8,7 +8,14 @@ //! 3. lives in the system tray: closing the window hides it (keeps MyHelper + the scheduler //! running); only tray → Quit stops the sidecar; //! 4. exposes native commands: folder picker, autostart (open-at-login), and keep-awake -//! (caffeinate, so scheduled tasks fire while the Mac is idle). +//! (caffeinate on macOS, SetThreadExecutionState on Windows, systemd-inhibit on Linux, +//! so scheduled tasks fire while the machine is idle). +//! +//! Platform notes. macOS and Windows always have a status area, so closing the window hides it +//! to the tray. Linux does not: plenty of sessions (ChromeOS Crostini among them) run no +//! StatusNotifier host at all, and there `TrayIconBuilder::build` fails. Tray creation is +//! therefore non-fatal, and when it fails, closing the window really closes the app — hiding +//! to a tray that isn't there would strand the user with a running, unreachable sidecar. //! //! The sidecar inherits this process's environment, so a shell-launched `npm run tauri dev` //! passes `OPENAI_API_KEY` through. A Finder-launched app has no shell env — there the key @@ -16,10 +23,17 @@ use std::path::PathBuf; use std::process::{Child, Command, Stdio}; -#[cfg(target_os = "windows")] use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex}; +// Voice Input ships on macOS and Windows only. On Linux the engine is swapped for the stub +// in `dictation_stub` below: linking ocw-stt would drag whisper.cpp (cmake + a C++ toolchain) +// and ALSA headers into every Linux build — a heavy build-time tax for a feature the released +// .deb/AppImage don't offer. The Tauri commands are identical either way; the stub simply +// reports "not installed" and refuses to record. +#[cfg(target_os = "linux")] +use dictation_stub::{Dictation, DownloadProgress}; +#[cfg(not(target_os = "linux"))] use ocw_stt::{Dictation, DownloadProgress}; use serde::Serialize; use tauri::{ @@ -56,6 +70,15 @@ const KNOWN_TOOL_DIRS: &[&str] = &[ "/usr/local/bin", // Intel Homebrew, most installers "/usr/local/sbin", "/opt/local/bin", // MacPorts + "/home/linuxbrew/.linuxbrew/bin", // Linuxbrew + "/snap/bin", // snap + // Home-relative (expanded against $HOME below). Debian/Ubuntu only put ~/.local/bin on + // PATH from ~/.profile *if it already existed at login*, so a pipx/pip --user install + // made after that login is invisible to a desktop-launched app without this. + "~/.local/bin", + "~/.cargo/bin", + "~/go/bin", + "~/.deno/bin", ]; /// The environment the sidecar should run with (OPE-83). @@ -90,7 +113,14 @@ fn sidecar_env() -> std::collections::HashMap { return out; } - let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/zsh".to_string()); + // Fall back to the platform's own default login shell — zsh on macOS, bash on Linux + // (where /bin/zsh usually does not exist, and spawning it would skip the probe entirely). + let default_shell = if cfg!(target_os = "macos") { + "/bin/zsh" + } else { + "/bin/bash" + }; + let shell = std::env::var("SHELL").unwrap_or_else(|_| default_shell.to_string()); let script = format!("echo {START}; env; echo {END}"); let spawned = Command::new(&shell) .args(["-ilc", &script]) @@ -152,9 +182,21 @@ fn sidecar_env() -> std::collections::HashMap { .or_else(|| std::env::var("PATH").ok()) .unwrap_or_default(); let mut parts: Vec = base.split(':').filter(|s| !s.is_empty()).map(String::from).collect(); + let home = out + .get("HOME") + .cloned() + .or_else(|| std::env::var("HOME").ok()) + .unwrap_or_default(); for dir in KNOWN_TOOL_DIRS { - if !parts.iter().any(|p| p == dir) && std::path::Path::new(dir).is_dir() { - parts.push((*dir).to_string()); + // `~/...` entries resolve against the user's home; with no HOME to resolve them + // against they are skipped rather than added as a literal "~" path. + let resolved = match dir.strip_prefix("~/") { + Some(rest) if !home.is_empty() => format!("{home}/{rest}"), + Some(_) => continue, + None => (*dir).to_string(), + }; + if !parts.iter().any(|p| *p == resolved) && std::path::Path::new(&resolved).is_dir() { + parts.push(resolved); } } out.insert("PATH".to_string(), parts.join(":")); @@ -169,14 +211,19 @@ fn sidecar_env() -> std::collections::HashMap { /// Path to the server entrypoint. Resolution order: /// 1. `COWORKER_SERVER_BIN` env override. -/// 2. The bundled onedir sidecar shipped via Tauri `resources` (production): the -/// `sidecar/` folder lands in Contents/Resources on macOS and in the install dir -/// (next to the app exe) on Windows. -/// 3. Legacy onefile slot: `openworker-server[.exe]` next to the app binary (pre-onedir +/// 2. Tauri's own resource directory — `resource_dir` is what the bundler actually +/// populated on this platform, so it is right by construction. This is the only +/// candidate that finds the sidecar in a Linux .deb/.AppImage, where the binary lands +/// in `/usr/bin/` and its resources in `/usr/lib/OpenWorker/` — nothing the exe-relative +/// guesses below would ever reach. +/// 3. Exe-relative guesses, kept as-is for the layouts they already serve: the `sidecar/` +/// folder lands in Contents/Resources on macOS and in the install dir (next to the app +/// exe) on Windows. +/// 4. Legacy onefile slot: `openworker-server[.exe]` next to the app binary (pre-onedir /// builds used Tauri externalBin). -/// 4. Dev fallback: the repo venv, relative to this crate (`src-tauri` → repo-root `.venv`; +/// 5. Dev fallback: the repo venv, relative to this crate (`src-tauri` → repo-root `.venv`; /// `bin/` on POSIX, `Scripts\` on Windows). -fn server_bin() -> PathBuf { +fn server_bin(resource_dir: Option) -> PathBuf { if let Ok(p) = std::env::var("COWORKER_SERVER_BIN") { return PathBuf::from(p); } @@ -185,20 +232,22 @@ fn server_bin() -> PathBuf { } else { "openworker-server" }; + let mut candidates: Vec = Vec::new(); + if let Some(res) = resource_dir { + candidates.push(res.join("sidecar").join(exe_name)); + } if let Ok(exe) = std::env::current_exe() { if let Some(dir) = exe.parent() { - // macOS: Contents/MacOS/ → Contents/Resources/sidecar/; Windows: resources - // unpack next to the exe, so /sidecar/. - let mut candidates = vec![dir.join("sidecar").join(exe_name)]; + candidates.push(dir.join("sidecar").join(exe_name)); if let Some(contents) = dir.parent() { candidates.push(contents.join("Resources").join("sidecar").join(exe_name)); } candidates.push(dir.join(exe_name)); // legacy onefile externalBin slot - for c in candidates { - if c.exists() { - return c; - } - } + } + } + for c in candidates { + if c.exists() { + return c; } } let mut p = PathBuf::from(env!("CARGO_MANIFEST_DIR")); @@ -266,7 +315,9 @@ fn write_keep_awake_pref(enabled: bool) { // Cross-platform behind a uniform `start_keep_awake() -> Option`; dropping the // guard releases the hold. macOS uses the built-in `caffeinate`; Windows uses the // SetThreadExecutionState API (a dedicated thread holds ES_CONTINUOUS so the state survives -// regardless of which Tauri worker thread toggled it); other platforms are a no-op. +// regardless of which Tauri worker thread toggled it); Linux and the other unixes use +// `systemd-inhibit`. None → no inhibitor on this system, and the caller reports the toggle +// as off rather than claiming a hold it never took. #[cfg(target_os = "macos")] struct KeepAwakeGuard(Child); @@ -335,13 +386,51 @@ fn start_keep_awake() -> Option { } #[cfg(not(any(target_os = "macos", target_os = "windows")))] -struct KeepAwakeGuard; +struct KeepAwakeGuard(Child); + +#[cfg(not(any(target_os = "macos", target_os = "windows")))] +impl Drop for KeepAwakeGuard { + fn drop(&mut self) { + // Closing the pipe — not killing the process — is what releases the lock: `cat` reads + // EOF and exits, and systemd-inhibit drops the inhibitor as it reaps it. Killing + // systemd-inhibit instead would leave its `cat` child orphaned to init forever, and a + // hard-killed app releases the lock for free this way (our pipe end closes with us). + drop(self.0.stdin.take()); + for _ in 0..50 { + match self.0.try_wait() { + Ok(Some(_)) => return, + Ok(None) => std::thread::sleep(std::time::Duration::from_millis(10)), + Err(_) => break, + } + } + // Backstop: a wedged inhibitor must never hold the UI thread longer than this. + let _ = self.0.kill(); + let _ = self.0.wait(); + } +} #[cfg(not(any(target_os = "macos", target_os = "windows")))] fn start_keep_awake() -> Option { - // No portable built-in inhibitor on Linux; keep-awake is a no-op (the toggle still reflects - // state so the UI behaves, but the OS sleep policy is left to the user). - Some(KeepAwakeGuard) + // logind's inhibitor lock, held for exactly as long as the command it runs. `cat` with a + // piped stdin is the hold (see Drop). Missing systemd-inhibit (no logind, a container, a + // non-systemd distro) → None, and the Settings toggle stays off instead of lying. + // + // ChromeOS Crostini caveat: the lock is real inside the VM, but ChromeOS itself decides + // when the device suspends, and a suspended Chromebook stops the VM regardless. + Command::new("systemd-inhibit") + .args([ + "--what=idle:sleep", + "--who=OpenWorker", + "--why=Scheduled coworker runs", + "--mode=block", + "cat", + ]) + .stdin(Stdio::piped()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .ok() + .map(KeepAwakeGuard) } // -- native commands (invoked from the SPA via window.__TAURI__.core.invoke) ----------------- @@ -396,6 +485,93 @@ fn start_window_drag(window: tauri::WebviewWindow) -> bool { window.start_dragging().is_ok() } +/// Linux stand-in for the `ocw-stt` engine (see the import at the top of this file). +/// +/// It mirrors exactly the API surface the commands below touch, so there is ONE set of Tauri +/// commands on every platform — no per-target `#[cfg]` on twelve `#[tauri::command]`s, and no +/// way for the two paths to drift. Every call answers the way an engine with no model installed +/// would, and `voice_input_compatibility()` already reports `supported: false` on Linux, so the +/// GUI dims the mic button before any of this is reachable. +#[cfg(target_os = "linux")] +mod dictation_stub { + use serde::Serialize; + use std::path::PathBuf; + + const UNSUPPORTED: &str = "Voice Input is not available in the Linux build of OpenWorker."; + + #[derive(Debug, Clone, Serialize)] + pub struct DictationStatus { + pub recording: bool, + pub model_installed: bool, + pub model_verified: bool, + pub test_passed: bool, + pub download_in_progress: bool, + pub model_name: &'static str, + pub model_bytes: u64, + } + + #[derive(Debug, Clone, Copy, Serialize)] + pub struct DownloadProgress { + pub downloaded_bytes: u64, + pub total_bytes: u64, + } + + pub struct Dictation; + + impl Dictation { + pub fn new(_model_dir: impl Into) -> Self { + Self + } + + pub fn status(&self) -> DictationStatus { + DictationStatus { + recording: false, + model_installed: false, + model_verified: false, + test_passed: false, + download_in_progress: false, + model_name: "none", + model_bytes: 0, + } + } + + pub fn install_default_model_with_progress( + &self, + _on_progress: impl FnMut(DownloadProgress), + ) -> Result<(), String> { + Err(UNSUPPORTED.to_owned()) + } + + pub fn verify_default_model(&self) -> Result<(), String> { + Err(UNSUPPORTED.to_owned()) + } + + pub fn mark_test_passed(&self) -> Result<(), String> { + Err(UNSUPPORTED.to_owned()) + } + + pub fn delete_default_model(&self) -> Result<(), String> { + Err(UNSUPPORTED.to_owned()) + } + + pub fn start(&self) -> Result<(), String> { + Err(UNSUPPORTED.to_owned()) + } + + pub fn stop_and_transcribe(&self) -> Result { + Err(UNSUPPORTED.to_owned()) + } + + pub fn cancel_model_download(&self) {} + + pub fn cancel(&self) {} + + pub fn input_level(&self) -> f32 { + 0.0 + } + } +} + // -- local dictation --------------------------------------------------------------------------- // The actual microphone/model code lives in the Tauri-free `ocw-stt` crate. This shell owns the // macOS permission prompt and translates the reusable API into React-friendly Tauri commands. @@ -491,6 +667,8 @@ fn voice_input_compatibility() -> (bool, String, Option) { #[cfg(not(any(target_os = "macos", target_os = "windows")))] fn voice_input_compatibility() -> (bool, String, Option) { + // Not a runtime capability check: the Linux build links the `dictation_stub` engine, so + // there is nothing to be compatible WITH. Everything else in the app works the same. ( false, format!("{} · {}", std::env::consts::OS, std::env::consts::ARCH), @@ -615,9 +793,39 @@ struct UpdateInfo { notes: String, } +/// Whether this install can replace itself in place. +/// +/// macOS and Windows always can (the .app is swapped; the NSIS installer relaunches). On Linux +/// only an AppImage can: Tauri's Linux updater rewrites the file `$APPIMAGE` points at, and a +/// .deb has no such file to rewrite — the system package manager owns those paths, and writing +/// into them behind its back is how you get a half-upgraded install. So a .deb never sees an +/// update prompt; `apt`/the download page is its upgrade path (docs/linux.md). +fn self_update_supported() -> bool { + if cfg!(target_os = "linux") { + std::env::var_os("APPIMAGE").is_some() + } else { + true + } +} + +const NO_SELF_UPDATE: &str = + "This build updates through your package manager, not from inside the app."; + +/// Asked once by Settings so it can drop its "Check for updates" button where checking is +/// meaningless. Without this the button would answer "You're on the latest version" to a .deb +/// install — a claim it has no way to make, since it never looked. +#[tauri::command] +fn can_self_update() -> bool { + self_update_supported() +} + #[tauri::command] async fn check_for_update(app: tauri::AppHandle) -> Result, String> { use tauri_plugin_updater::UpdaterExt; + // No prompt where accepting it could not work — see self_update_supported(). + if !self_update_supported() { + return Ok(None); + } let updater = app.updater().map_err(|e| e.to_string())?; let update = updater.check().await.map_err(|e| e.to_string())?; Ok(update.map(|u| UpdateInfo { @@ -637,6 +845,9 @@ async fn download_update( pending: tauri::State<'_, PendingUpdate>, ) -> Result<(), String> { use tauri_plugin_updater::UpdaterExt; + if !self_update_supported() { + return Err(NO_SELF_UPDATE.to_owned()); + } let updater = app.updater().map_err(|e| e.to_string())?; let Some(update) = updater.check().await.map_err(|e| e.to_string())? else { return Err("no update available".into()); @@ -671,6 +882,9 @@ async fn install_update( pending: tauri::State<'_, PendingUpdate>, ) -> Result<(), String> { use tauri_plugin_updater::UpdaterExt; + if !self_update_supported() { + return Err(NO_SELF_UPDATE.to_owned()); + } let updater = app.updater().map_err(|e| e.to_string())?; let Some(update) = updater.check().await.map_err(|e| e.to_string())? else { return Err("no update available".into()); @@ -692,12 +906,36 @@ async fn install_update( .map_err(|e| e.to_string())?, } // Windows never reaches here (the NSIS installer takes over and relaunches). - // macOS: the .app was swapped in place — restart into the new version. The tray - // Exit path's sidecar kill runs via RunEvent, so no orphaned openworker-server. + // macOS: the .app was swapped in place — restart into the new version. Linux: same, for + // the AppImage. The tray Exit path's sidecar kill runs via RunEvent, so no orphaned + // openworker-server. app.restart(); } +/// ChromeOS Crostini: the container reaches the GPU through virtio-gpu and Sommelier, and +/// WebKitGTK's DMABuf renderer (its default since 2.42) renders a BLANK WHITE WINDOW there — +/// the app looks hung on first launch with nothing in the log to explain it. The older +/// renderer costs nothing in a VM with no direct GPU access anyway. +/// +/// Deliberately narrow: only on Crostini (both markers are ChromeOS-only), and only when the +/// user has not set the variable themselves. A normal Linux desktop keeps the fast path. +/// Must run before the webview exists, hence the top of `run()`. +#[cfg(target_os = "linux")] +fn apply_crostini_workarounds() { + if std::env::var_os("WEBKIT_DISABLE_DMABUF_RENDERER").is_some() { + return; + } + let crostini = std::path::Path::new("/dev/.cros_milestone").exists() + || std::path::Path::new("/opt/google/cros-containers").is_dir(); + if crostini { + std::env::set_var("WEBKIT_DISABLE_DMABUF_RENDERER", "1"); + } +} + pub fn run() { + #[cfg(target_os = "linux")] + apply_crostini_workarounds(); + let port = free_port(); let api_token = launch_token(); let http = format!("http://127.0.0.1:{port}"); @@ -739,6 +977,7 @@ pub fn run() { mark_dictation_test_passed, delete_dictation_model, dictation_level, + can_self_update, check_for_update, download_update, clear_pending_update, @@ -746,7 +985,7 @@ pub fn run() { ]) .setup(move |app| { // 1. Start the Python server sidecar on the chosen port (inherits our env). - let mut server_cmd = Command::new(server_bin()); + let mut server_cmd = Command::new(server_bin(app.path().resource_dir().ok())); server_cmd .args(["--host", "127.0.0.1", "--port", &port.to_string()]) // The user's real shell environment (PATH to their tools, AWS_PROFILE, @@ -812,6 +1051,9 @@ pub fn run() { // 2. Build the window, injecting the sidecar endpoints before the SPA loads. // Overlay title bar (macOS): traffic lights float over the edge-to-edge UI. + // Only the macOS block below reassigns `builder`, so everywhere else the `mut` + // is dead — allow it there rather than duplicating the whole builder chain. + #[cfg_attr(not(target_os = "macos"), allow(unused_mut))] let mut builder = WebviewWindowBuilder::new(app, "main", WebviewUrl::App("index.html".into())) .title("OpenWorker") @@ -836,12 +1078,21 @@ pub fn run() { } let win = builder.build()?; - // Close-to-tray: hide instead of quitting so the sidecar keeps running. + // Close-to-tray: hide instead of quitting so the sidecar keeps running — but ONLY + // once we know there is a tray to close TO. Set by step 3 below; the flag is read + // at close time, long after setup has finished. + let has_tray = Arc::new(AtomicBool::new(false)); let w = win.clone(); + let close_has_tray = has_tray.clone(); win.on_window_event(move |event| { if let WindowEvent::CloseRequested { api, .. } = event { - let _ = w.hide(); - api.prevent_close(); + if close_has_tray.load(Ordering::SeqCst) { + let _ = w.hide(); + api.prevent_close(); + } + // No tray: let the close through and let the app exit. Hiding would strand + // the user with an invisible window and a running sidecar, reachable only + // by killing the process. } }); @@ -854,7 +1105,7 @@ pub fn run() { // A monochrome template icon (black + alpha, raw RGBA 44×44) so the menu bar tints // it for light/dark automatically — not the full-color app icon. let tray_icon = tauri::image::Image::new(include_bytes!("../icons/tray.rgba"), 44, 44); - TrayIconBuilder::new() + let tray = TrayIconBuilder::new() .tooltip("OpenWorker") .icon(tray_icon) .icon_as_template(true) @@ -872,7 +1123,19 @@ pub fn run() { "quit" => app.exit(0), _ => {} }) - .build(app)?; + .build(app); + // NOT fatal, and this used to be `?`. macOS and Windows always have a status area, + // but many Linux sessions run no StatusNotifier/AppIndicator host — ChromeOS + // Crostini has no status area at all — and there this call fails. Propagating the + // error aborts `setup`, so the whole app would refuse to start over a tray icon. + // Instead: log it, leave has_tray false, and the window close handler above turns + // close-to-tray back into a plain quit. + match tray { + Ok(_) => has_tray.store(true, Ordering::SeqCst), + Err(e) => eprintln!( + "[coworker] no system tray on this desktop ({e}) — closing the window will quit" + ), + } Ok(()) }) @@ -894,3 +1157,71 @@ pub fn run() { } }); } + + +#[cfg(test)] +mod tests { + use super::*; + + /// `COWORKER_SERVER_BIN` short-circuits resolution, so these tests are only meaningful + /// when it is unset — which it is everywhere except a developer's overridden shell. + fn env_override_set() -> bool { + std::env::var_os("COWORKER_SERVER_BIN").is_some() + } + + /// The Linux .deb/.AppImage layout — binary in `/usr/bin/OpenWorker`, its resources in + /// `/usr/lib/OpenWorker/` — is reachable ONLY through Tauri's resource dir. Every + /// exe-relative guess (`/sidecar`, `/../Resources/sidecar`) misses it, + /// so before the resource-dir candidate existed the packaged Linux app fell all the way + /// through to the dev-venv path and never started its server. + #[test] + fn server_bin_prefers_the_resource_dir() { + if env_override_set() { + return; + } + let root = std::env::temp_dir().join(format!("ocw-sidecar-{}", Uuid::new_v4().simple())); + let dir = root.join("sidecar"); + std::fs::create_dir_all(&dir).expect("temp sidecar dir"); + let exe = dir.join(if cfg!(windows) { + "openworker-server.exe" + } else { + "openworker-server" + }); + std::fs::write(&exe, b"").expect("temp sidecar binary"); + + assert_eq!(server_bin(Some(root.clone())), exe); + + let _ = std::fs::remove_dir_all(&root); + } + + /// A resource dir that holds no sidecar must not win by existing: resolution carries on to + /// the exe-relative slots and finally the dev venv, which is what `npm run tauri dev` uses. + #[test] + fn server_bin_falls_through_an_empty_resource_dir() { + if env_override_set() { + return; + } + let empty = std::env::temp_dir().join(format!("ocw-empty-{}", Uuid::new_v4().simple())); + std::fs::create_dir_all(&empty).expect("temp dir"); + + let resolved = server_bin(Some(empty.clone())); + assert!( + resolved.to_string_lossy().contains(".venv"), + "expected the dev venv fallback, got {}", + resolved.display() + ); + + let _ = std::fs::remove_dir_all(&empty); + } + + /// Voice Input is compiled out on Linux, and the GUI gates the mic button on this flag — + /// so it must stay false there however the stub engine answers. + #[test] + fn voice_input_is_unsupported_on_linux() { + let (supported, _summary, reason) = voice_input_compatibility(); + if cfg!(target_os = "linux") { + assert!(!supported); + assert!(reason.is_some(), "an unsupported platform must say why"); + } + } +} diff --git a/surfaces/gui/src-tauri/tauri.conf.json b/surfaces/gui/src-tauri/tauri.conf.json index b16419f967..fef09cf8a9 100644 --- a/surfaces/gui/src-tauri/tauri.conf.json +++ b/surfaces/gui/src-tauri/tauri.conf.json @@ -20,6 +20,10 @@ "active": true, "targets": "all", "publisher": "OpenWorker", + "shortDescription": "AI that gets your everyday tasks done", + "longDescription": "OpenWorker is an open-source AI coworker that runs on your own machine and delivers finished work, not just chat. It works across your files, terminal and connected apps, checks in before anything consequential, and uses the model provider you choose.", + "homepage": "https://openworker.com", + "license": "MIT", "icon": [ "icons/32x32.png", "icons/128x128.png", @@ -30,10 +34,18 @@ "resources": { "binaries/sidecar": "sidecar" }, + "category": "Productivity", "macOS": { "entitlements": "entitlements.plist", "minimumSystemVersion": "12.0" }, + "linux": { + "deb": { + "recommends": ["xdg-utils", "zenity"], + "section": "utils", + "priority": "optional" + } + }, "windows": { "webviewInstallMode": { "type": "downloadBootstrapper" diff --git a/surfaces/gui/src/components/SettingsView.tsx b/surfaces/gui/src/components/SettingsView.tsx index 000dc6584c..3401493650 100644 --- a/surfaces/gui/src/components/SettingsView.tsx +++ b/surfaces/gui/src/components/SettingsView.tsx @@ -23,6 +23,7 @@ import { getAutostart, getDictationStatus, getKeepAwake, + canSelfUpdate, checkForUpdate, installUpdate, isTauri, @@ -398,6 +399,10 @@ function AppearanceSection() { const [theme, setTheme] = useThemePref(); const [autostart, setAuto] = useState(false); const [keepAwake, setKeep] = useState(false); + // The shell reports back whether it actually took a hold. It can't everywhere — a Linux + // session with no systemd-inhibit, ChromeOS Crostini (the host decides when it sleeps) — + // and there the checkbox silently springs back, which reads as a broken toggle. Say why. + const [keepAwakeUnavailable, setKeepUnavailable] = useState(false); const desktop = isTauri(); useEffect(() => { @@ -408,7 +413,11 @@ function AppearanceSection() { }, []); const toggleAuto = async (v: boolean) => setAuto(!!(await setAutostart(v))); - const toggleKeep = async (v: boolean) => setKeep(!!(await setKeepAwake(v))); + const toggleKeep = async (v: boolean) => { + const on = !!(await setKeepAwake(v)); + setKeep(on); + setKeepUnavailable(v && !on); + }; const runSetupAgain = async () => { await setOnboarded(false); window.dispatchEvent(new CustomEvent("coworker:open-onboarding")); @@ -455,6 +464,12 @@ function AppearanceSection() { Keep this system awake Prevent idle sleep so scheduled tasks fire on time. + {keepAwakeUnavailable && ( + + This system doesn't offer a sleep inhibitor OpenWorker can hold, so sleep stays + under your OS's control. + + )} @@ -535,6 +550,15 @@ function TrustedWorkspacesCard() { function UpdateInline() { const [state, setState] = useState<"idle" | "checking" | "none" | "found" | "installing" | "error">("idle"); const [version, setVersion] = useState(""); + // A .deb install can't replace its own files — the package manager owns them. Checking there + // would only ever answer "you're on the latest version", which is a guess, so ask the shell + // first and say what's actually true instead. Defaults to true: macOS, Windows and the + // AppImage all self-update, and nothing should flicker while the answer is in flight. + const [selfUpdates, setSelfUpdates] = useState(true); + + useEffect(() => { + canSelfUpdate().then((v) => setSelfUpdates(v !== false)); + }, []); const check = async () => { setState("checking"); @@ -560,6 +584,14 @@ function UpdateInline() { } }; + if (!selfUpdates) { + return ( + + This install updates through your package manager. + + ); + } + return ( {state === "found" ? ( diff --git a/surfaces/gui/src/tauri.ts b/surfaces/gui/src/tauri.ts index 95f3ccdba0..dc39a26347 100644 --- a/surfaces/gui/src/tauri.ts +++ b/surfaces/gui/src/tauri.ts @@ -70,10 +70,16 @@ export const getAutostart = () => invoke("get_autostart"); export const setAutostart = (enabled: boolean) => invoke("set_autostart", { enabled }); /** Keep this system awake so scheduled tasks fire while idle (caffeinate on macOS, - * SetThreadExecutionState on Windows). Persists across restarts. */ + * SetThreadExecutionState on Windows, systemd-inhibit on Linux). Persists across restarts. + * setKeepAwake returns whether the hold was actually taken — false where the system offers + * no inhibitor the app can hold. */ export const getKeepAwake = () => invoke("get_keep_awake"); export const setKeepAwake = (enabled: boolean) => invoke("set_keep_awake", { enabled }); +/** False where the app can't replace itself (a Linux .deb — its files belong to the package + * manager), so Settings can say that instead of checking. null in the browser build. */ +export const canSelfUpdate = () => invoke("can_self_update"); + /** Begin native window dragging from a custom title/header region. */ export const startWindowDrag = () => invoke("start_window_drag"); diff --git a/tests/test_server.py b/tests/test_server.py index fcefbc3cdc..14818bd13a 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -1005,6 +1005,52 @@ def boom(*a, **k): assert out["ok"] is False and "picker" in out["error"] +def test_pick_native_folder_linux_dialog_fallbacks(tmp_path, monkeypatch): + """Linux ships no guaranteed folder dialog — a minimal desktop (ChromeOS Crostini's + container among them) has none — so the picker tries zenity, then kdialog, then qarma and + stops at the first one actually installed. A cancel from the dialog that DID open ends it: + a second window appearing after the user dismissed one is worse than no dialog at all.""" + import subprocess + import sys + from types import SimpleNamespace + + client = _client(tmp_path, []) + mgr = client.app.state.manager + monkeypatch.setattr(sys, "platform", "linux") + + tried: list[str] = [] + + def only(installed, *, returncode=0, stdout="/tmp/picked\n"): + """A subprocess.run stand-in where exactly one dialog binary exists.""" + + def run(cmd, *a, **k): + tried.append(cmd[0]) + if cmd[0] != installed: + raise FileNotFoundError(cmd[0]) + return SimpleNamespace(returncode=returncode, stdout=stdout, stderr="") + + return run + + # zenity absent, kdialog present: kdialog answers and qarma is never reached. + monkeypatch.setattr(subprocess, "run", only("kdialog")) + assert mgr.pick_native_folder() == {"ok": True, "path": "/tmp/picked"} + assert tried == ["zenity", "kdialog"] + + # A cancel from the first installed dialog is final. + tried.clear() + monkeypatch.setattr(subprocess, "run", only("zenity", returncode=1, stdout="")) + assert mgr.pick_native_folder() == {"ok": False, "canceled": True} + assert tried == ["zenity"] + + # None installed: every candidate is tried, then a clean error — never an exception, + # because the GUI's paste-a-path field is still a working way in. + tried.clear() + monkeypatch.setattr(subprocess, "run", only("none-of-them")) + out = mgr.pick_native_folder() + assert out["ok"] is False and "picker" in out["error"] + assert tried == ["zenity", "kdialog", "qarma"] + + def test_provider_set_and_remove_roundtrip(tmp_path): """Settings ▸ Models "Remove key": DELETE /v1/providers/{name} forgets the stored profile so the provider reads unconfigured again; unknown names are a clean error. From 83f11f5da621a20baa95f2482c794eaa0e7c19b7 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 26 Aug 2026 17:13:31 +0000 Subject: [PATCH 2/4] Add packaging/bootstrap_linux.sh: one command to a runnable checkout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_019b8eay3F9BvB2emcd19GvF --- README.md | 4 +- docs/linux.md | 51 +++++++-- packaging/bootstrap_linux.sh | 197 +++++++++++++++++++++++++++++++++++ 3 files changed, 240 insertions(+), 12 deletions(-) create mode 100755 packaging/bootstrap_linux.sh diff --git a/README.md b/README.md index d4932c6254..4f644ad71c 100644 --- a/README.md +++ b/README.md @@ -67,7 +67,7 @@ OpenWorker is local-first. Everything lives on your machine: the agent loop, you ## Run from source -Prerequisites: Python 3.10+, Node 20+, and (for the desktop shell) the Rust toolchain via [rustup](https://rustup.rs/). On Linux the shell also needs the WebKit/GTK development packages — see [docs/linux.md](docs/linux.md). +Prerequisites: Python 3.10+, Node 20+, and (for the desktop shell) the Rust toolchain via [rustup](https://rustup.rs/). On Linux, `bash packaging/bootstrap_linux.sh` installs all of it — including the WebKit/GTK packages the shell needs — in one step; see [docs/linux.md](docs/linux.md). ```shell git clone https://github.com/andrewyng/openworker @@ -103,7 +103,7 @@ Tests: `.venv/bin/pytest` (server), `npm test` and `npm run e2e` in `surfaces/gu | `coworker/` | Python backend - agent engine, model providers, connectors, MCP client, memory, automations | | `surfaces/gui/` | Desktop app - React UI + Tauri shell that supervises the server | | `stt/` | Speech-to-text sidecar (Rust) for voice input | -| `packaging/` | Installer builds (macOS DMG, Windows, Linux .deb/AppImage), auto-update manifest, dev bootstrap | +| `packaging/` | Installer builds (macOS DMG, Windows, Linux .deb/AppImage), auto-update manifest, dev bootstrap (`setup_dev_env.sh`, `bootstrap_linux.sh`) | | `docs/` | Design specs and decision logs | | `tests/` | Backend test suite | diff --git a/docs/linux.md b/docs/linux.md index b14aa9dc72..4f5581128f 100644 --- a/docs/linux.md +++ b/docs/linux.md @@ -99,25 +99,55 @@ disabled. Nothing else in the app is affected. Turning it on later is a small ch ## Build from source -Prerequisites: Python 3.10+, Node 20+, the Rust toolchain via [rustup](https://rustup.rs/), and -the Tauri system libraries. +### The one-command way + +```shell +git clone https://github.com/andrewyng/openworker +cd openworker +bash packaging/bootstrap_linux.sh +``` + +That installs everything a build needs, skipping whatever you already have: the WebKit/GTK +system libraries (apt or dnf, via `sudo`), the Rust toolchain, Node 20 if yours is older, +the Python venv at `.venv`, and the GUI's npm packages. It prints the plan and asks before +touching anything — `--yes` skips the prompt, `--packaging` also installs the extra Python +deps `build_linux.sh` needs. + +It installs Rust and Node with the official rustup/nvm installers, which append to your shell +profile. Prefer your distro's packages? Install those two yourself first; the script detects +them and moves on. + +Budget **~10 GB of free disk** — `surfaces/gui/src-tauri/target` alone reaches 5.8 GB. On +ChromeOS: Settings → Advanced → Developers → Linux → *Disk size*. The script warns if you're +short before it starts. + +Then: + +```shell +cd surfaces/gui && npm run tauri dev # the real desktop shell — window + server +``` + +### Doing it by hand + +Prerequisites: Python 3.10+, Node 20+ (Debian 12 ships 18), the Rust toolchain via +[rustup](https://rustup.rs/), and the Tauri system libraries. ```shell # Debian 12 / Ubuntu 22.04+ (Crostini included) -sudo apt install libwebkit2gtk-4.1-dev libgtk-3-dev librsvg2-dev \ - libayatana-appindicator3-dev patchelf file \ - build-essential curl wget file python3-venv +sudo apt install build-essential pkg-config curl wget file git \ + python3-venv python3-dev \ + libwebkit2gtk-4.1-dev libgtk-3-dev librsvg2-dev \ + libayatana-appindicator3-dev libssl-dev patchelf # Fedora -sudo dnf install webkit2gtk4.1-devel gtk3-devel librsvg2-devel patchelf +sudo dnf install gcc gcc-c++ make pkgconf-pkg-config python3-devel \ + webkit2gtk4.1-devel gtk3-devel librsvg2-devel \ + libappindicator-gtk3-devel openssl-devel patchelf ``` Then the same three steps as every other platform: ```shell -git clone https://github.com/andrewyng/openworker -cd openworker - bash packaging/setup_dev_env.sh # 1. Python venv at .venv .venv/bin/openworker-server --cwd ~/some/project --port 8765 # 2. the server @@ -135,7 +165,8 @@ bash packaging/build_linux.sh ``` Produces `.deb` and `.AppImage` under `surfaces/gui/src-tauri/target/release/bundle/`. It needs -the build-only Python deps in the venv first: +the build-only Python deps in the venv first (`bootstrap_linux.sh --packaging` does this for +you): ```shell .venv/bin/pip install -e '.[bedrock]' pyinstaller typer diff --git a/packaging/bootstrap_linux.sh b/packaging/bootstrap_linux.sh new file mode 100755 index 0000000000..d0bc7a5819 --- /dev/null +++ b/packaging/bootstrap_linux.sh @@ -0,0 +1,197 @@ +#!/usr/bin/env bash +# One command to take a bare Linux machine to a runnable OpenWorker checkout. +# +# bash packaging/bootstrap_linux.sh # ask before installing anything +# bash packaging/bootstrap_linux.sh --yes # no prompt +# bash packaging/bootstrap_linux.sh --packaging # also set up for build_linux.sh +# +# It installs, skipping whatever is already present: +# 1. the system libraries the Tauri shell links against (needs sudo); +# 2. the Rust toolchain, via rustup, if `cargo` isn't on PATH; +# 3. Node 20, via nvm, if `node` is missing or older (Debian 12 ships Node 18); +# 4. the Python venv at .venv (packaging/setup_dev_env.sh); +# 5. the GUI's npm dependencies. +# +# Steps 2 and 3 run the official rustup/nvm installers, which pipe a script from the network +# into a shell and append to your shell profile. That is upstream's supported install path, and +# both are skipped entirely when the tool is already there — but it is your call, which is why +# this asks first. Prefer your distro's own packages? Install Rust and Node yourself, then run +# this: it will detect them and move on. +# +# This is for people BUILDING from source. If you just want to run the app, install the .deb or +# AppImage from a release instead — neither needs any of this. See docs/linux.md. +set -euo pipefail + +HERE="$(cd "$(dirname "$0")" && pwd)" +ROOT="$(cd "$HERE/.." && pwd)" +GUI="$ROOT/surfaces/gui" + +ASSUME_YES=0 +FOR_PACKAGING=0 +for arg in "$@"; do + case "$arg" in + -y|--yes) ASSUME_YES=1 ;; + --packaging) FOR_PACKAGING=1 ;; + -h|--help) sed -n '2,20p' "$0"; exit 0 ;; + *) echo "unknown option: $arg (try --help)" >&2; exit 2 ;; + esac +done + +[ "$(uname -s)" = "Linux" ] || { + echo "This bootstrap is for Linux. macOS/Windows: see packaging/setup_dev_env.sh." >&2 + exit 1 +} + +# ChromeOS Crostini — worth naming, because two of its quirks bite during a first build. +IS_CROSTINI=0 +if [ -e /dev/.cros_milestone ] || [ -d /opt/google/cros-containers ]; then + IS_CROSTINI=1 +fi + +say() { printf '\n\033[1m==> %s\033[0m\n' "$1"; } +have() { command -v "$1" >/dev/null 2>&1; } + +# -- what we need -------------------------------------------------------------------------- +# webkit2gtk + gtk are what the shell links against; librsvg renders the icon; patchelf and +# file are what the AppImage bundler shells out to; the rest are the ordinary C toolchain the +# Rust crates and the Python venv expect. python3-venv is separate on Debian/Ubuntu, and +# without it `python3 -m venv` fails at the last step of an otherwise fine setup. +APT_PKGS=( + build-essential pkg-config curl wget file git + python3-venv python3-dev + libwebkit2gtk-4.1-dev libgtk-3-dev librsvg2-dev + libayatana-appindicator3-dev libssl-dev + patchelf +) +DNF_PKGS=( + gcc gcc-c++ make pkgconf-pkg-config curl wget file git + python3-devel + webkit2gtk4.1-devel gtk3-devel librsvg2-devel + libappindicator-gtk3-devel openssl-devel + patchelf +) + +if have apt-get; then + PM=apt +elif have dnf; then + PM=dnf +else + echo "No apt or dnf found. Install these yourself, then re-run:" >&2 + printf ' %s\n' "${APT_PKGS[@]}" >&2 + echo "(names are Debian/Ubuntu's; translate for your distro)" >&2 + exit 1 +fi + +# -- disk check ---------------------------------------------------------------------------- +# A full build is ~7 GB: surfaces/gui/src-tauri/target alone reaches 5.8 GB, plus the cargo +# registry, node_modules, the venv and the frozen sidecar. Running out mid-build wastes the +# whole compile, and Crostini's disk is small by default. +AVAIL_GB="$(df -BG --output=avail "$ROOT" 2>/dev/null | tail -1 | tr -dc '0-9' || echo 0)" +if [ -n "$AVAIL_GB" ] && [ "$AVAIL_GB" -lt 10 ] 2>/dev/null; then + echo "WARNING: only ${AVAIL_GB}G free at $ROOT; a full build wants ~10G." + if [ "$IS_CROSTINI" = 1 ]; then + echo " ChromeOS: Settings → Advanced → Developers → Linux → Disk size." + fi +fi + +# -- plan, then consent -------------------------------------------------------------------- +say "plan" +echo " system packages ($PM, needs sudo)" +have cargo && echo " Rust: already installed ($(cargo --version 2>/dev/null))" \ + || echo " Rust: install via rustup (writes to your shell profile)" +NODE_MAJOR=0 +if have node; then NODE_MAJOR="$(node -p 'process.versions.node.split(".")[0]' 2>/dev/null || echo 0)"; fi +[ "$NODE_MAJOR" -ge 20 ] 2>/dev/null && echo " Node: already $(node -v)" \ + || echo " Node: install 20 via nvm (writes to your shell profile)" +echo " Python venv at $ROOT/.venv" +echo " npm dependencies in surfaces/gui" +[ "$FOR_PACKAGING" = 1 ] && echo " plus pyinstaller + typer (for packaging/build_linux.sh)" + +if [ "$ASSUME_YES" != 1 ]; then + # No terminal to ask on (piped, CI, a hook): say so plainly instead of dying on /dev/tty. + [ -e /dev/tty ] || { + echo "" >&2 + echo "Not running interactively — re-run with --yes to accept the plan above." >&2 + exit 1 + } + printf '\nProceed? [y/N] ' + read -r reply &2; exit 1; } + +# -- 3. Node ------------------------------------------------------------------------------- +say "[3/5] Node 20+" +NODE_VIA_NVM=0 +if [ "$NODE_MAJOR" -ge 20 ] 2>/dev/null; then + echo " already $(node -v) — leaving it alone" +else + export NVM_DIR="${NVM_DIR:-$HOME/.nvm}" + if [ ! -s "$NVM_DIR/nvm.sh" ]; then + curl -fsSL https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.1/install.sh | bash + fi + # shellcheck disable=SC1091 + . "$NVM_DIR/nvm.sh" + nvm install 20 + nvm use 20 + NODE_VIA_NVM=1 +fi +have npm || { echo "ERROR: npm still not on PATH after install" >&2; exit 1; } + +# -- 4. Python venv ------------------------------------------------------------------------ +say "[4/5] Python venv" +bash "$HERE/setup_dev_env.sh" +if [ "$FOR_PACKAGING" = 1 ]; then + # Build-time only: pyinstaller freezes the sidecar, and typer is needed because + # PyInstaller walks mcp.cli, which sys.exit()s at import without it. + "$ROOT/.venv/bin/pip" install --quiet -e "$ROOT[bedrock]" pyinstaller typer + echo " plus pyinstaller + typer" +fi + +# -- 5. GUI deps --------------------------------------------------------------------------- +say "[5/5] npm dependencies" +( cd "$GUI" && npm install --no-fund --no-audit ) + +# -- what now ------------------------------------------------------------------------------ +say "ready" +if ! have cargo || [ "$NODE_VIA_NVM" = 1 ]; then + echo " Start a new shell first (this one predates the PATH changes):" + echo " exec \$SHELL -l" + echo "" +fi +echo " Run the desktop app from source:" +echo " cd $GUI && npm run tauri dev" +echo "" +echo " Or the browser UI (two terminals):" +echo " $ROOT/.venv/bin/openworker-server --cwd ~/some/project --port 8765" +echo " cd $GUI && npm run dev" +if [ "$FOR_PACKAGING" = 1 ]; then + echo "" + echo " Build installable packages:" + echo " bash packaging/build_linux.sh" +fi +if [ "$IS_CROSTINI" = 1 ]; then + echo "" + echo " ChromeOS: to let OpenWorker reach files outside the container, right-click the" + echo " folder in the Files app → 'Share with Linux'. It appears at /mnt/chromeos/MyFiles/." +fi From 247a9afb273c4a562c73a5de5467517117ebb687 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 26 Aug 2026 18:42:09 +0000 Subject: [PATCH 3/4] bootstrap_linux: show the "start a new shell" hint after a fresh Rust install MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_019b8eay3F9BvB2emcd19GvF --- packaging/bootstrap_linux.sh | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/packaging/bootstrap_linux.sh b/packaging/bootstrap_linux.sh index d0bc7a5819..cefa67e598 100755 --- a/packaging/bootstrap_linux.sh +++ b/packaging/bootstrap_linux.sh @@ -97,8 +97,15 @@ fi # -- plan, then consent -------------------------------------------------------------------- say "plan" echo " system packages ($PM, needs sudo)" -have cargo && echo " Rust: already installed ($(cargo --version 2>/dev/null))" \ - || echo " Rust: install via rustup (writes to your shell profile)" +# Captured BEFORE step 2 runs. Step 2 sources $HOME/.cargo/env into this script so the rest of +# it can use cargo, which makes `have cargo` true afterwards even on a fresh install — and the +# "start a new shell" hint at the end, keyed off that, was suppressed on exactly the case that +# needs it. (Found on a first real run, ChromeOS Crostini, 2026-08-26.) NODE_MAJOR below is +# captured up here for the same reason. +RUST_PREINSTALLED=0 +if have cargo; then RUST_PREINSTALLED=1; fi +[ "$RUST_PREINSTALLED" = 1 ] && echo " Rust: already installed ($(cargo --version 2>/dev/null))" \ + || echo " Rust: install via rustup (writes to your shell profile)" NODE_MAJOR=0 if have node; then NODE_MAJOR="$(node -p 'process.versions.node.split(".")[0]' 2>/dev/null || echo 0)"; fi [ "$NODE_MAJOR" -ge 20 ] 2>/dev/null && echo " Node: already $(node -v)" \ @@ -130,7 +137,7 @@ fi # -- 2. Rust ------------------------------------------------------------------------------- say "[2/5] Rust toolchain" -if have cargo; then +if [ "$RUST_PREINSTALLED" = 1 ]; then echo " already installed — leaving it alone" else curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y @@ -174,7 +181,7 @@ say "[5/5] npm dependencies" # -- what now ------------------------------------------------------------------------------ say "ready" -if ! have cargo || [ "$NODE_VIA_NVM" = 1 ]; then +if [ "$RUST_PREINSTALLED" = 0 ] || [ "$NODE_VIA_NVM" = 1 ]; then echo " Start a new shell first (this one predates the PATH changes):" echo " exec \$SHELL -l" echo "" From 1773bd46d5eb2d7fd9508d49376c70acc3d44ca7 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 26 Aug 2026 18:53:33 +0000 Subject: [PATCH 4/4] keep-awake: don't report a hold when systemd-inhibit can't take one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_019b8eay3F9BvB2emcd19GvF --- surfaces/gui/src-tauri/src/lib.rs | 58 ++++++++++++++++++++++++++++--- 1 file changed, 53 insertions(+), 5 deletions(-) diff --git a/surfaces/gui/src-tauri/src/lib.rs b/surfaces/gui/src-tauri/src/lib.rs index d50d210ad9..dfc10efa76 100644 --- a/surfaces/gui/src-tauri/src/lib.rs +++ b/surfaces/gui/src-tauri/src/lib.rs @@ -412,12 +412,12 @@ impl Drop for KeepAwakeGuard { #[cfg(not(any(target_os = "macos", target_os = "windows")))] fn start_keep_awake() -> Option { // logind's inhibitor lock, held for exactly as long as the command it runs. `cat` with a - // piped stdin is the hold (see Drop). Missing systemd-inhibit (no logind, a container, a - // non-systemd distro) → None, and the Settings toggle stays off instead of lying. + // piped stdin is the hold (see Drop). No lock → None, and the Settings toggle stays off + // instead of lying. // // ChromeOS Crostini caveat: the lock is real inside the VM, but ChromeOS itself decides // when the device suspends, and a suspended Chromebook stops the VM regardless. - Command::new("systemd-inhibit") + let mut child = Command::new("systemd-inhibit") .args([ "--what=idle:sleep", "--who=OpenWorker", @@ -429,8 +429,32 @@ fn start_keep_awake() -> Option { .stdout(Stdio::null()) .stderr(Stdio::null()) .spawn() - .ok() - .map(KeepAwakeGuard) + .ok()?; + + // A successful 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 discarded and exiting 1 straight away. Taking + // the spawn as success left us holding a corpse and reporting a hold nobody held, which is + // the exact lie this function exists to avoid. + // + // So wait for it to fail. A working inhibitor runs `cat` until we close its stdin, i.e. + // forever; a broken one is gone in milliseconds. Polling caps the cost at GRACE and returns + // the instant it dies, which on the happy path a settings toggle will never notice. + const GRACE: std::time::Duration = std::time::Duration::from_millis(400); + const STEP: std::time::Duration = std::time::Duration::from_millis(10); + let deadline = std::time::Instant::now() + GRACE; + while std::time::Instant::now() < deadline { + match child.try_wait() { + Ok(None) => std::thread::sleep(STEP), // still alive — the lock is real so far + Ok(Some(_)) => return None, // exited: no inhibitor was ever taken + Err(_) => { + let _ = child.kill(); + let _ = child.wait(); + return None; + } + } + } + Some(KeepAwakeGuard(child)) } // -- native commands (invoked from the SPA via window.__TAURI__.core.invoke) ----------------- @@ -1214,6 +1238,30 @@ mod tests { let _ = std::fs::remove_dir_all(&empty); } + /// A keep-awake guard must represent a LIVE hold. `Command::spawn` only proves the binary + /// could be executed: `systemd-inhibit` itself exits non-zero when it cannot reach logind + /// (containers, non-systemd sessions, a session bus that isn't there), and a guard built + /// from that dead child would make the Settings toggle report a hold nobody is holding. + /// + /// Holds on either kind of machine: where an inhibitor can be taken this asserts the child + /// is alive, and where one can't, `start_keep_awake()` must return None rather than a + /// corpse. (Reported by a review bot; reproduced in a container where systemd-inhibit is + /// installed but exits 1 with "Failed to connect to bus".) + #[cfg(not(any(target_os = "macos", target_os = "windows")))] + #[test] + fn keep_awake_never_reports_a_dead_inhibitor() { + if let Some(mut guard) = start_keep_awake() { + // A real inhibitor lives until we release it; a broken one dies within + // milliseconds. Checking immediately after spawn cannot tell them apart — the + // child has not been scheduled yet — so give it time to fail first. + std::thread::sleep(std::time::Duration::from_millis(500)); + assert!( + guard.0.try_wait().expect("query the inhibitor").is_none(), + "start_keep_awake() returned a guard whose inhibitor had already exited" + ); + } + } + /// Voice Input is compiled out on Linux, and the GUI gates the mic button on this flag — /// so it must stay false there however the stub engine answers. #[test]