Skip to content
Merged
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
20 changes: 12 additions & 8 deletions .github/workflows/ci-linux.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
# Linux CI: AppImage with sidecar inside. macOS/Windows stay --no-bundle.
# Linux CI: full unsigned bundles (deb + rpm + AppImage).
# Tags ship signed artifacts via release-linux.yml.
name: ci-linux

on:
Expand All @@ -16,16 +17,16 @@ env:

jobs:
build:
name: appimage (ubuntu-24.04)
name: deb + rpm + appimage (ubuntu-24.04)
runs-on: ubuntu-24.04

steps:
- uses: actions/checkout@v7

- name: Install Linux WebKitGTK deps
- 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
sudo apt-get install -y libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev patchelf rpm

- uses: actions/setup-node@v7
with:
Expand All @@ -47,8 +48,11 @@ jobs:
npm ci
npm --prefix app ci

- name: Bundle AppImage
run: npm run build:appimage
- name: Bundle Linux packages
run: npx tauri build -b deb,rpm,appimage

- name: Check AppImage
run: ls -lh src-tauri/target/release/bundle/appimage/*.AppImage
- name: Check bundles
run: |
ls -lh src-tauri/target/release/bundle/deb/*.deb
ls -lh src-tauri/target/release/bundle/rpm/*.rpm
ls -lh src-tauri/target/release/bundle/appimage/*.AppImage
43 changes: 17 additions & 26 deletions .github/workflows/release-linux.yml
Original file line number Diff line number Diff line change
@@ -1,11 +1,10 @@
# Build Linux GUI packages with the Node sidecar inside.
# PR CI stays --no-bundle; this job is tags (and manual dispatch) only.
# Tag v* → signed Linux packages + GitHub Release + updater latest.json.
# PR/master CI stays unsigned (ci-linux.yml). Do not merge the two.
name: release-linux

on:
push:
tags: ["v*"]
workflow_dispatch:

permissions:
contents: write
Expand All @@ -14,9 +13,12 @@ concurrency:
group: release-linux-${{ github.ref }}
cancel-in-progress: true

env:
APPIMAGE_EXTRACT_AND_RUN: "1"

jobs:
linux:
name: deb + rpm (ubuntu-24.04)
name: publish (ubuntu-24.04)
runs-on: ubuntu-24.04

steps:
Expand Down Expand Up @@ -47,26 +49,15 @@ jobs:
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/')
- uses: tauri-apps/tauri-action@v1
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
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
with:
tagName: ${{ github.ref_name }}
releaseName: ${{ github.ref_name }}
generateReleaseNotes: true
args: >-
-b deb,rpm,appimage
--config {"bundle":{"createUpdaterArtifacts":true}}
4 changes: 3 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +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 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/`.
- Config is `src-tauri/tauri.conf.json5` (`config-json5` on `tauri` + `tauri-build`). Version is `../package.json`; bump **root `package.json` and `src-tauri/Cargo.toml` together**, then tag `v*`.
- CI Linux (`ci-linux.yml`): unsigned `deb,rpm,appimage` on PRs and `master`. macOS/Windows stay `npm run build:ci` (`tauri build --no-bundle`). Local: `build:linux` (deb), `build:appimage`, `build:rpm`.
- Release: push a `v*` tag → `release-linux.yml` (`tauri-action`, signed AppImage + `latest.json`). Do **not** set `createUpdaterArtifacts` in the config (breaks unsigned PR CI). Sign with the Say It key (`~/.tauri/sayit.key`), never the Nucube `TAURI_SIGNING_*` env. `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`.

Expand Down
15 changes: 12 additions & 3 deletions LINUX.md
Original file line number Diff line number Diff line change
Expand Up @@ -153,13 +153,22 @@ disk. Anything that needs the API should resolve the token the same way.
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
- Config is `src-tauri/tauri.conf.json5`. App version is `version: '../package.json'`;
bump root `package.json` and `src-tauri/Cargo.toml` together before a `v*` tag.
- CSP in `tauri.conf.json5` 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.
- Auto-update is **AppImage only** (`tauri-plugin-updater`). Push a `v*`
tag; `release-linux.yml` runs `tauri-action`, which signs the AppImage
(CI secrets `TAURI_SIGNING_PRIVATE_KEY` / `_PASSWORD` — not another
project's global env), creates the GitHub Release, and uploads
`latest.json`. `createUpdaterArtifacts` is only in that job's `--config`
so PR AppImage CI stays unsigned. Settings → Check for updates is
user-initiated; it is not telemetry.

## 8. Known limitations (vs the original)

Expand All @@ -183,7 +192,7 @@ disk. Anything that needs the API should resolve the token the same way.
- **engine.js is a boundary** — engine-agnostic interface out, kokoro-js in.
- **Models come from the catalog** — do not hardcode Hugging Face ids in the UI.
- **Offline after first model download**; no analytics, no telemetry, no
passive clipboard monitoring (the original's privacy posture is part of
the product).
passive clipboard monitoring. AppImage update checks hit GitHub only
when the user clicks Check for updates.
- CLI command surface stays compatible with the original where the feature
exists.
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,8 +71,7 @@ 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:
[Releases](https://github.com/ildella/sayit/releases), then:

```sh
chmod +x SayIt-*.AppImage
Expand All @@ -82,6 +81,9 @@ chmod +x SayIt-*.AppImage
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.

Auto-update (Settings → Check for updates) only applies to the AppImage.
`.deb` / `.rpm` stay distro packages.

### Terminal

The install includes a `sayit` CLI for speech, playback, models, and
Expand Down
33 changes: 33 additions & 0 deletions app/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,5 +14,10 @@
"@sveltejs/vite-plugin-svelte": "^5.0.0",
"svelte": "^5.19.0",
"vite": "^6.0.0"
},
"dependencies": {
"@tauri-apps/api": "^2.11.1",
"@tauri-apps/plugin-process": "^2.3.1",
"@tauri-apps/plugin-updater": "^2.11.0"
}
}
80 changes: 78 additions & 2 deletions app/src/routes/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,18 @@
let text = $state('');
let voice = $state('af_heart');
let speed = $state(1.0);

onMount(() => { initStore(); });
let appVersion = $state('');
let updatePhase = $state('idle');
let updateInfo = $state(null);
let downloadPct = $state(0);

onMount(() => {
initStore();
import('@tauri-apps/api/app')
.then(({ getVersion }) => getVersion())
.then((v) => { appVersion = v; })
.catch(() => {});
});

// Fill the speak-page pickers once voices arrive. Do not assign only in
// onMount: a second initStore() used to return before voices existed, so
Expand Down Expand Up @@ -114,6 +124,50 @@
await api.deleteEntry(id);
appState.history = appState.history.filter((e) => e.id !== id);
}

async function checkForUpdates() {
updatePhase = 'checking';
updateInfo = null;
try {
const { check } = await import('@tauri-apps/plugin-updater');
const update = await check();
if (!update) {
updatePhase = 'none';
return;
}
updateInfo = update;
updatePhase = 'available';
} catch (err) {
updatePhase = 'error';
appState.error = err.message;
}
}

async function installUpdate() {
if (!updateInfo) return;
updatePhase = 'downloading';
downloadPct = 0;
try {
let downloaded = 0;
let total = 0;
await updateInfo.downloadAndInstall((event) => {
if (event.event === 'Started') total = event.data.contentLength ?? 0;
if (event.event === 'Progress') {
downloaded += event.data.chunkLength;
downloadPct = total ? Math.min(100, Math.round((downloaded / total) * 100)) : 0;
}
if (event.event === 'Finished') {
downloadPct = 100;
updatePhase = 'installing';
}
});
const { relaunch } = await import('@tauri-apps/plugin-process');
await relaunch();
} catch (err) {
updatePhase = 'error';
appState.error = err.message;
}
}
</script>

<div class="shell">
Expand Down Expand Up @@ -342,6 +396,28 @@
/>
</label>

{#if appVersion}
<h3>Updates</h3>
<p class="dim">AppImage only. Version {appVersion}. Checks GitHub Releases when you ask — nothing is sent in the background.</p>
{#if updatePhase === 'idle' || updatePhase === 'error'}
<button onclick={checkForUpdates}>Check for updates</button>
{:else if updatePhase === 'checking'}
<p class="dim">Checking…</p>
{:else if updatePhase === 'none'}
<p class="dim">You are on the latest version.</p>
<button onclick={checkForUpdates}>Check again</button>
{:else if updatePhase === 'available'}
<p>Version {updateInfo.version} is available.</p>
{#if updateInfo.body}<p class="dim">{updateInfo.body}</p>{/if}
<button class="primary" onclick={installUpdate}>Download and install</button>
{:else if updatePhase === 'downloading'}
<p class="dim">Downloading… {downloadPct}%</p>
<div class="track"><div class="fill" style:width="{downloadPct}%"></div></div>
{:else if updatePhase === 'installing'}
<p class="dim">Installing… the app will restart.</p>
{/if}
{/if}

<h3>Hotkeys & clipboard</h3>
<p class="dim">
<kbd>Ctrl</kbd>+<kbd>Alt</kbd>+<kbd>V</kbd> speaks the clipboard from anywhere
Expand Down
Loading
Loading