diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 6b3e96d2..c6e3bf0b 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -52,10 +52,27 @@ jobs:
slug: macos-x64
- os: windows-latest
slug: windows
+ # Linux: built on ubuntu-22.04 (glibc 2.35) so the .deb/AppImage run on 22.04+ and
+ # most derivatives. webkit2gtk-4.1 + the STT sidecar's ALSA/whisper.cpp needs are
+ # installed in the dedicated deps step below.
+ - os: ubuntu-22.04
+ slug: linux
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
+ - name: Install Linux build dependencies (Tauri + STT sidecar)
+ # Tauri 2 webkit GUI + the ocw-stt crate (cpal→ALSA, whisper.cpp→C++). RPM targets
+ # would need rpmbuild; we ship deb + AppImage by default (see build_linux.sh).
+ if: runner.os == 'Linux'
+ run: |
+ sudo apt-get update
+ sudo apt-get install -y \
+ build-essential pkg-config file curl \
+ libwebkit2gtk-4.1-dev libssl-dev libayatana-appindicator3-dev \
+ librsvg2-dev patchelf libasound2-dev \
+ libclang-dev
+
- uses: actions/setup-node@v4
with:
node-version: 20
@@ -122,6 +139,15 @@ jobs:
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
run: ./packaging/build_windows.ps1
+ - name: Build .deb + AppImage (Linux)
+ if: runner.os == 'Linux'
+ env:
+ # Updater signing key (minisign, same secret as the other targets). Absent on
+ # fork/scratch runs → build_linux.sh skips updater artifacts with a warning.
+ 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: Stage artifacts (versioned + stable names)
run: |
mkdir -p out
@@ -135,7 +161,7 @@ jobs:
# secret is configured). The .sig signs CONTENT, so the stable rename is safe.
SIG=$(ls "$BUNDLE"/nsis/*.exe.sig 2>/dev/null | head -1 || true)
[ -n "$SIG" ] && cp "$SIG" out/OpenWorker-windows-setup.exe.sig
- else
+ elif [ "$RUNNER_OS" = "macOS" ]; then
cp "$BUNDLE"/dmg/*.dmg out/
cp "$BUNDLE"/dmg/*.dmg out/OpenWorker-${{ matrix.slug }}.dmg
# macOS updater artifact: the signed .app tarball the installed app swaps in.
@@ -143,6 +169,30 @@ jobs:
cp "$BUNDLE"/macos/OpenWorker.app.tar.gz out/OpenWorker-${{ matrix.slug }}.app.tar.gz
cp "$BUNDLE"/macos/OpenWorker.app.tar.gz.sig out/OpenWorker-${{ matrix.slug }}.app.tar.gz.sig
fi
+ else
+ # Linux: deb + AppImage (AppImage is optional — build_linux.sh emits it when
+ # linuxdeploy fetches cleanly; the deb always ships). Stable names keep the
+ # website's releases/latest/download links working as the version advances.
+ cp "$BUNDLE"/deb/*.deb out/
+ cp "$BUNDLE"/deb/*.deb out/OpenWorker-linux-amd64.deb
+ if ls "$BUNDLE"/appimage/*.AppImage >/dev/null 2>&1; then
+ cp "$BUNDLE"/appimage/*.AppImage out/
+ cp "$BUNDLE"/appimage/*.AppImage out/OpenWorker-linux-amd64.AppImage
+ fi
+ # Updater signature for the .deb (present only when the updater key secret is set).
+ SIG=$(ls "$BUNDLE"/deb/*.deb.sig 2>/dev/null | head -1 || true)
+ [ -n "$SIG" ] && cp "$SIG" out/OpenWorker-linux-amd64.deb.sig
+ # Regression guard: the deb MUST ship the sidecar at usr/lib/OpenWorker/sidecar/ —
+ # that is the resource_dir() path server_bin() resolves on Linux (src-tauri/src/lib.rs).
+ # A green build without this entry is a non-functional app (the macOS/Windows "next to
+ # the exe" resolution does NOT hold on a deb, where the binary is /usr/bin and
+ # resources live under /usr/lib). Fail loudly rather than ship a broken bundle.
+ # Dump to a file then grep it: `dpkg-deb -c | grep` breaks the pipe under pipefail
+ # (grep exits on first match → dpkg-deb gets SIGPIPE → 141), which would false-fail CI.
+ DEB=$(ls "$BUNDLE"/deb/*.deb | head -1)
+ dpkg-deb -c "$DEB" > /tmp/openworker-deb-contents.txt
+ grep -q 'usr/lib/OpenWorker/sidecar/openworker-server' /tmp/openworker-deb-contents.txt \
+ || { echo "::error::deb is missing the sidecar at usr/lib/OpenWorker/sidecar/ — the app would not start"; exit 1; }
fi
ls -la out
diff --git a/README.md b/README.md
index b32a299d..e6c143f8 100644
--- a/README.md
+++ b/README.md
@@ -20,6 +20,8 @@ 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 (x86_64)** — `.deb` + AppImage are buildable from source; see [Build a Linux package](#build-a-linux-package-deb--appimage) below. Signed release downloads are coming.
+
Open the app, add a model key (or point it at Ollama), and ask for something real.
## How it works
@@ -91,7 +93,31 @@ 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`.
+
+## Build a Linux package (.deb + AppImage)
+
+Produces a `.deb` (and an `.AppImage` when `linuxdeploy` fetches cleanly) for x86_64, built on `ubuntu-22.04` for broad glibc compatibility.
+
+```shell
+# 1. One-time: install the Tauri + STT-sidecar system libs (Debian/Ubuntu/Fedora/Arch aware)
+bash packaging/install_linux_deps.sh # add -y to skip the confirm prompt
+
+# 2. One-time: create the build venv the bundler expects at .venv
+python3 -m venv .venv
+.venv/bin/pip install -e '.[bedrock]' pyinstaller typer
+
+# 3. Build the frontend + Rust shell + PyInstaller sidecar, then bundle
+cd surfaces/gui && npm ci && cd ../..
+./packaging/build_linux.sh
+```
+
+Output lands in `surfaces/gui/src-tauri/target/release/bundle/{deb,appimage}/`. Override the targets with `BUNDLES=` (e.g. `BUNDLES=deb` or `BUNDLES=rpm,appimage` — `rpm` needs `rpmbuild` installed).
+
+Notes:
+- The build is **unsigned**; Linux has no universal code-signing scheme. Verify release artifacts via the GitHub Release's GPG/signature if you need integrity guarantees.
+- **Voice Input** is not yet wired up on Linux — the STT crate compiles (cpal/whisper.cpp), but the shell marks dictation unsupported on Linux for now. Wiring a Linux audio backend is a natural follow-up.
+- **Auto-update** emits signed updater artifacts only when `TAURI_SIGNING_PRIVATE_KEY` is set; without it the build omits them (the app still runs, just no self-update).
## Repository layout
@@ -100,7 +126,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/packaging/build_linux.sh b/packaging/build_linux.sh
new file mode 100755
index 00000000..5a4cdacc
--- /dev/null
+++ b/packaging/build_linux.sh
@@ -0,0 +1,94 @@
+#!/usr/bin/env bash
+# Build the OpenWorker 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` → installable packages (resources copied in).
+#
+# The Linux counterpart to build_dmg.sh / build_windows.ps1. It is UNSIGNED — Linux has no
+# universal code-signing scheme; distro repos / AppArmor / GPG-signing the release artifacts
+# are downstream concerns.
+#
+# Prerequisites (mirrors the macOS/Windows scripts' headers):
+# - Rust (rustup) + Node/npm, and the GUI deps installed (`npm ci` in surfaces/gui).
+# - Tauri Linux system libraries (Debian/Ubuntu names):
+# sudo apt-get install -y libwebkit2gtk-4.1-dev libssl-dev libayatana-appindicator3-dev \
+# librsvg2-dev patchelf build-essential pkg-config file libclang-dev
+# (Fedora: webkit2gtk4.1-devel libayatana-appindicator-devel librsvg2-devel patchelf clang-devel;
+# Arch: webkit2gtk-4.1 libayatana-appindicator librsvg patchelf clang)
+# The STT sidecar (ocw-stt) uses cpal → ALSA (libasound2-dev) and whisper.cpp needs a C/C++
+# compiler (build-essential / gcc-c++) plus libclang-dev for bindgen at build time.
+# - 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.
+# (aisuite installs like any other dependency — git-pinned in pyproject.toml.)
+# - AppImage: Tauri fetches its own `linuxdeploy`/`appimagetool` at build time, so a network
+# connection is required for the appimage target. Running an AppImage needs `libfuse2`.
+#
+# 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
+#
+# Override the bundles with BUNDLES= (e.g. BUNDLES=deb or BUNDLES=rpm,appimage). `rpm` requires
+# `rpm`/`rpmbuild` to be installed; Tauri only emits .rpm when it is requested explicitly.
+set -euo pipefail
+
+HERE="$(cd "$(dirname "$0")" && pwd)"
+PLATFORM="$(cd "$HERE/.." && pwd)"
+GUI="$PLATFORM/surfaces/gui"
+APP="OpenWorker"
+# 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
+ARCH="${TRIPLE%%-*}"
+BUNDLES="${BUNDLES:-deb,appimage}"
+
+# A running openworker-server (e.g. a prior dev sidecar) can hold a write lock on the PyInstaller
+# output and make the overwrite fail. Best-effort: stop any before bundling.
+if pgrep -x openworker-server >/dev/null 2>&1; then
+ echo "==> stopping running openworker-server process(es) holding the output exe"
+ pkill -x openworker-server || true
+ sleep 1
+fi
+
+echo "==> [1/3] PyInstaller: bundling openworker-server ($TRIPLE)"
+"$PLATFORM/.venv/bin/pyinstaller" --noconfirm --clean \
+ --distpath "$HERE/dist" --workpath "$HERE/build" "$HERE/openworker-server.spec"
+
+echo "==> [2/3] staging sidecar resources"
+# Onedir bundle (exe + _internal/) ships via Tauri `resources`, landing under the install
+# resource dir (e.g. /usr/lib//sidecar/ on a deb). rm -rf first: a dev-convenience
+# symlink here once clobbered another worktree's venv; also clears stale pre-onedir onefile bins.
+mkdir -p "$GUI/src-tauri/binaries"
+rm -rf "$GUI/src-tauri/binaries/sidecar" "$GUI/src-tauri/binaries/openworker-server-$TRIPLE"
+cp -RL "$HERE/dist/openworker-server" "$GUI/src-tauri/binaries/sidecar"
+chmod +x "$GUI/src-tauri/binaries/sidecar/openworker-server"
+
+echo "==> [3/3] tauri build (--bundles $BUNDLES)"
+# Auto-update artifacts (.deb/.AppImage + minisign .sig): 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 the macOS script).
+# Keyless builds skip the overlay entirely so dev/fork builds keep working; a keyless RELEASE
+# would strand every install without auto-update, hence the loud warning.
+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
+# ${arr[@]+…} guard: plain "${arr[@]}" on an EMPTY array is "unbound variable" under set -u.
+( cd "$GUI" && npm run tauri build -- --bundles "$BUNDLES" ${UPDATER_OVERLAY[@]+"${UPDATER_OVERLAY[@]}"} )
+
+BUNDLE="$GUI/src-tauri/target/release/bundle"
+echo ""
+echo "Done. Bundles under: $BUNDLE"
+# List whatever was actually produced (each target dir may or may not exist).
+find "$BUNDLE" \( -name '*.deb' -o -name '*.rpm' -o -name '*.AppImage' \) -print 2>/dev/null || true
diff --git a/packaging/install_linux_deps.sh b/packaging/install_linux_deps.sh
new file mode 100755
index 00000000..4a5c5685
--- /dev/null
+++ b/packaging/install_linux_deps.sh
@@ -0,0 +1,79 @@
+#!/usr/bin/env bash
+# Install the build-time system dependencies for OpenWorker on Linux.
+#
+# Two groups are needed to build the desktop app (packaging/build_linux.sh):
+# 1. Tauri 2 shell + webkit GUI — libwebkit2gtk-4.1, appindicator, librsvg, patchelf, …
+# 2. The STT sidecar crate (ocw-stt) — cpal needs ALSA, whisper.cpp needs a C/C++ compiler.
+#
+# Detects the distro from /etc/os-release and uses the right package names. Run once, then
+# create the Python venv per the header of build_linux.sh. Requires sudo for the install;
+# pass -y to auto-confirm the apt/distro prompt.
+set -euo pipefail
+
+ASSUME_YES=0
+for a in "$@"; do
+ case "$a" in -y|--yes) ASSUME_YES=1 ;; *) echo "unknown arg: $a" >&2; exit 2 ;; esac
+done
+
+if [ ! -r /etc/os-release ]; then
+ echo "Cannot detect distro: /etc/os-release missing. Install the deps listed in build_linux.sh by hand." >&2
+ exit 1
+fi
+# shellcheck disable=SC1091
+. /etc/os-release
+DISTRO="${ID:-}"
+# Family detection — handle derivatives (e.g. Linux Mint → id=linuxmint, ID_LIKE=debian).
+FAMILY=""
+case "$DISTRO" in
+ debian|ubuntu|linuxmint|pop) FAMILY=debian ;;
+ fedora|rhel|rocky|almalinux|centos) FAMILY=fedora ;;
+ arch|manjaro| EndeavourOS) FAMILY=arch ;;
+esac
+if [ -z "$FAMILY" ]; then
+ for like in ${ID_LIKE:-}; do
+ case "$like" in debian|ubuntu) FAMILY=debian ;; fedora|rhel) FAMILY=fedora ;; arch) FAMILY=arch ;; esac
+ [ -n "$FAMILY" ] && break
+ done
+fi
+[ -n "$FAMILY" ] || { echo "Unsupported distro: '$DISTRO'. See build_linux.sh for the manual package list." >&2; exit 1; }
+
+CONFIRM=""
+[ "$ASSUME_YES" = 1 ] && case "$FAMILY" in debian) CONFIRM=-y ;; fedora) CONFIRM="-y" ;; arch) CONFIRM="--noconfirm" ;; esac
+
+echo "==> detected: $DISTRO ($FAMILY)"
+case "$FAMILY" in
+ debian)
+ PACKAGES=(
+ build-essential pkg-config file curl
+ libwebkit2gtk-4.1-dev libssl-dev libayatana-appindicator3-dev
+ librsvg2-dev patchelf libasound2-dev
+ # Build-time only: whisper-rs-sys (the STT sidecar) runs bindgen, which needs
+ # libclang to parse whisper.cpp's headers. Not a runtime dependency.
+ libclang-dev
+ )
+ echo "==> apt-get install: ${PACKAGES[*]}"
+ sudo apt-get update
+ sudo apt-get install -y "${PACKAGES[@]}"
+ ;;
+ fedora)
+ PACKAGES=(
+ gcc-c++ pkgconfig file curl patchelf clang-devel
+ webkit2gtk4.1-devel openssl-devel libayatana-appindicator-devel
+ librsvg2-devel alsa-lib-devel
+ )
+ echo "==> dnf install: ${PACKAGES[*]}"
+ sudo dnf install -y "${PACKAGES[@]}"
+ ;;
+ arch)
+ PACKAGES=(base-devel pkgconf file curl patchelf webkit2gtk-4.1 libayatana-appindicator librsvg alsa-lib clang)
+ echo "==> pacman -S: ${PACKAGES[*]}"
+ sudo pacman -S --noconfirm "${PACKAGES[@]}"
+ ;;
+esac
+
+echo ""
+echo "Done. Next: create the build venv and build —"
+echo " python3 -m venv .venv"
+echo " .venv/bin/pip install -e '.[bedrock]' pyinstaller typer"
+echo " cd surfaces/gui && npm ci && cd ../.."
+echo " ./packaging/build_linux.sh"
diff --git a/packaging/make_update_manifest.py b/packaging/make_update_manifest.py
index 84c4f295..38d9733b 100644
--- a/packaging/make_update_manifest.py
+++ b/packaging/make_update_manifest.py
@@ -12,6 +12,7 @@
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-amd64.deb(.sig) -> platforms["linux-x86_64"]
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 +38,7 @@
"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-amd64.deb": "linux-x86_64",
}
diff --git a/surfaces/gui/src-tauri/src/lib.rs b/surfaces/gui/src-tauri/src/lib.rs
index 460021b4..724d90a8 100644
--- a/surfaces/gui/src-tauri/src/lib.rs
+++ b/surfaces/gui/src-tauri/src/lib.rs
@@ -50,13 +50,15 @@ fn launch_token() -> String {
/// 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.
+/// `sidecar/` folder lands in Contents/Resources on macOS, in the install dir
+/// (next to the app exe) on Windows, and under Tauri's `resource_dir()`
+/// (e.g. `/usr/lib//`) on Linux — NOT next to the binary like the
+/// other two, so Linux resolves via the runtime path API instead of `current_exe`.
/// 3. 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` → `platform/.venv`;
/// `bin/` on POSIX, `Scripts\` on Windows).
-fn server_bin() -> PathBuf {
+fn server_bin(app: &tauri::AppHandle) -> PathBuf {
if let Ok(p) = std::env::var("COWORKER_SERVER_BIN") {
return PathBuf::from(p);
}
@@ -65,6 +67,18 @@ fn server_bin() -> PathBuf {
} else {
"openworker-server"
};
+ // Linux: .deb/.rpm/AppImage install resources under `resource_dir()`, not next to the
+ // exe (`current_exe` is /usr/bin/ on a deb, while the sidecar is in /usr/lib//).
+ // Resolving through Tauri's own path API stays correct across every Linux bundle format.
+ #[cfg(target_os = "linux")]
+ {
+ if let Ok(rd) = app.path().resource_dir() {
+ let cand = rd.join("sidecar").join(exe_name);
+ if cand.exists() {
+ return cand;
+ }
+ }
+ }
if let Ok(exe) = std::env::current_exe() {
if let Some(dir) = exe.parent() {
// macOS: Contents/MacOS/ → Contents/Resources/sidecar/; Windows: resources
@@ -626,7 +640,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.handle()));
server_cmd
.args(["--host", "127.0.0.1", "--port", &port.to_string()])
// The sidecar self-exits if we die abruptly (dev-watcher restart, crash) —
@@ -688,6 +702,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.
+ // `mut` is only used in the macOS title-bar block below; silence the unused-mut
+ // warning on Windows/Linux without changing macOS behavior.
+ #[cfg_attr(not(target_os = "macos"), allow(unused_mut))]
let mut builder =
WebviewWindowBuilder::new(app, "main", WebviewUrl::App("index.html".into()))
.title("OpenWorker")