From 9b2d5db6446e0da7881ad5901c8fdd535e848942 Mon Sep 17 00:00:00 2001
From: Daniele Dellafiore <66707+ildella@users.noreply.github.com>
Date: Thu, 10 Sep 2026 13:09:49 +0200
Subject: [PATCH 1/6] cleaned up readme, added highlight section
---
.gitignore | 1 +
README.md | 369 ++++++++++++++++-------------------------------------
2 files changed, 111 insertions(+), 259 deletions(-)
diff --git a/.gitignore b/.gitignore
index 35ab0d5..2af0753 100644
--- a/.gitignore
+++ b/.gitignore
@@ -14,3 +14,4 @@ Thumbs.db
.idea/
.vscode/
.codegraph/
+.zcode/
diff --git a/README.md b/README.md
index 9dc4b24..f0ede61 100644
--- a/README.md
+++ b/README.md
@@ -4,87 +4,96 @@
[](https://github.com/ildella/sayit/actions/workflows/ci-macos.yml)
[](https://github.com/ildella/sayit/actions/workflows/ci-windows.yml)
-Private, local text-to-speech, cross-platform. Say It turns copied text into
-speech with open models running entirely on your machine — your text and
-generated audio never leave your computer.
+Private, local text-to-speech. Say It turns copied text into speech with open
+models running entirely on your machine — your text and generated audio never
+leave your computer.
+
+This is a Linux-first port of [callebtc/sayit](https://github.com/callebtc/sayit)
+(macOS / Apple silicon). It keeps the original architecture and CLI while
+replacing Apple-specific layers with Tauri, Svelte, and kokoro-js.
-
+
-This is a multi-platform port of [callebtc/sayit](https://github.com/callebtc/sayit)
-(macOS / Apple silicon), keeping its architecture and CLI surface while
-swapping every Apple-specific layer for portable equivalents:
+Linux (X11 and Wayland) is built and tested. macOS and Windows should compile;
+help wanted.
-| macOS original | This port |
-| ------------------------- | ------------------------------------------- |
-| SwiftUI menu-bar app | Tauri v2 + SvelteKit 2 / Svelte 5 tray app |
-| MLX Audio (Apple silicon) | **kokoro-js** — Kokoro-82M on onnxruntime-node, pure JavaScript |
-| XPC | Token-protected REST API on 127.0.0.1:7878 + SSE |
-| Accessibility selection | Clipboard hotkey (see Wayland notes below) |
-| macOS Services | `sayit-clipboard`, bindable in any DE |
-| `sayit` CLI | Same CLI, same commands |
+## Highlights
-No Python anywhere. The sidecar is Node ≥ 20, the UI is Svelte, the shell is Rust.
+- **Speak from anywhere.** Copy text and press the clipboard hotkey
+ (**Ctrl+Alt+V** on X11), or bind `sayit-clipboard` as a custom shortcut in
+ your desktop environment — the reliable path on Wayland.
+- **A desktop player.** Tray window to speak, pause, seek, change playback
+ speed, and revisit history without leaving your current app.
+- **Open models.** Download supported Kokoro-82M weights in the app or CLI
+ (`kokoro-q8` ~90 MB, or `kokoro-q4`). Speak never downloads on its own.
+- **Efficient model loading.** Only one model is kept in memory, and it is
+ unloaded after a configurable idle period (ten minutes by default).
+- **Local by design.** Synthesis works offline after model download. There is
+ no analytics, cloud inference, or passive clipboard monitoring.
+- **Hear your coding agent work.** The bundled
+ [Say It agent skill](skills/sayit/SKILL.md) provides live, hands-free spoken
+ progress updates while an agent works.
-## Platform status
+## Getting started
-| Platform | Status |
-| -------- | ------ |
-| Linux | ✅ Built and tested (X11 & Wayland) |
-| macOS | 🔶 Should build — help wanted |
-| Windows | 🔶 Should build — help wanted |
+Say It needs **Node ≥ 20**, npm, and **mpv** for playback (`aplay` is a
+limited fallback). Clipboard tools (`wl-paste`, `xclip`, or `xsel`) only if
+you want the hotkey.
-The Tauri v2 shell is cross-platform by design; only the playback helper
-(`mpv`, with `aplay` fallback) and clipboard tools are POSIX-flavored today.
-Porting notes for macOS/Windows contributors are welcome — see
-[LINUX.md](LINUX.md) for how the port is put together and why.
+1. Install the sidecar and CLI, and start the service at login:
-## Quick start
+ ```sh
+ git clone https://github.com/ildella/sayit.git
+ cd sayit
+ bash scripts/install.sh --systemd
+ ```
-Requirements: Node ≥ 20, npm, and **mpv** for playback (falls back to `aplay`).
-Clipboard tools (`wl-paste` / `xclip` / `xsel`) only if you want the hotkey.
+ Omit `--systemd` to start the daemon once without enabling it. Put
+ `~/.local/bin` on your `PATH`.
-### 1. Install
+2. Download a model (once, then the app stays offline):
-```sh
-curl -fsSL https://raw.githubusercontent.com/ildella/sayit/master/scripts/install.sh | bash -s -- --systemd
-```
+ ```sh
+ sayit models install kokoro-q8 --use
+ ```
-Or from a clone of this repo:
+3. Speak:
-```sh
-bash scripts/install.sh # sidecar + sayit CLI
-bash scripts/install.sh --systemd # + start automatically at login
-```
+ ```sh
+ sayit "Hello from Say It"
+ ```
-That puts the sidecar in `~/.local/share/sayit/sidecar` and the **CLI** at
-`~/.local/bin/sayit`, then starts the daemon. Ensure `~/.local/bin` is on
-your `PATH`. Download a catalog model before speaking; after that the app
-stays offline.
+ Or copy text and invoke the hotkey / `sayit-clipboard`.
-After pulling updates, re-run `scripts/setup-sidecar.sh` (or `npm run setup`)
-and restart the service. The GUI and CLI talk to whatever is already on
-port 7878 — an old sidecar will look “stuck” or reject speak.
+Onboarding in the desktop app offers the same recommended model if none is
+installed. `POST /v1/speak` returns 409 until a catalog model is installed
+and selected.
-### 2. Run the CLI
+### Terminal
+
+The install includes a `sayit` CLI for speech, playback, models, and
+automation:
```sh
-sayit models install kokoro-q8 --use # once, ~90 MB
-sayit "Hello from Say It" # speak
-sayit volume 0 # silence; 1 = normal, 2 = boost
-sayit service status # is the daemon running?
+sayit "Read this aloud"
+printf 'Read standard input' | sayit
+sayit status
+sayit pause
+sayit resume
+sayit volume 0 # silence; 1 = normal, 2 = boost
+sayit service status
+sayit skill path
```
-`sayit` talks to the sidecar on `127.0.0.1:7878`. If the daemon is down:
-`sayit service start` (or `systemctl --user start sayit` if you used
-`--systemd`).
+Run `sayit --help` for all commands. The CLI talks to the sidecar on
+`127.0.0.1:7878`. If the daemon is down: `sayit service start` (or
+`systemctl --user start sayit` after `--systemd`).
-### 3. Install the agent skill
+### Coding-agent voice mode
-The [skill](skills/sayit/SKILL.md) is from [callebtc/sayit](https://github.com/callebtc/sayit);
-this port only installs it next to the Linux CLI. After `install.sh` (or
-`npm run setup`):
+After install:
```sh
sayit skill install
@@ -105,67 +114,60 @@ cp "$(sayit skill path)" ~/.claude/skills/sayit/SKILL.md
```
Re-run `sayit skill install` after upgrading Say It. The sidecar must be
-running (`sayit service start`) and a model installed before speech works.
+running and a model installed before speech works.
-### 4. Run the desktop app
+### Wayland vs X11
-From a clone, after the sidecar is installed (`install.sh` or `npm run setup`):
+In-app global shortcuts (**Ctrl+Alt+V**) work on X11. Wayland compositors
+block them — bind a **custom shortcut** in your desktop settings to
+`sayit-clipboard` (installed next to the CLI). There is no cross-compositor
+API for another app's *selection* (not clipboard); on X11 you can point
+`sayit-clipboard` at `xclip -o` (PRIMARY) instead.
-```sh
-npm install # once: @tauri-apps/cli
-npm --prefix app install # once: SvelteKit UI
-npm run dev # sidecar + Tauri window
-```
+## Build from source
-`npm run dev` (or `npm run tauri dev`) opens the tray/window. If the daemon
-is already up, Tauri connects to it instead of spawning a second one.
+The steps above install the **CLI and sidecar**. The desktop app is a Tauri
+v2 + SvelteKit UI; you need Rust and [Tauri's prerequisites](https://v2.tauri.app/start/prerequisites/)
+as well as Node ≥ 20 and mpv.
```sh
-npm run tauri build # .deb / AppImage (Linux shell only)
+git clone https://github.com/ildella/sayit.git && cd sayit
+npm run setup # sidecar + CLI into ~/.local
+npm install # @tauri-apps/cli
+npm --prefix app install # SvelteKit UI
+npm run dev # sidecar + Tauri window
```
-The `.deb` installs a **Say It** launcher and `/usr/bin/sayit` (the GUI).
-The setup script also puts the **CLI** at `~/.local/bin/sayit`. If your PATH
-lists `~/.local/bin` first, the GNOME icon or `sayit status` may run the CLI
-instead of the window. Launch the GUI with `/usr/bin/sayit`, the CLI with
-`~/.local/bin/sayit`.
-
-### 5. Which engine is running?
-
-There is one synthesis engine today: **Kokoro-82M** via kokoro-js /
-onnxruntime-node (CPU). What *does* vary is the **catalog model** (q8 vs q4)
-and whether it is loaded in memory.
+`npm run dev` (or `npm run tauri dev`) opens the tray/window. If a daemon is
+already listening on port 7878, Tauri connects to it instead of spawning a
+second one.
```sh
-sayit status
-```
-
-Example:
-
-```
-state: idle
-engine: loaded (kokoro-q8)
-sidecar: 0.x.x
+npm run tauri build # .deb / AppImage (Linux)
+npm run build:ci # compile the shell, skip installers
```
-- **engine** — `loaded` / `loading…` / `unloaded`, and the active model id
- (`kokoro-q8` or `kokoro-q4`). Unloaded after idle timeout; the next speak
- loads it again.
-- **sidecar** — installed sidecar version (and a protocol mismatch warning
- if the CLI is newer than the daemon).
+The `.deb` installs a **Say It** launcher and `/usr/bin/sayit` (the GUI).
+The setup script puts the **CLI** at `~/.local/bin/sayit`. If `~/.local/bin`
+is first on `PATH`, a GNOME icon or `sayit status` may run the CLI instead of
+the window — launch the GUI with `/usr/bin/sayit`.
-List every catalog row and which one is **active**:
+After pulling updates, re-run `npm run setup` (or `scripts/setup-sidecar.sh`)
+and restart the service so the GUI and CLI are not talking to a stale
+sidecar.
-```sh
-sayit models
-```
-
-In the GUI: **Settings → Models**. The row marked in use is the engine
-the next speak will load. There is no separate GPU / ROCm / CUDA switch in
-this port — onnxruntime-node runs on CPU.
+CI runs `build:ci` on Ubuntu 22.04, macOS, and Windows. That proves the crate
+and UI compile; the TTS sidecar is still installed separately, so those
+binaries are not a shippable app yet.
## Architecture
+The SvelteKit frontend (in a Tauri v2 tray shell) is separate from a
+per-user Node sidecar that owns model downloads, synthesis, playback, and
+history. The app, CLI, and `sayit-clipboard` talk to that service over a
+token-protected REST + SSE API bound to `127.0.0.1:7878`. There is no
+Python; synthesis is **Kokoro-82M** via kokoro-js / onnxruntime-node (CPU).
+
```
┌──────────────┐ REST + SSE, Bearer token ┌──────────────────┐
│ Tauri v2 app │ ◄──────────────────────────► │ sidecar (Node) │
@@ -175,168 +177,17 @@ this port — onnxruntime-node runs on CPU.
└──────────────┘ └──────────────────┘
```
-- **sidecar/** — per-user service: synthesis (Kokoro ONNX via kokoro-js),
- playback via mpv's JSON IPC (pause / seek / speed / volume), history, model catalog,
- settings. One model in memory, unloaded after 10 idle minutes (configurable).
-- **app/** — SvelteKit 2 + Svelte 5 UI: speak box, transport, history, voices,
- Settings marketplace for models, onboarding when none are installed.
-- **cli/sayit.js** — `sayit "text"`, `printf … | sayit`, `sayit status`,
- `pause`, `resume`, `stop`, `seek`, `speed`, `volume`, `voices`, `models`, `history`, `replay`, `skill path`, `skill install`.
-- **src-tauri/** — tray icon, global hotkey (Ctrl+Alt+V speaks clipboard),
- spawns the sidecar, hands the API token to the webview.
-
-## Models and first run
-
-Speech models are a **catalog**, not a silent download on first speak.
-
-- **Onboarding:** if nothing is installed, the Speak tab shows the recommended model (Kokoro q8, ~90 MB) and **Download and Use**. Deleting the last model brings that screen back.
-- **Marketplace:** Settings → Models lists every catalog entry we can run today (`kokoro-q8` and `kokoro-q4`). Download, Download and Use, cancel, Use, Delete (not the active model).
-- **Progress:** the UI shows downloading / canceling. Byte-level percent is not wired yet (kokoro-js does not expose a reliable byte callback).
-- **Speak never downloads.** `POST /v1/speak` returns 409 until a model is installed and selected.
-
-CLI:
-
-```sh
-sayit models
-sayit models install kokoro-q8 --use
-sayit models select kokoro-q4
-sayit models rm kokoro-q4
-```
-
-Weights land in `~/.cache/sayit/models`. After that the app stays offline. Adding another ONNX family later is a catalog row, not a new Settings screen.
-
-## Playback speed
-
-- **Speak tab slider:** 0.5×–2.5× in 0.25 steps (the default-speed setting uses the same range).
-- **Player − / + buttons:** ±0.25 per press while playing, clamped to 0.5×–4.0×.
-- **CLI:** `sayit speed <0.5-4>` accepts any value in the range, not just the steps.
-- Pitch is preserved at every speed (mpv `scaletempo`); at 1× the audio plays
- untouched, and history replays apply speed only at playback — the stored
- audio file is always the original.
-
-## Setup (development)
-
-Requirements: Node ≥ 20, npm, **mpv** (recommended; falls back to `aplay`),
-and `wl-paste` (Wayland) or `xclip`/`xsel` (X11) for clipboard features.
-Rust + Tauri prerequisites only if you build the app.
-
-```sh
-git clone https://github.com/ildella/sayit && cd sayit
-
-# 1. Install sidecar + CLI into ~/.local/share/sayit and ~/.local/bin
-npm run setup # or: sh scripts/setup-sidecar.sh
-
-# 2a. Run the sidecar standalone…
-cd ~/.local/share/sayit/sidecar && npm start
-
-# 2b. …or as a systemd user service
-cp scripts/sayit.service ~/.config/systemd/user/
-systemctl --user enable --now sayit
-```
-
-### Desktop app
-
-```sh
-npm install # root: pulls @tauri-apps/cli
-npm --prefix app install
-npm run tauri dev # dev: vite on :1420 + sidecar auto-spawn
-npm run tauri build # produces .deb / AppImage (Linux shell only)
-npm run build:ci # compile the shell, skip installers
-```
-
-CI (`.github/workflows/ci.yml`) runs `build:ci` on Ubuntu 22.04, macOS, and
-Windows. That only proves the crate and UI compile — the TTS sidecar is still
-installed separately via `scripts/install.sh`, so those binaries are not a
-shippable app.
-
-In dev, the app finds the sidecar via `$SAYIT_SIDECAR_DIR` or the installed
-copy in `~/.local/share/sayit/sidecar`; if a service is already listening on
-7878 it just connects.
-
-## Wayland vs X11
-
-Global shortcut registration (Ctrl+Alt+V inside the app) works on X11.
-On Wayland, compositors block app-registered global shortcuts — the reliable
-path is a **custom shortcut in your desktop settings** bound to
-`sayit-clipboard` (installed by the setup script). Reading another app's
-*selection* (not clipboard) has no cross-compositor API on Wayland; on X11
-you can adapt `sayit-clipboard` to use `xclip -o` (PRIMARY) instead.
-
-## Config & data
-
-| Path | Content |
-| ---- | ------- |
-| `~/.config/sayit/token` | API token (0600), shared by app/CLI/scripts |
-| `~/.config/sayit/settings.json` | port, voice, speed, volume (0–2), unload timeout |
-| `~/.local/share/sayit/history.json` | spoken history (last 200) |
-| `~/.cache/sayit/models` | downloaded models |
-| `~/.cache/sayit/audio` | synthesized WAVs |
-| `~/.cache/sayit/sidecar.log` | sidecar stdout/stderr (when spawned by the GUI) |
-| `~/.cache/sayit/sidecar.pid` | pid of the running sidecar, used by health/recovery |
-
-## Troubleshooting
-
-**`error: Model is not installed`** — download once:
-
-```sh
-sayit models install kokoro-q8 --use
-```
-
-Speak never fetches weights by itself.
-
-**Empty Voice menu, red connection dot, or Speak stuck on Synthesizing** —
-the UI is talking to an outdated sidecar. The GUI handles this on its own:
-it verifies sidecar health and protocol at launch and every 30 seconds, and
-retires + respawns a stale one (a process it cannot attribute to this install
-is never touched). One caveat: if the stale sidecar is the systemd unit itself,
-`Restart=on-failure` brings it back while the GUI waits for the port — stop
-the unit first (`systemctl --user stop sayit`), let the GUI win, then either
-keep the GUI-managed sidecar or refresh the install and start the unit again.
-Without the GUI — daemon-only setups — stop the unit, refresh the install,
-start again:
-
-```sh
-systemctl --user stop sayit
-# pkill alone is not enough if the user unit is enabled — systemd will respawn it
-ss -ltnp | grep 7878 || echo '7878 libero'
-./scripts/setup-sidecar.sh
-systemctl --user start sayit # or: ~/.local/bin/sayit service start
-sayit models
-sayit status
-```
-
-Logs: `journalctl --user -u sayit -f` and `~/.cache/sayit/sidecar.log`.
-
-**`sayit status` opens the desktop window** — PATH hit `/usr/bin/sayit` (GUI).
-Use `~/.local/bin/sayit status`.
-
-## API (v1)
-
-`GET /v1/status` · `GET /v1/health` (liveness + sidecar version/protocol) ·
-`POST /v1/speak|pause|resume|stop|seek|speed|volume` ·
-`GET /v1/voices|models|history|settings` ·
-`POST /v1/models/:id/install|select` · `DELETE /v1/models/:id[/install]` ·
-`POST /v1/history/replay` ·
-`DELETE /v1/history/:id` · `GET /v1/events` (SSE) — all behind
-`Authorization: Bearer `.
-
-## Differences from the original
-
-- **Kokoro only, for now.** Qwen3-TTS / Chatterbox / OmniVoice are MLX- or
- Python-bound; kokoro-js is the one solid pure-JS engine today. The engine
- layer (`sidecar/src/engine.js`) is isolated so a second backend (e.g.
- ONNX exports of other models) can slot in.
-- **No voice cloning yet.** Kokoro has no cloning support.
-- **No selection capture on Linux/Wayland** (see Wayland vs X11).
+How the port maps onto the original (XPC → loopback HTTP, MLX → kokoro-js,
+selection → clipboard), filesystem layout, and invariants are in
+[LINUX.md](LINUX.md).
## Acknowledgments
-Say It was created by [callebtc](https://github.com/callebtc) as a beautiful,
+Say It was created by [callebtc](https://github.com/callebtc) as a
privacy-first macOS app. This project exists thanks to his generosity in
-releasing it under MIT — thank you! If you want the original Apple-silicon
-experience (MLX Audio, voice cloning, Voice Studio), use
-[callebtc/sayit](https://github.com/callebtc/sayit). This port reuses its
-architecture, API surface, and CLI design.
+releasing it under MIT. For the original Apple-silicon experience (MLX Audio,
+voice cloning, Voice Studio), use
+[callebtc/sayit](https://github.com/callebtc/sayit).
## License
From 572303a010a8f3e42c078e469c6ceb0fe7994791 Mon Sep 17 00:00:00 2001
From: Daniele Dellafiore <66707+ildella@users.noreply.github.com>
Date: Thu, 10 Sep 2026 13:44:43 +0200
Subject: [PATCH 2/6] prepping bundles for the desktop app
---
.github/workflows/ci-linux.yml | 3 +-
.github/workflows/ci-macos.yml | 3 +-
.github/workflows/ci-windows.yml | 3 +-
.gitignore | 4 ++
AGENTS.md | 5 +-
LINUX.md | 16 +++--
README.md | 87 ++++++++++++-----------
package.json | 1 +
scripts/install.sh | 12 ++--
scripts/prepare-sidecar-bundle.sh | 32 +++++++++
src-tauri/sidecar-bundle/.gitkeep | 0
src-tauri/src/main.rs | 112 ++++++++++++++++++------------
src-tauri/tauri.conf.json | 22 +++++-
13 files changed, 188 insertions(+), 112 deletions(-)
mode change 100644 => 100755 scripts/install.sh
create mode 100755 scripts/prepare-sidecar-bundle.sh
create mode 100644 src-tauri/sidecar-bundle/.gitkeep
diff --git a/.github/workflows/ci-linux.yml b/.github/workflows/ci-linux.yml
index d7bbfa4..6308121 100644
--- a/.github/workflows/ci-linux.yml
+++ b/.github/workflows/ci-linux.yml
@@ -1,6 +1,5 @@
# Compile the Tauri shell on Linux.
-# Intentionally --no-bundle: installers would be a mute window until the
-# Node sidecar is packaged. This job only proves the crate + UI build.
+# PRs stay --no-bundle (fast). Linux installers are `.github/workflows/release-linux.yml`.
name: ci-linux
on:
diff --git a/.github/workflows/ci-macos.yml b/.github/workflows/ci-macos.yml
index 55864f3..2ecbd73 100644
--- a/.github/workflows/ci-macos.yml
+++ b/.github/workflows/ci-macos.yml
@@ -1,6 +1,5 @@
# Compile the Tauri shell on macOS.
-# Intentionally --no-bundle: installers would be a mute window until the
-# Node sidecar is packaged. This job only proves the crate + UI build.
+# PRs stay --no-bundle. macOS installers are not shipped yet.
name: ci-macos
on:
diff --git a/.github/workflows/ci-windows.yml b/.github/workflows/ci-windows.yml
index 799fc63..dad910b 100644
--- a/.github/workflows/ci-windows.yml
+++ b/.github/workflows/ci-windows.yml
@@ -1,6 +1,5 @@
# Compile the Tauri shell on Windows.
-# Intentionally --no-bundle: installers would be a mute window until the
-# Node sidecar is packaged. This job only proves the crate + UI build.
+# PRs stay --no-bundle. Windows installers are not shipped yet.
name: ci-windows
on:
diff --git a/.gitignore b/.gitignore
index 2af0753..10bb79e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -3,6 +3,10 @@ app/build/
app/.svelte-kit/
src-tauri/target/
src-tauri/gen/
+src-tauri/sidecar-bundle/node_modules/
+src-tauri/sidecar-bundle/src/
+src-tauri/sidecar-bundle/package.json
+src-tauri/sidecar-bundle/package-lock.json
*.log
# OS / editor junk
diff --git a/AGENTS.md b/AGENTS.md
index 0ac4d57..858af33 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -7,8 +7,9 @@ Crucial reminders for future sessions. See LINUX.md for full architecture.
- **Node ≥ 20**, Rust, **mpv** (playback), `wl-paste`/`xclip`/`xsel` (clipboard).
- Root deps: `@tauri-apps/cli`. App and sidecar have their own `package.json`.
- Dev: `npm run dev` starts sidecar + `tauri dev`. `beforeDevCommand` runs from project root with `--prefix app`.
-- CI: `npm run build:ci` (`tauri build --no-bundle`) on Ubuntu 22.04, macOS, Windows. Do not add bundling to that job until the sidecar is inside the package.
-- Sidecar search order: `$SAYIT_SIDECAR_DIR` → `~/.local/share/sayit/sidecar`.
+- CI: `npm run build:ci` (`tauri build --no-bundle`) on PRs. Linux `.deb`/`.rpm` (sidecar inside) come from `npm run build:linux` / the tag workflow. `beforeBundleCommand` stages `src-tauri/sidecar-bundle/`.
+- GUI binary is `sayit-desktop`. CLI remains `sayit`.
+- Sidecar search order: `$SAYIT_SIDECAR_DIR` → bundled resources → `~/.local/share/sayit/sidecar`.
## Gotchas
diff --git a/LINUX.md b/LINUX.md
index 92cd571..6b45be0 100644
--- a/LINUX.md
+++ b/LINUX.md
@@ -131,7 +131,8 @@ read_clipboard()` ↔ `sayit-clipboard.sh`).
|---|---|
| `~/.config/sayit/token` | API token, 0600 — shared by app, CLI, scripts |
| `~/.config/sayit/settings.json` | port, host, voice, speed, model, unload timeout |
-| `~/.local/share/sayit/sidecar/` | installed sidecar (by `scripts/setup-sidecar.sh`) |
+| `~/.local/share/sayit/sidecar/` | installed sidecar (by `scripts/setup-sidecar.sh` / `install.sh`) |
+| GUI package `$RESOURCE/sidecar/` | sidecar tree inside the `.deb` / `.rpm` (see `spawn_sidecar`) |
| `~/.local/share/sayit/history.json` | last 200 entries, references WAV files |
| `~/.cache/sayit/models/` | HF model cache (passed as `cache_dir` to kokoro-js) |
| `~/.cache/sayit/audio/` | synthesized WAVs (deleted with history entries) |
@@ -146,14 +147,19 @@ disk. Anything that needs the API should resolve the token the same way.
- `build.rs` + `tauri-build` exist because `tauri::generate_context!()`
requires `OUT_DIR` — removing them breaks the build with a confusing macro
error.
-- `beforeDevCommand`/`beforeBuildCommand` run from the **project root**, so
- they use `--prefix app`; `frontendDist` is relative to `src-tauri/`, so it
- keeps `../app/build`. Mixing these up produces an ENOENT two directories
- away from the real problem.
+- `beforeDevCommand`/`beforeBuildCommand`/`beforeBundleCommand` run from the
+ **project root**, so they use `--prefix app` / `scripts/…`; `frontendDist`
+ is relative to `src-tauri/`, so it keeps `../app/build`. Mixing these up
+ produces an ENOENT two directories away from the real problem.
- The tray icon has a 1×1 transparent fallback (`Image::new_owned`) because
`default_window_icon()` can be `None` in some bundling configurations.
- CSP in `tauri.conf.json` must keep `connect-src http://127.0.0.1:7878` or
the webview can't reach the sidecar.
+- GUI packages (`npm run build:linux`) run `scripts/prepare-sidecar-bundle.sh`
+ then embed that tree as `bundle.resources` → `sidecar/`. The window binary
+ is `sayit-desktop` so it never shadows the CLI `sayit`. Spawn order:
+ `$SAYIT_SIDECAR_DIR` → bundled resources → `~/.local/share/sayit/sidecar`.
+ If 7878 is already healthy, the GUI does not spawn a second engine.
## 8. Known limitations (vs the original)
diff --git a/README.md b/README.md
index f0ede61..c98fb66 100644
--- a/README.md
+++ b/README.md
@@ -38,38 +38,47 @@ help wanted.
## Getting started
-Say It needs **Node ≥ 20**, npm, and **mpv** for playback (`aplay` is a
-limited fallback). Clipboard tools (`wl-paste`, `xclip`, or `xsel`) only if
-you want the hotkey.
+Two products, one engine. The CLI installer never needs a window. The GUI
+package includes its own copy of the sidecar and talks to whatever is already
+on port 7878.
-1. Install the sidecar and CLI, and start the service at login:
+### CLI (no window)
- ```sh
- git clone https://github.com/ildella/sayit.git
- cd sayit
- bash scripts/install.sh --systemd
- ```
+Needs **Node ≥ 20**, npm, and **mpv** (`aplay` is a limited fallback).
+Clipboard tools (`wl-paste`, `xclip`, or `xsel`) only if you want the hotkey.
- Omit `--systemd` to start the daemon once without enabling it. Put
- `~/.local/bin` on your `PATH`.
+```sh
+curl -fsSL https://raw.githubusercontent.com/ildella/sayit/master/scripts/install.sh | bash -s -- --systemd
+```
-2. Download a model (once, then the app stays offline):
+Or from a clone: `bash scripts/install.sh --systemd`. Omit `--systemd` to
+start the daemon once without enabling it. Put `~/.local/bin` on your `PATH`.
- ```sh
- sayit models install kokoro-q8 --use
- ```
+Then download a model (once, then the app stays offline) and speak:
-3. Speak:
+```sh
+sayit models install kokoro-q8 --use
+sayit "Hello from Say It"
+```
- ```sh
- sayit "Hello from Say It"
- ```
+Or copy text and run `sayit-clipboard` (bind that in your desktop
+shortcuts). Speak returns an error until a catalog model is installed
+and selected.
- Or copy text and invoke the hotkey / `sayit-clipboard`.
+### Desktop app
-Onboarding in the desktop app offers the same recommended model if none is
-installed. `POST /v1/speak` returns 409 until a catalog model is installed
-and selected.
+Download the `.deb` or `.rpm` from
+[Releases](https://github.com/ildella/sayit/releases) (needs Node ≥ 20 and
+mpv on the system):
+
+```sh
+sudo apt install ./SayIt_*_amd64.deb
+sayit-desktop
+```
+
+The menu launcher is **Say It**. The binary is `sayit-desktop` so it does not
+replace the CLI `sayit`. You can run both: whoever starts first owns the
+sidecar; the other connects.
### Terminal
@@ -126,39 +135,29 @@ API for another app's *selection* (not clipboard); on X11 you can point
## Build from source
-The steps above install the **CLI and sidecar**. The desktop app is a Tauri
-v2 + SvelteKit UI; you need Rust and [Tauri's prerequisites](https://v2.tauri.app/start/prerequisites/)
+Contributors: live UI. Packaging: `npm run build:linux` (embeds the sidecar).
+
+You need Rust and [Tauri's prerequisites](https://v2.tauri.app/start/prerequisites/)
as well as Node ≥ 20 and mpv.
```sh
git clone https://github.com/ildella/sayit.git && cd sayit
-npm run setup # sidecar + CLI into ~/.local
+npm run setup # sidecar + CLI into ~/.local (same as install.sh)
npm install # @tauri-apps/cli
npm --prefix app install # SvelteKit UI
-npm run dev # sidecar + Tauri window
+npm run dev # live UI (developer loop, not a distribution)
```
-`npm run dev` (or `npm run tauri dev`) opens the tray/window. If a daemon is
-already listening on port 7878, Tauri connects to it instead of spawning a
-second one.
+If a daemon is already listening on port 7878, Tauri connects to it instead
+of spawning a second one.
```sh
-npm run tauri build # .deb / AppImage (Linux)
-npm run build:ci # compile the shell, skip installers
+npm run build:linux # .deb + .rpm with sidecar inside → sayit-desktop
+npm run build:ci # compile the shell, skip installers (PR CI)
```
-The `.deb` installs a **Say It** launcher and `/usr/bin/sayit` (the GUI).
-The setup script puts the **CLI** at `~/.local/bin/sayit`. If `~/.local/bin`
-is first on `PATH`, a GNOME icon or `sayit status` may run the CLI instead of
-the window — launch the GUI with `/usr/bin/sayit`.
-
-After pulling updates, re-run `npm run setup` (or `scripts/setup-sidecar.sh`)
-and restart the service so the GUI and CLI are not talking to a stale
-sidecar.
-
-CI runs `build:ci` on Ubuntu 22.04, macOS, and Windows. That proves the crate
-and UI compile; the TTS sidecar is still installed separately, so those
-binaries are not a shippable app yet.
+After pulling updates, re-run `npm run setup` and restart the service so a
+CLI install is not talking to a stale sidecar.
## Architecture
diff --git a/package.json b/package.json
index a3dbd56..0831711 100644
--- a/package.json
+++ b/package.json
@@ -8,6 +8,7 @@
"dev": "npm --prefix sidecar run start & npm run tauri dev",
"build:app": "npm --prefix app run build",
"build:ci": "tauri build --no-bundle --ci",
+ "build:linux": "tauri build -b deb,rpm",
"setup": "sh scripts/setup-sidecar.sh",
"tauri": "tauri"
},
diff --git a/scripts/install.sh b/scripts/install.sh
old mode 100644
new mode 100755
index 696d395..2afae79
--- a/scripts/install.sh
+++ b/scripts/install.sh
@@ -5,16 +5,14 @@
# bash scripts/install.sh # service + sayit CLI
# bash scripts/install.sh --systemd # also start automatically at login
#
-# The same script becomes a one-liner once the repo is published:
-# curl -fsSL /raw//scripts/install.sh | bash -s -- --systemd
-# In piped mode it downloads the project tarball from SAYIT_REPO_URL.
+# Piped one-liner (no clone):
+# curl -fsSL https://raw.githubusercontent.com/ildella/sayit/master/scripts/install.sh | bash -s -- --systemd
+# Override with SAYIT_REPO_URL / SAYIT_REPO_BRANCH if needed.
set -eu
# --- Config -----------------------------------------------------------------
-# Piped installs fetch the project from here. Point this at the public repo
-# when publishing; local checkouts never need it.
-REPO_URL="${SAYIT_REPO_URL:-}"
-REPO_BRANCH="${SAYIT_REPO_BRANCH:-main}"
+REPO_URL="${SAYIT_REPO_URL:-https://github.com/ildella/sayit}"
+REPO_BRANCH="${SAYIT_REPO_BRANCH:-master}"
SYSTEMD=no
for arg in "$@"; do
diff --git a/scripts/prepare-sidecar-bundle.sh b/scripts/prepare-sidecar-bundle.sh
new file mode 100755
index 0000000..2d4332e
--- /dev/null
+++ b/scripts/prepare-sidecar-bundle.sh
@@ -0,0 +1,32 @@
+#!/bin/sh
+# Stage a production sidecar tree for Tauri resources (GUI .deb / .rpm).
+# Invoked by beforeBundleCommand — not by `tauri dev` or `build:ci`.
+set -eu
+
+ROOT="$(cd "$(dirname "$0")/.." && pwd)"
+SRC="$ROOT/sidecar"
+DEST="$ROOT/src-tauri/sidecar-bundle"
+
+if [ ! -f "$SRC/package.json" ] || [ ! -f "$SRC/package-lock.json" ]; then
+ echo "prepare-sidecar-bundle: missing sidecar/package.json or package-lock.json" >&2
+ exit 1
+fi
+
+rm -rf "$DEST"
+mkdir -p "$DEST"
+cp "$SRC/package.json" "$SRC/package-lock.json" "$DEST/"
+cp -R "$SRC/src" "$DEST/src"
+
+cd "$DEST"
+# Isolate from ~/.npmrc; package.json allowScripts still permits onnxruntime-node.
+if ! npm_config_allow_scripts= npm ci --omit=dev --userconfig /dev/null; then
+ echo "prepare-sidecar-bundle: npm ci failed" >&2
+ exit 1
+fi
+
+if [ ! -f "$DEST/src/index.js" ]; then
+ echo "prepare-sidecar-bundle: src/index.js missing after copy" >&2
+ exit 1
+fi
+
+echo "prepare-sidecar-bundle: $DEST"
diff --git a/src-tauri/sidecar-bundle/.gitkeep b/src-tauri/sidecar-bundle/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs
index 09e78de..26d9fd7 100644
--- a/src-tauri/src/main.rs
+++ b/src-tauri/src/main.rs
@@ -7,12 +7,16 @@
use std::path::PathBuf;
use std::process::{Child, Command, Stdio};
use std::sync::atomic::{AtomicBool, Ordering};
-use std::sync::{Arc, Mutex};
+use std::sync::{Arc, Mutex, OnceLock};
use std::time::{Duration, Instant};
use tauri::menu::{Menu, MenuItem};
+use tauri::path::BaseDirectory;
use tauri::tray::TrayIconBuilder;
use tauri::{AppHandle, Manager};
+
+/// Sidecar tree shipped inside the GUI package (`bundle.resources` → `sidecar/`).
+static BUNDLED_SIDECAR_DIR: OnceLock = OnceLock::new();
use tauri_plugin_global_shortcut::{GlobalShortcutExt, ShortcutState};
/// Must match `PROTOCOL_VERSION` in sidecar/src/config.js. A sidecar serving
@@ -83,18 +87,29 @@ fn speak_clipboard() {
}
}
+fn remember_bundled_sidecar(app: &tauri::App) {
+ let Ok(index) = app
+ .path()
+ .resolve("sidecar/src/index.js", BaseDirectory::Resource)
+ else {
+ return;
+ };
+ if !index.is_file() {
+ return;
+ }
+ if let Some(dir) = index.parent().and_then(|src| src.parent()) {
+ let _ = BUNDLED_SIDECAR_DIR.set(dir.to_path_buf());
+ }
+}
+
/// Locate and spawn the sidecar. Search order:
/// 1. $SAYIT_SIDECAR_DIR (dev)
-/// 2. ~/.local/share/sayit/sidecar (installed by scripts/setup-sidecar.sh)
+/// 2. bundled resources (GUI .deb / .rpm)
+/// 3. ~/.local/share/sayit/sidecar (CLI install.sh)
fn spawn_sidecar() -> Option {
- let dir = std::env::var("SAYIT_SIDECAR_DIR")
- .map(PathBuf::from)
- .ok()
- .filter(|p| p.join("src/index.js").exists())
- .or_else(|| {
- let p = data_dir().join("sidecar");
- p.join("src/index.js").exists().then_some(p)
- })?;
+ let dir = sidecar_dirs()
+ .into_iter()
+ .find(|p| p.join("src/index.js").is_file())?;
let node = std::env::var("SAYIT_NODE").unwrap_or_else(|_| "node".to_string());
let log_path = dirs::cache_dir()
@@ -165,6 +180,9 @@ fn sidecar_dirs() -> Vec {
if let Ok(dir) = std::env::var("SAYIT_SIDECAR_DIR") {
dirs.push(PathBuf::from(dir));
}
+ if let Some(dir) = BUNDLED_SIDECAR_DIR.get() {
+ dirs.push(dir.clone());
+ }
dirs.push(data_dir().join("sidecar"));
dirs
}
@@ -356,16 +374,6 @@ fn main() {
let sidecar = Arc::new(Mutex::new(None::));
let shutdown = Arc::new(AtomicBool::new(false));
- // Short startup wait: the watcher retries with a longer budget, so a slow
- // or unattributable recovery must not delay the window here.
- recover_sidecar(&sidecar, Duration::from_secs(5));
-
- std::thread::spawn({
- let sidecar = Arc::clone(&sidecar);
- let shutdown = Arc::clone(&shutdown);
- move || recovery_watcher(sidecar, shutdown)
- });
-
tauri::Builder::default()
.plugin(
tauri_plugin_global_shortcut::Builder::new()
@@ -377,33 +385,47 @@ fn main() {
})
.build(),
)
- .setup(|app| {
- app.global_shortcut()
- .register("Ctrl+Alt+V".parse::()?)?;
-
- let show = MenuItem::with_id(app, "show", "Show Say It", true, None::<&str>)?;
- let quit = MenuItem::with_id(app, "quit", "Quit", true, None::<&str>)?;
- let menu = Menu::with_items(app, &[&show, &quit])?;
-
- TrayIconBuilder::new()
- .menu(&menu)
- .tooltip("Say It")
- .icon(app.default_window_icon().cloned().unwrap_or_else(|| {
- tauri::image::Image::new_owned(vec![0u8; 4], 1, 1)
- }))
- .on_menu_event(|app: &AppHandle, event| match event.id.as_ref() {
- "show" => {
- if let Some(win) = app.get_webview_window("main") {
- let _ = win.show();
- let _ = win.set_focus();
+ .setup({
+ let sidecar = Arc::clone(&sidecar);
+ let shutdown = Arc::clone(&shutdown);
+ move |app| {
+ remember_bundled_sidecar(app);
+ // Short wait: the watcher retries with a longer budget, so a slow
+ // or unattributable recovery must not delay the window here.
+ recover_sidecar(&sidecar, Duration::from_secs(5));
+ std::thread::spawn({
+ let sidecar = Arc::clone(&sidecar);
+ let shutdown = Arc::clone(&shutdown);
+ move || recovery_watcher(sidecar, shutdown)
+ });
+
+ app.global_shortcut()
+ .register("Ctrl+Alt+V".parse::()?)?;
+
+ let show = MenuItem::with_id(app, "show", "Show Say It", true, None::<&str>)?;
+ let quit = MenuItem::with_id(app, "quit", "Quit", true, None::<&str>)?;
+ let menu = Menu::with_items(app, &[&show, &quit])?;
+
+ TrayIconBuilder::new()
+ .menu(&menu)
+ .tooltip("Say It")
+ .icon(app.default_window_icon().cloned().unwrap_or_else(|| {
+ tauri::image::Image::new_owned(vec![0u8; 4], 1, 1)
+ }))
+ .on_menu_event(|app: &AppHandle, event| match event.id.as_ref() {
+ "show" => {
+ if let Some(win) = app.get_webview_window("main") {
+ let _ = win.show();
+ let _ = win.set_focus();
+ }
}
- }
- "quit" => app.exit(0),
- _ => {}
- })
- .build(app)?;
+ "quit" => app.exit(0),
+ _ => {}
+ })
+ .build(app)?;
- Ok(())
+ Ok(())
+ }
})
.invoke_handler(tauri::generate_handler![get_token])
.build(tauri::generate_context!())
diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json
index ce0cb72..f4a32b1 100644
--- a/src-tauri/tauri.conf.json
+++ b/src-tauri/tauri.conf.json
@@ -1,13 +1,15 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "SayIt",
+ "mainBinaryName": "sayit-desktop",
"version": "0.1.0",
"identifier": "dev.sayit.linux",
"build": {
"frontendDist": "../app/build",
"devUrl": "http://localhost:1420",
"beforeDevCommand": "npm --prefix app run dev",
- "beforeBuildCommand": "npm --prefix app run build"
+ "beforeBuildCommand": "npm --prefix app run build",
+ "beforeBundleCommand": "sh scripts/prepare-sidecar-bundle.sh"
},
"app": {
"withGlobalTauri": true,
@@ -28,8 +30,10 @@
},
"bundle": {
"active": true,
- "targets": ["deb", "appimage"],
+ "targets": ["deb", "rpm"],
"category": "Utility",
+ "homepage": "https://github.com/ildella/sayit",
+ "licenseFile": "../LICENSE",
"shortDescription": "Private, local text-to-speech",
"longDescription": "Say It turns copied text into speech with open models running locally.",
"icon": [
@@ -38,6 +42,18 @@
"icons/256x256.png",
"icons/icon.png",
"icons/icon.ico"
- ]
+ ],
+ "resources": {
+ "sidecar-bundle/": "sidecar/"
+ },
+ "linux": {
+ "deb": {
+ "depends": ["nodejs", "mpv"],
+ "recommends": ["wl-clipboard", "xclip", "xsel"]
+ },
+ "rpm": {
+ "depends": ["nodejs", "mpv"]
+ }
+ }
}
}
From 052b5b5d318450bf7c753d8fb47afd6307561b2e Mon Sep 17 00:00:00 2001
From: Daniele Dellafiore <66707+ildella@users.noreply.github.com>
Date: Thu, 10 Sep 2026 13:55:32 +0200
Subject: [PATCH 3/6] new flows
---
.github/workflows/ci-linux.yml | 14 +++---
.github/workflows/ci-macos.yml | 2 +-
.github/workflows/ci-windows.yml | 2 +-
.github/workflows/release-linux.yml | 72 +++++++++++++++++++++++++++++
AGENTS.md | 2 +-
README.md | 3 +-
package.json | 4 +-
src-tauri/sidecar-bundle/.gitkeep | 0
src-tauri/tauri.conf.json | 2 +-
9 files changed, 88 insertions(+), 13 deletions(-)
create mode 100644 .github/workflows/release-linux.yml
delete mode 100644 src-tauri/sidecar-bundle/.gitkeep
diff --git a/.github/workflows/ci-linux.yml b/.github/workflows/ci-linux.yml
index 6308121..82ae729 100644
--- a/.github/workflows/ci-linux.yml
+++ b/.github/workflows/ci-linux.yml
@@ -14,20 +14,20 @@ concurrency:
jobs:
build:
- name: build (ubuntu-22.04)
- runs-on: ubuntu-22.04
+ name: build (ubuntu-24.04)
+ runs-on: ubuntu-24.04
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v7
- name: Install Linux WebKitGTK deps
run: |
sudo apt-get update
- sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf
+ sudo apt-get install -y libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev patchelf
- - uses: actions/setup-node@v4
+ - uses: actions/setup-node@v7
with:
- node-version: 20
+ node-version: 26
cache: npm
cache-dependency-path: |
package-lock.json
@@ -48,4 +48,4 @@ jobs:
run: npm run build:ci
- name: Check release binary
- run: test -f src-tauri/target/release/sayit
+ run: test -f src-tauri/target/release/sayit-desktop
diff --git a/.github/workflows/ci-macos.yml b/.github/workflows/ci-macos.yml
index 2ecbd73..8825f65 100644
--- a/.github/workflows/ci-macos.yml
+++ b/.github/workflows/ci-macos.yml
@@ -43,4 +43,4 @@ jobs:
run: npm run build:ci
- name: Check release binary
- run: test -f src-tauri/target/release/sayit
+ run: test -f src-tauri/target/release/sayit-desktop
diff --git a/.github/workflows/ci-windows.yml b/.github/workflows/ci-windows.yml
index dad910b..1cccc92 100644
--- a/.github/workflows/ci-windows.yml
+++ b/.github/workflows/ci-windows.yml
@@ -43,4 +43,4 @@ jobs:
run: npm run build:ci
- name: Check release binary
- run: test -f src-tauri/target/release/sayit.exe
+ run: test -f src-tauri/target/release/sayit-desktop.exe
diff --git a/.github/workflows/release-linux.yml b/.github/workflows/release-linux.yml
new file mode 100644
index 0000000..e0d4649
--- /dev/null
+++ b/.github/workflows/release-linux.yml
@@ -0,0 +1,72 @@
+# Build Linux GUI packages with the Node sidecar inside.
+# PR CI stays --no-bundle; this job is tags (and manual dispatch) only.
+name: release-linux
+
+on:
+ push:
+ tags: ["v*"]
+ workflow_dispatch:
+
+permissions:
+ contents: write
+
+concurrency:
+ group: release-linux-${{ github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ linux:
+ name: deb + rpm (ubuntu-24.04)
+ runs-on: ubuntu-24.04
+
+ steps:
+ - uses: actions/checkout@v7
+
+ - name: Install Linux WebKitGTK + rpm tooling
+ run: |
+ sudo apt-get update
+ sudo apt-get install -y libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev patchelf rpm
+
+ - uses: actions/setup-node@v7
+ with:
+ node-version: 26
+ cache: npm
+ cache-dependency-path: |
+ package-lock.json
+ app/package-lock.json
+ sidecar/package-lock.json
+
+ - uses: dtolnay/rust-toolchain@stable
+
+ - uses: swatinem/rust-cache@v2
+ with:
+ workspaces: ./src-tauri -> target
+
+ - name: Install JS deps
+ run: |
+ npm ci
+ npm --prefix app ci
+
+ - name: Bundle deb and rpm
+ run: npx tauri build -b deb,rpm
+
+ - uses: actions/upload-artifact@v7
+ with:
+ name: linux-packages
+ path: |
+ src-tauri/target/release/bundle/deb/*.deb
+ src-tauri/target/release/bundle/rpm/*.rpm
+
+ - name: Publish GitHub Release
+ if: startsWith(github.ref, 'refs/tags/')
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ run: |
+ gh release create "${{ github.ref_name }}" \
+ src-tauri/target/release/bundle/deb/*.deb \
+ src-tauri/target/release/bundle/rpm/*.rpm \
+ --generate-notes --verify-tag \
+ || gh release upload "${{ github.ref_name }}" \
+ src-tauri/target/release/bundle/deb/*.deb \
+ src-tauri/target/release/bundle/rpm/*.rpm \
+ --clobber
diff --git a/AGENTS.md b/AGENTS.md
index 858af33..74c4427 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -7,7 +7,7 @@ Crucial reminders for future sessions. See LINUX.md for full architecture.
- **Node ≥ 20**, Rust, **mpv** (playback), `wl-paste`/`xclip`/`xsel` (clipboard).
- Root deps: `@tauri-apps/cli`. App and sidecar have their own `package.json`.
- Dev: `npm run dev` starts sidecar + `tauri dev`. `beforeDevCommand` runs from project root with `--prefix app`.
-- CI: `npm run build:ci` (`tauri build --no-bundle`) on PRs. Linux `.deb`/`.rpm` (sidecar inside) come from `npm run build:linux` / the tag workflow. `beforeBundleCommand` stages `src-tauri/sidecar-bundle/`.
+- CI: `npm run build:ci` (`tauri build --no-bundle`) on PRs (Ubuntu 24.04, Node 26). Local GUI package is `npm run build:linux` (`.deb` only). `npm run build:appimage` / `npm run build:rpm` for the other formats. Tag workflow still ships deb+rpm. `beforeBundleCommand` stages `src-tauri/sidecar-bundle/`.
- GUI binary is `sayit-desktop`. CLI remains `sayit`.
- Sidecar search order: `$SAYIT_SIDECAR_DIR` → bundled resources → `~/.local/share/sayit/sidecar`.
diff --git a/README.md b/README.md
index c98fb66..055bfa2 100644
--- a/README.md
+++ b/README.md
@@ -152,7 +152,8 @@ If a daemon is already listening on port 7878, Tauri connects to it instead
of spawning a second one.
```sh
-npm run build:linux # .deb + .rpm with sidecar inside → sayit-desktop
+npm run build:linux # .deb with sidecar inside → sayit-desktop
+npm run build:appimage # AppImage (no sudo; good for a local smoke test)
npm run build:ci # compile the shell, skip installers (PR CI)
```
diff --git a/package.json b/package.json
index 0831711..54dbcef 100644
--- a/package.json
+++ b/package.json
@@ -8,7 +8,9 @@
"dev": "npm --prefix sidecar run start & npm run tauri dev",
"build:app": "npm --prefix app run build",
"build:ci": "tauri build --no-bundle --ci",
- "build:linux": "tauri build -b deb,rpm",
+ "build:linux": "tauri build -b deb",
+ "build:appimage": "tauri build -b appimage",
+ "build:rpm": "tauri build -b rpm",
"setup": "sh scripts/setup-sidecar.sh",
"tauri": "tauri"
},
diff --git a/src-tauri/sidecar-bundle/.gitkeep b/src-tauri/sidecar-bundle/.gitkeep
deleted file mode 100644
index e69de29..0000000
diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json
index f4a32b1..24e5987 100644
--- a/src-tauri/tauri.conf.json
+++ b/src-tauri/tauri.conf.json
@@ -30,7 +30,7 @@
},
"bundle": {
"active": true,
- "targets": ["deb", "rpm"],
+ "targets": ["deb"],
"category": "Utility",
"homepage": "https://github.com/ildella/sayit",
"licenseFile": "../LICENSE",
From a8f79d0c9663c91d8979593e42a778d00a4be55c Mon Sep 17 00:00:00 2001
From: Daniele Dellafiore <66707+ildella@users.noreply.github.com>
Date: Thu, 10 Sep 2026 14:17:05 +0200
Subject: [PATCH 4/6] ci: AppImage on Linux, mkdir sidecar-bundle
Tauri requires bundle.resources at compile time, before
beforeBundleCommand. Linux CI builds AppImage only; prune
host-only ONNX so linuxdeploy does not pull CUDA.
---
.github/workflows/ci-linux.yml | 17 ++++++++++-------
README.md | 3 +--
scripts/prepare-sidecar-bundle.sh | 17 +++++++++++++++++
src-tauri/tauri.conf.json | 11 +----------
4 files changed, 29 insertions(+), 19 deletions(-)
diff --git a/.github/workflows/ci-linux.yml b/.github/workflows/ci-linux.yml
index 82ae729..b7e77b6 100644
--- a/.github/workflows/ci-linux.yml
+++ b/.github/workflows/ci-linux.yml
@@ -1,5 +1,4 @@
-# Compile the Tauri shell on Linux.
-# PRs stay --no-bundle (fast). Linux installers are `.github/workflows/release-linux.yml`.
+# Linux CI: AppImage with sidecar inside. macOS/Windows stay --no-bundle.
name: ci-linux
on:
@@ -12,9 +11,12 @@ concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
+env:
+ APPIMAGE_EXTRACT_AND_RUN: "1"
+
jobs:
build:
- name: build (ubuntu-24.04)
+ name: appimage (ubuntu-24.04)
runs-on: ubuntu-24.04
steps:
@@ -32,6 +34,7 @@ jobs:
cache-dependency-path: |
package-lock.json
app/package-lock.json
+ sidecar/package-lock.json
- uses: dtolnay/rust-toolchain@stable
@@ -44,8 +47,8 @@ jobs:
npm ci
npm --prefix app ci
- - name: Compile shell (no bundle)
- run: npm run build:ci
+ - name: Bundle AppImage
+ run: npm run build:appimage
- - name: Check release binary
- run: test -f src-tauri/target/release/sayit-desktop
+ - name: Check AppImage
+ run: ls -lh src-tauri/target/release/bundle/appimage/*.AppImage
diff --git a/README.md b/README.md
index 055bfa2..9c6a3ff 100644
--- a/README.md
+++ b/README.md
@@ -68,8 +68,7 @@ and selected.
### Desktop app
Download the `.deb` or `.rpm` from
-[Releases](https://github.com/ildella/sayit/releases) (needs Node ≥ 20 and
-mpv on the system):
+[Releases](https://github.com/ildella/sayit/releases):
```sh
sudo apt install ./SayIt_*_amd64.deb
diff --git a/scripts/prepare-sidecar-bundle.sh b/scripts/prepare-sidecar-bundle.sh
index 2d4332e..a2ab95e 100755
--- a/scripts/prepare-sidecar-bundle.sh
+++ b/scripts/prepare-sidecar-bundle.sh
@@ -29,4 +29,21 @@ if [ ! -f "$DEST/src/index.js" ]; then
exit 1
fi
+# onnxruntime-node ships every OS/arch plus CUDA plugins. linuxdeploy walks
+# those ELF files, then dies on libcublasLt. Keep this host only.
+case "$(uname -m)" in
+ x86_64) ort_arch=x64 ;;
+ aarch64|arm64) ort_arch=arm64 ;;
+ *) ort_arch=x64 ;;
+esac
+ort="$DEST/node_modules/onnxruntime-node/bin/napi-v3"
+if [ -d "$ort" ]; then
+ find "$ort" -mindepth 1 -maxdepth 1 -type d ! -name linux -exec rm -rf {} +
+ if [ -d "$ort/linux" ]; then
+ find "$ort/linux" -mindepth 1 -maxdepth 1 -type d ! -name "$ort_arch" -exec rm -rf {} +
+ fi
+ rm -f "$ort/linux/$ort_arch"/libonnxruntime_providers_cuda.so \
+ "$ort/linux/$ort_arch"/libonnxruntime_providers_tensorrt.so
+fi
+
echo "prepare-sidecar-bundle: $DEST"
diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json
index 24e5987..6c810c4 100644
--- a/src-tauri/tauri.conf.json
+++ b/src-tauri/tauri.conf.json
@@ -8,7 +8,7 @@
"frontendDist": "../app/build",
"devUrl": "http://localhost:1420",
"beforeDevCommand": "npm --prefix app run dev",
- "beforeBuildCommand": "npm --prefix app run build",
+ "beforeBuildCommand": "node -e \"require('fs').mkdirSync('src-tauri/sidecar-bundle',{recursive:true})\" && npm --prefix app run build",
"beforeBundleCommand": "sh scripts/prepare-sidecar-bundle.sh"
},
"app": {
@@ -45,15 +45,6 @@
],
"resources": {
"sidecar-bundle/": "sidecar/"
- },
- "linux": {
- "deb": {
- "depends": ["nodejs", "mpv"],
- "recommends": ["wl-clipboard", "xclip", "xsel"]
- },
- "rpm": {
- "depends": ["nodejs", "mpv"]
- }
}
}
}
From 4e0bd13f67f1036ca7365fdd8ea6a17b8bc5d3e3 Mon Sep 17 00:00:00 2001
From: Daniele Dellafiore <66707+ildella@users.noreply.github.com>
Date: Thu, 10 Sep 2026 14:21:28 +0200
Subject: [PATCH 5/6] fix(ci): Windows resource path for sidecar-bundle
Trailing slash makes Path::exists false on Windows. Keep a
tracked .keep so the dir is in the checkout.
---
.github/workflows/ci-windows.yml | 4 ++++
src-tauri/sidecar-bundle/.keep | 0
src-tauri/tauri.conf.json | 2 +-
3 files changed, 5 insertions(+), 1 deletion(-)
create mode 100644 src-tauri/sidecar-bundle/.keep
diff --git a/.github/workflows/ci-windows.yml b/.github/workflows/ci-windows.yml
index 1cccc92..8370a22 100644
--- a/.github/workflows/ci-windows.yml
+++ b/.github/workflows/ci-windows.yml
@@ -39,6 +39,10 @@ jobs:
npm ci
npm --prefix app ci
+ - name: Ensure sidecar-bundle exists
+ shell: bash
+ run: mkdir -p src-tauri/sidecar-bundle
+
- name: Compile shell (no bundle)
run: npm run build:ci
diff --git a/src-tauri/sidecar-bundle/.keep b/src-tauri/sidecar-bundle/.keep
new file mode 100644
index 0000000..e69de29
diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json
index 6c810c4..2b326e5 100644
--- a/src-tauri/tauri.conf.json
+++ b/src-tauri/tauri.conf.json
@@ -44,7 +44,7 @@
"icons/icon.ico"
],
"resources": {
- "sidecar-bundle/": "sidecar/"
+ "sidecar-bundle": "sidecar"
}
}
}
From f1e296688931ca210626d33cc09c6b98af139aa9 Mon Sep 17 00:00:00 2001
From: Daniele Dellafiore <66707+ildella@users.noreply.github.com>
Date: Thu, 10 Sep 2026 14:30:19 +0200
Subject: [PATCH 6/6] docs: AppImage as the end-user GUI install
---
README.md | 25 ++++++++++++++-----------
1 file changed, 14 insertions(+), 11 deletions(-)
diff --git a/README.md b/README.md
index 9c6a3ff..f476ab0 100644
--- a/README.md
+++ b/README.md
@@ -67,17 +67,20 @@ and selected.
### Desktop app
-Download the `.deb` or `.rpm` from
-[Releases](https://github.com/ildella/sayit/releases):
+The Linux GUI is an **AppImage**. It embeds the sidecar. Needs **Node ≥ 20**
+and **mpv** on the machine (same as the CLI). No sudo.
+
+Download it from
+[Releases](https://github.com/ildella/sayit/releases) (or the Linux CI
+artifact), then:
```sh
-sudo apt install ./SayIt_*_amd64.deb
-sayit-desktop
+chmod +x SayIt-*.AppImage
+./SayIt-*.AppImage
```
-The menu launcher is **Say It**. The binary is `sayit-desktop` so it does not
-replace the CLI `sayit`. You can run both: whoever starts first owns the
-sidecar; the other connects.
+The window binary is `sayit-desktop`; it does not replace the CLI `sayit`.
+You can run both: whoever starts first owns port 7878; the other connects.
### Terminal
@@ -134,7 +137,7 @@ API for another app's *selection* (not clipboard); on X11 you can point
## Build from source
-Contributors: live UI. Packaging: `npm run build:linux` (embeds the sidecar).
+Contributors: live UI. The end-user GUI package is the AppImage.
You need Rust and [Tauri's prerequisites](https://v2.tauri.app/start/prerequisites/)
as well as Node ≥ 20 and mpv.
@@ -151,9 +154,9 @@ If a daemon is already listening on port 7878, Tauri connects to it instead
of spawning a second one.
```sh
-npm run build:linux # .deb with sidecar inside → sayit-desktop
-npm run build:appimage # AppImage (no sudo; good for a local smoke test)
-npm run build:ci # compile the shell, skip installers (PR CI)
+npm run build:appimage # AppImage with sidecar inside (Linux CI)
+npm run build:linux # .deb (optional)
+npm run build:ci # compile the shell, skip installers (macOS/Windows CI)
```
After pulling updates, re-run `npm run setup` and restart the service so a