diff --git a/.github/workflows/ci-windows.yml b/.github/workflows/ci-windows.yml index 8370a22..53864ea 100644 --- a/.github/workflows/ci-windows.yml +++ b/.github/workflows/ci-windows.yml @@ -1,5 +1,5 @@ -# Compile the Tauri shell on Windows. -# PRs stay --no-bundle. Windows installers are not shipped yet. +# Windows CI: unsigned MSI. +# Tags ship signed artifacts via release-windows.yml. name: ci-windows on: @@ -14,19 +14,20 @@ concurrency: jobs: build: - name: build (windows-latest) + name: msi (windows-latest) runs-on: windows-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - - 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 @@ -39,12 +40,9 @@ 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: Bundle MSI + run: npx tauri build -b msi - - name: Check release binary - run: test -f src-tauri/target/release/sayit-desktop.exe + - name: Check MSI + shell: bash + run: ls -lh src-tauri/target/release/bundle/msi/*.msi diff --git a/.github/workflows/release-windows.yml b/.github/workflows/release-windows.yml new file mode 100644 index 0000000..a64a1d9 --- /dev/null +++ b/.github/workflows/release-windows.yml @@ -0,0 +1,55 @@ +# Tag v* → signed MSI + GitHub Release assets (merges into latest.json). +# PR/master CI stays unsigned (ci-windows.yml). Do not merge the two. +name: release-windows + +on: + push: + tags: ["v*"] + +permissions: + contents: write + +concurrency: + group: release-windows-${{ github.ref }} + cancel-in-progress: true + +jobs: + windows: + name: publish (windows-latest) + runs-on: windows-latest + + steps: + - uses: actions/checkout@v7 + + - 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 + + - uses: tauri-apps/tauri-action@v1 + env: + 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 msi + --config {"bundle":{"createUpdaterArtifacts":true}} diff --git a/AGENTS.md b/AGENTS.md index eb1f24f..c26b4fd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,8 +8,8 @@ Crucial reminders for future sessions. See LINUX.md for full architecture. - 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`. - 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/`. +- CI Linux (`ci-linux.yml`): unsigned `deb,rpm,appimage` on PRs and `master`. CI Windows (`ci-windows.yml`): unsigned `msi`. macOS stays `npm run build:ci` (`tauri build --no-bundle`). Local: `build:linux` (deb), `build:appimage`, `build:rpm`, `build:msi` (Windows host). +- Release: push a `v*` tag → `release-linux.yml` (signed AppImage + `latest.json`) and `release-windows.yml` (signed MSI, merges into the same release / `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` runs `node scripts/prepare-sidecar-bundle.js` (keeps this-host onnxruntime only). - GUI binary is `sayit-desktop`. CLI remains `sayit`. - Sidecar search order: `$SAYIT_SIDECAR_DIR` → bundled resources → `~/.local/share/sayit/sidecar`. diff --git a/LINUX.md b/LINUX.md index ea3c950..d5c14e4 100644 --- a/LINUX.md +++ b/LINUX.md @@ -157,18 +157,21 @@ disk. Anything that needs the API should resolve the token the same way. 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: +- GUI packages run `node scripts/prepare-sidecar-bundle.js` then embed that + tree as `bundle.resources` → `sidecar/`. The script keeps only this-host + onnxruntime binaries (linux / darwin / win32). 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. +- Auto-update is **AppImage on Linux, MSI on Windows** (`tauri-plugin-updater`). + Push a `v*` tag; `release-linux.yml` and `release-windows.yml` run + `tauri-action`, which signs the bundles (CI secrets + `TAURI_SIGNING_PRIVATE_KEY` / `_PASSWORD` — not another project's global + env), creates/updates the GitHub Release, and merges `latest.json`. + `createUpdaterArtifacts` is only in those jobs' `--config` so PR CI stays + unsigned. Settings → Check for updates is user-initiated; it is not + telemetry. The Windows MSI is experimental: sidecar still needs Node on + PATH and mpv; playback/clipboard are Linux-oriented. ## 8. Known limitations (vs the original) @@ -180,9 +183,9 @@ disk. Anything that needs the API should resolve the token the same way. its phonemizer WASM is English-only, so those ids fail at generate. The `VOICES` table in `engine.js` lists what actually works. 5. Single in-flight job; no queue (§3). -6. Linux only — nothing here is tested on macOS/Windows, though the sidecar - and CLI are platform-agnostic in principle (mpv/aplay are the - platform-specific bits). +6. Linux is the tested product. Windows CI ships an experimental MSI; macOS + still compiles without bundling. Sidecar/CLI are platform-agnostic in + principle (mpv/aplay and clipboard tools are the Linux-specific bits). ## 9. Invariants — do not break these @@ -193,6 +196,6 @@ disk. Anything that needs the API should resolve the token the same way. - **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. AppImage update checks hit GitHub only - when the user clicks Check for updates. + when the user clicks Check for updates (AppImage / Windows MSI). - CLI command surface stays compatible with the original where the feature exists. diff --git a/README.md b/README.md index bef9512..af2cb8e 100644 --- a/README.md +++ b/README.md @@ -16,8 +16,9 @@ replacing Apple-specific layers with Tauri, Svelte, and kokoro-js. Say It desktop app — Speak tab

-Linux (X11 and Wayland) is built and tested. macOS and Windows should compile; -help wanted. +Linux (X11 and Wayland) is built and tested. Windows CI builds an experimental +MSI (needs Node and mpv on the machine; playback is not a full Windows port). +macOS should compile; help wanted. ## Highlights @@ -68,7 +69,9 @@ 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. +and **mpv** on the machine (same as the CLI). No sudo. Tags also publish an +experimental **Windows MSI** (same Node + mpv requirement; not a full Windows +port). Download it from [Releases](https://github.com/ildella/sayit/releases), then: @@ -81,8 +84,8 @@ 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. +Auto-update (Settings → Check for updates) applies to the AppImage and the +experimental Windows MSI. `.deb` / `.rpm` stay distro packages. ### Terminal @@ -158,7 +161,8 @@ of spawning a second one. ```sh 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) +npm run build:msi # MSI with sidecar inside (Windows CI; Windows host) +npm run build:ci # compile the shell, skip installers (macOS CI) ``` After pulling updates, re-run `npm run setup` and restart the service so a diff --git a/package.json b/package.json index 934d44c..fd76ac8 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "build:linux": "tauri build -b deb", "build:appimage": "tauri build -b appimage", "build:rpm": "tauri build -b rpm", + "build:msi": "tauri build -b msi", "setup": "sh scripts/setup-sidecar.sh", "tauri": "tauri" }, diff --git a/scripts/prepare-sidecar-bundle.js b/scripts/prepare-sidecar-bundle.js new file mode 100644 index 0000000..f73b9d5 --- /dev/null +++ b/scripts/prepare-sidecar-bundle.js @@ -0,0 +1,77 @@ +#!/usr/bin/env node +// Stage a production sidecar tree for Tauri resources (GUI packages). +// Invoked by beforeBundleCommand — not by `tauri dev` or `build:ci`. +'use strict'; + +const { spawnSync } = require('node:child_process'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); + +const ROOT = path.resolve(__dirname, '..'); +const SRC = path.join(ROOT, 'sidecar'); +const DEST = path.join(ROOT, 'src-tauri', 'sidecar-bundle'); + +function fail(msg) { + console.error(`prepare-sidecar-bundle: ${msg}`); + process.exit(1); +} + +if (!fs.existsSync(path.join(SRC, 'package.json')) || !fs.existsSync(path.join(SRC, 'package-lock.json'))) { + fail('missing sidecar/package.json or package-lock.json'); +} + +fs.rmSync(DEST, { recursive: true, force: true }); +fs.mkdirSync(DEST, { recursive: true }); +fs.copyFileSync(path.join(SRC, 'package.json'), path.join(DEST, 'package.json')); +fs.copyFileSync(path.join(SRC, 'package-lock.json'), path.join(DEST, 'package-lock.json')); +fs.cpSync(path.join(SRC, 'src'), path.join(DEST, 'src'), { recursive: true }); + +const emptyNpmrc = path.join(os.tmpdir(), 'sayit-empty-npmrc'); +fs.writeFileSync(emptyNpmrc, ''); + +const npm = process.platform === 'win32' ? 'npm.cmd' : 'npm'; +const ci = spawnSync(npm, ['ci', '--omit=dev', `--userconfig=${emptyNpmrc}`], { + cwd: DEST, + stdio: 'inherit', + env: { ...process.env, npm_config_allow_scripts: '' }, + shell: process.platform === 'win32', +}); +if (ci.status !== 0) fail('npm ci failed'); + +if (!fs.existsSync(path.join(DEST, 'src', 'index.js'))) { + fail('src/index.js missing after copy'); +} + +// onnxruntime-node ships every OS/arch plus CUDA plugins. linuxdeploy walks +// those ELF files, then dies on libcublasLt. Keep this host only. +const ortOs = process.platform; +const ortArch = process.arch === 'arm64' ? 'arm64' : 'x64'; +const ort = path.join(DEST, 'node_modules', 'onnxruntime-node', 'bin', 'napi-v3'); +if (fs.existsSync(ort)) { + for (const name of fs.readdirSync(ort)) { + const p = path.join(ort, name); + if (fs.statSync(p).isDirectory() && name !== ortOs) { + fs.rmSync(p, { recursive: true, force: true }); + } + } + const osDir = path.join(ort, ortOs); + if (fs.existsSync(osDir)) { + for (const name of fs.readdirSync(osDir)) { + const p = path.join(osDir, name); + if (fs.statSync(p).isDirectory() && name !== ortArch) { + fs.rmSync(p, { recursive: true, force: true }); + } + } + const binDir = path.join(osDir, ortArch); + if (fs.existsSync(binDir)) { + for (const f of fs.readdirSync(binDir)) { + if (/onnxruntime_providers_(cuda|tensorrt)/i.test(f)) { + fs.rmSync(path.join(binDir, f), { force: true }); + } + } + } + } +} + +console.log(`prepare-sidecar-bundle: ${DEST}`); diff --git a/scripts/prepare-sidecar-bundle.sh b/scripts/prepare-sidecar-bundle.sh index a2ab95e..4f5b414 100755 --- a/scripts/prepare-sidecar-bundle.sh +++ b/scripts/prepare-sidecar-bundle.sh @@ -1,49 +1,5 @@ #!/bin/sh -# Stage a production sidecar tree for Tauri resources (GUI .deb / .rpm). -# Invoked by beforeBundleCommand — not by `tauri dev` or `build:ci`. +# Thin wrapper so existing `sh scripts/prepare-sidecar-bundle.sh` calls still work. 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" +exec node "$ROOT/scripts/prepare-sidecar-bundle.js" diff --git a/src-tauri/tauri.conf.json5 b/src-tauri/tauri.conf.json5 index 1c6ea5d..65bac33 100644 --- a/src-tauri/tauri.conf.json5 +++ b/src-tauri/tauri.conf.json5 @@ -10,7 +10,7 @@ devUrl: "http://localhost:1420", beforeDevCommand: "npm --prefix app run dev", beforeBuildCommand: "node -e \"require('fs').mkdirSync('src-tauri/sidecar-bundle',{recursive:true})\" && npm --prefix app run build", - beforeBundleCommand: "sh scripts/prepare-sidecar-bundle.sh", + beforeBundleCommand: "node scripts/prepare-sidecar-bundle.js", }, app: { withGlobalTauri: true,