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 60172b41d0..91225376ac 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.
## Use cases
@@ -86,7 +89,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, `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
@@ -113,7 +116,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
@@ -122,7 +125,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 (`setup_dev_env.sh`, `bootstrap_linux.sh`) |
| `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..4f5581128f
--- /dev/null
+++ b/docs/linux.md
@@ -0,0 +1,205 @@
+# 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
+
+### 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 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 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
+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 (`bootstrap_linux.sh --packaging` does this for
+you):
+
+```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/bootstrap_linux.sh b/packaging/bootstrap_linux.sh
new file mode 100755
index 0000000000..cefa67e598
--- /dev/null
+++ b/packaging/bootstrap_linux.sh
@@ -0,0 +1,204 @@
+#!/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)"
+# 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)" \
+ || 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 [ "$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 ""
+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
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..dfc10efa76 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,75 @@ 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). 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.
+ let mut child = 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()?;
+
+ // 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) -----------------
@@ -396,6 +509,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 +691,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 +817,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