Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 31 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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
43 changes: 41 additions & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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

Expand Down Expand Up @@ -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
Expand All @@ -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"
Expand Down Expand Up @@ -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
Expand All @@ -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/
Expand Down
9 changes: 6 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
<sub>builds are not yet code-signed, so SmartScreen will warn; signing is in progress</sub>

[**⬇ Linux (.deb · AppImage, x64 + arm64)**](https://github.com/andrewyng/openworker/releases/latest)
<sub>Debian 12+ / Ubuntu 22.04+ · runs on ChromeOS Crostini · [setup and caveats](docs/linux.md)</sub>

Open the app, add a model key (or point it at Ollama), and ask for something real.

## Use cases
Expand Down Expand Up @@ -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
Expand All @@ -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

Expand All @@ -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 |

Expand Down
55 changes: 37 additions & 18 deletions coworker/server/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -2895,39 +2895,58 @@ 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
# (the dialog silently fails in the default MTA apartment).
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
Expand Down
Loading