diff --git a/.github/workflows/ci-linux.yml b/.github/workflows/ci-linux.yml
index d7bbfa4..b7e77b6 100644
--- a/.github/workflows/ci-linux.yml
+++ b/.github/workflows/ci-linux.yml
@@ -1,6 +1,4 @@
-# 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.
+# Linux CI: AppImage with sidecar inside. macOS/Windows stay --no-bundle.
name: ci-linux
on:
@@ -13,26 +11,30 @@ concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
+env:
+ APPIMAGE_EXTRACT_AND_RUN: "1"
+
jobs:
build:
- name: build (ubuntu-22.04)
- runs-on: ubuntu-22.04
+ name: appimage (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
app/package-lock.json
+ sidecar/package-lock.json
- uses: dtolnay/rust-toolchain@stable
@@ -45,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
+ - name: Check AppImage
+ run: ls -lh src-tauri/target/release/bundle/appimage/*.AppImage
diff --git a/.github/workflows/ci-macos.yml b/.github/workflows/ci-macos.yml
index 55864f3..8825f65 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:
@@ -44,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 799fc63..8370a22 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:
@@ -40,8 +39,12 @@ 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
- 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/.gitignore b/.gitignore
index 35ab0d5..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
@@ -14,3 +18,4 @@ Thumbs.db
.idea/
.vscode/
.codegraph/
+.zcode/
diff --git a/AGENTS.md b/AGENTS.md
index 0ac4d57..74c4427 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 (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`.
## 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 9dc4b24..f476ab0 100644
--- a/README.md
+++ b/README.md
@@ -4,87 +4,107 @@
[](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.
+
+## Highlights
-| 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 |
+- **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.
-No Python anywhere. The sidecar is Node ≥ 20, the UI is Svelte, the shell is Rust.
+## Getting started
-## Platform status
+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.
-| Platform | Status |
-| -------- | ------ |
-| Linux | ✅ Built and tested (X11 & Wayland) |
-| macOS | 🔶 Should build — help wanted |
-| Windows | 🔶 Should build — help wanted |
+### CLI (no window)
-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.
+Needs **Node ≥ 20**, npm, and **mpv** (`aplay` is a limited fallback).
+Clipboard tools (`wl-paste`, `xclip`, or `xsel`) only if you want the hotkey.
-## Quick start
+```sh
+curl -fsSL https://raw.githubusercontent.com/ildella/sayit/master/scripts/install.sh | bash -s -- --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.
+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`.
-### 1. Install
+Then download a model (once, then the app stays offline) and speak:
```sh
-curl -fsSL https://raw.githubusercontent.com/ildella/sayit/master/scripts/install.sh | bash -s -- --systemd
+sayit models install kokoro-q8 --use
+sayit "Hello from Say It"
```
-Or from a clone of this repo:
+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.
+
+### Desktop app
+
+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
-bash scripts/install.sh # sidecar + sayit CLI
-bash scripts/install.sh --systemd # + start automatically at login
+chmod +x SayIt-*.AppImage
+./SayIt-*.AppImage
```
-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.
+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.
-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.
+### Terminal
-### 2. Run the CLI
+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 +125,51 @@ 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.
-
-### 4. Run the desktop app
+running and a model installed before speech works.
-From a clone, after the sidecar is installed (`install.sh` or `npm run setup`):
+### Wayland vs X11
-```sh
-npm install # once: @tauri-apps/cli
-npm --prefix app install # once: SvelteKit UI
-npm run dev # sidecar + Tauri window
-```
-
-`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.
-
-```sh
-npm run tauri build # .deb / AppImage (Linux shell only)
-```
+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.
-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`.
+## Build from source
-### 5. Which engine is running?
+Contributors: live UI. The end-user GUI package is the AppImage.
-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.
+You need Rust and [Tauri's prerequisites](https://v2.tauri.app/start/prerequisites/)
+as well as Node ≥ 20 and mpv.
```sh
-sayit status
-```
-
-Example:
-
-```
-state: idle
-engine: loaded (kokoro-q8)
-sidecar: 0.x.x
+git clone https://github.com/ildella/sayit.git && cd sayit
+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 # live UI (developer loop, not a distribution)
```
-- **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).
-
-List every catalog row and which one is **active**:
+If a daemon is already listening on port 7878, Tauri connects to it instead
+of spawning a second one.
```sh
-sayit models
+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)
```
-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.
+After pulling updates, re-run `npm run setup` and restart the service so a
+CLI install is not talking to a stale sidecar.
## 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 +179,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
diff --git a/package.json b/package.json
index a3dbd56..54dbcef 100644
--- a/package.json
+++ b/package.json
@@ -8,6 +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",
+ "build:appimage": "tauri build -b appimage",
+ "build:rpm": "tauri build -b 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..a2ab95e
--- /dev/null
+++ b/scripts/prepare-sidecar-bundle.sh
@@ -0,0 +1,49 @@
+#!/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
+
+# 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/sidecar-bundle/.keep b/src-tauri/sidecar-bundle/.keep
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..2b326e5 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": "node -e \"require('fs').mkdirSync('src-tauri/sidecar-bundle',{recursive:true})\" && 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"],
"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,9 @@
"icons/256x256.png",
"icons/icon.png",
"icons/icon.ico"
- ]
+ ],
+ "resources": {
+ "sidecar-bundle": "sidecar"
+ }
}
}