The "Compute SHA-256 checksums" step used `mapfile -t` (a bash 4+ builtin) but
macOS GitHub runners execute `shell: bash` as /bin/bash 3.2, which has no
`mapfile`. The step exited 127 ("mapfile: command not found") on the macOS leg,
so `SHA256SUMS-macOS Apple Silicon.txt` was never produced/uploaded for v0.3.1
and v0.3.2 (the binaries themselves shipped fine; only the macOS checksum file
was missing and had to be regenerated by hand each time).
Replace `mapfile` with a portable `while IFS= read -r … done < <(find … | sort)`
loop (works on bash 3.2). Verified on bash 3.2.57: builds the array correctly,
handles spaces in bundle filenames. Linux/Windows legs are unaffected.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Phase A: stamp each preview build with a unique monotonic semver prerelease
(<base>-preview.<run_number>) via an ephemeral tauri.conf.json rewrite on the
preview path. Today every preview reported the static 0.3.0, so the updater
never saw a newer version and never delivered preview updates. The prerelease
ordering makes each new preview offer-able and converges to stable when <base>
ships. (Windows MSI ProductVersion strips the prerelease — caveat noted to
verify; mac/linux unaffected.)
Phase B (rollback) is captured as a design spec for review, not implemented:
per-version preview releases + retention, an in-app Preview-builds picker, an
allow_downgrades install path, and the alembic-head data-safety boundary.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The APPLE_CERTIFICATE secret is currently set-but-invalid, so tauri-action's
'security import' fails and kills the whole macOS build — on stable v* releases
too, not just preview. Make Developer-ID signing OPT-IN: pass the Apple creds
only on a v* tag push AND when the repo variable MACOS_SIGNING_ENABLED == 'true'.
Otherwise pass empty -> the build stays unsigned and succeeds (users clear
quarantine via xattr -cr, as documented). Preview is always unsigned.
To re-enable signed stable releases: fix the signing secrets, then set
MACOS_SIGNING_ENABLED=true (Settings -> Secrets and variables -> Actions ->
Variables). No code change needed to flip it.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The first preview build surfaced four real release-pipeline issues (all of which
also affect a stable v* release):
- macOS: build died at codesign — `security import: failed to import keychain
certificate` (the APPLE_CERTIFICATE secret is set but invalid). Preview now
force-skips Apple signing (passes empty creds) so it can't fail on a bad/absent
cert; stable v* tags still receive the secrets, so signing engages once the
cert is fixed.
- Linux: .deb bundling fails with "Failed to create control scripts: No such
file or directory" (no custom deb config of ours). Drop .deb, ship AppImage
only — the universal Linux format and the Linux auto-update target.
- Installer smoke (all 3 OSes): the steps hunted for a frozen backend binary to
boot with --health-check, but the thin uv-venv installer ships no such binary
(the venv builds on first launch). Rewrite to structural verification —
assert the bundle carries the shell binary + bundled uv sidecar + backend
source resources (pyproject.toml + backend/main.py).
Also: a new preview-notes job regenerates the rolling preview release body with
GitHub's auto-generated notes (What's Changed by PR + New Contributors + Full
Changelog) plus a Contributors avatar strip built from the PR authors — instead
of the bare "Auto-generated release for main…" fallback. Runs once after the
matrix, preview-only; stable keeps its CHANGELOG section + appended checksums.
Stable v* tag-push behavior is otherwise unchanged. YAML validated.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds a user-selectable updater release channel (Settings -> About -> Update
channel). Stable (default, every install + launch) tracks tagged vX.Y.Z
releases; Preview tracks the latest main build via a rolling "preview"
prerelease, falling back to stable if a stable release is ahead.
Why Rust: tauri-plugin-updater reads its endpoints from tauri.conf.json and
neither the JS check() nor the plugin's registration Builder can change them at
runtime (verified against the 2.10.1 source). The only runtime-endpoint API is
UpdaterExt::endpoints, so check+install move into two Rust commands that mirror
the plugin's own check/download_and_install -- the Stable path behaves
identically to the JS flow it replaces; only which manifest is consulted
changes. Switching is instant (channel is read per check), no restart.
backend (Rust):
- config.rs: update_channel field (default "stable", VALID_CHANNELS) +
get/set_update_channel commands.
- updater_channel.rs: channel_endpoints() (preview -> [preview, stable]) +
check_update / install_update commands; install emits update://progress.
frontend:
- utils/updateChannel.js (+test): single source of truth, normalizeChannel.
- utils/updater.js: routes the badge flow (#198) through the Rust commands via
the same store contract -- UpdateBadge/App.jsx unchanged.
- Settings About: Stable/Preview segmented toggle, channel-aware endpoint row +
diagnostics; Check-for-updates honors the live channel.
- i18n en + zh-CN.
release.yml: additive, workflow_dispatch-guarded preview publish to a rolling
"preview" prerelease. The v* tag-push stable path evaluates to its exact prior
values (verified) and is never affected. Preview builds are manual -- no
scheduled CI spend, nothing auto-published.
docs/update-channels.md.
Verified: cargo check (compiles clean), tsc, vitest 162/162, build, CJK guard,
release.yml YAML parses.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The unsigned DMG triggers macOS Gatekeeper's misleading "app is damaged" block
(#134, #72). Two parts:
- release.yml: pass APPLE_CERTIFICATE / _PASSWORD / APPLE_SIGNING_IDENTITY /
APPLE_ID / APPLE_PASSWORD / APPLE_TEAM_ID to tauri-action. It signs +
notarizes the macOS bundle when these repo secrets are set, and is a no-op
(today's unsigned build) when they're absent — so this is safe to merge now
and "activates" the moment the maintainer adds an Apple Developer cert.
- docs/install/macos.md: explain the "damaged" message is Gatekeeper (not
corruption), give the `xattr -cr` + right-click→Open workarounds, and add a
"For maintainers" table of the required secrets. Removed the stale "tracked
for v0.4" line (versioning rule: everything's on v0.3.0).
The in-app error→docs deeplink (GATEKEEPER_QUARANTINE) already targets the
#gatekeeper-quarantine anchor.
Refs #134, #72.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Three P0 fixes bundled — foundation cleanup before v0.3.0 phase work. Closes release.yml drift (PR #51's tabs broke v0.3.0 tag releases), production bind exposure (Critic F1), and 9-endpoint LAN gap on /system/* (Critic F2+F3). 5 new tests; 243 full pass.
* docs: initialize OmniVoice stabilization milestone project
* chore: add project config (yolo + balanced)
* docs: domain research for stabilization milestone
* docs: define v1 requirements for stabilization milestone
* docs: add GGUF + singing engine spike requirements (Phase 4 new)
* docs: roadmap revision + CLAUDE.md (7 phases, 62 reqs, +GGUF/SING spikes)
* docs(phase-0): add Gates phase RESEARCH.md
Phase 0 research synthesizes the cross-platform CI matrix, frozen
omnivoice_data fixture, installer post-build smoke, SHA-256 checksum
publishing, and PR-template extension into copy-paste-ready YAML and
Python snippets composed entirely from existing in-repo patterns.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs(phase-0): add Gates phase CONTEXT, PATTERNS, and PLAN
Phase 0 — Gates is the hard pre-condition for v0.3.x stabilization.
Lays cross-platform CI matrix (macos-14/windows-2022/ubuntu-22.04),
regression fixture (≤200 KB), installer smoke on tag push, SHA-256
checksums in release body + per-OS SHA256SUMS-*.txt assets, PR
template with RC cadence + fixture line, and the open-PR landing
for #51.
Plan covers GATE-01..06; structured into 7 slices (A–G) with explicit
Slice C → Slice G dependency reordering so the new smoke-matrix lands
on main before PR #51 (CONTEXT.md L86 interleave decision).
Plan-checker iteration 2: APPROVED — all 3 BLOCKERs + 3 MAJORs from
iteration 1 resolved (file truncation/Slice-G missing, GATE-06 sibling
PR verification, Slice C ordering, Truth #5 wording, macOS Tauri
WebView avoidance per Pitfall #5, Windows taskkill per Pitfall #2).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test(00-gates): seed regression fixture (GATE-01)
- scripts/seed-test-fixture.py — deterministic builder for tests/fixtures/omnivoice_data/
- wipes + rebuilds; fixed created_at=1700000000.0; all-zero PCM for byte-deterministic diffs
- calls backend.core.db.init_db() directly (alembic versions/ is empty — see CONTEXT.md)
- checkpoints WAL → DELETE on close so no -shm/-wal sidecars pollute git status
- exits non-zero if fixture > 200 KB
- tests/fixtures/omnivoice_data/{omnivoice.db, README.md} — 8-table empty DB + 1 voice_profiles row
- tests/fixtures/omnivoice_data/voices/test-voice/{profile.json, sample.wav} — 1-sec 24 kHz mono silence
- .gitignore — explicit allow-list (!tests/fixtures/omnivoice_data/**) so the existing
omnivoice_data/, *.db, *.wav patterns don't hide the fixture from git
Verifies: du = 144 KB on disk; sqlite_master lists 8 init_db tables + sqlite_sequence;
voice_profiles has exactly 1 row id='test-voice'; 0 rows in generation_history.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test(00-gates): add tests/smoke/test_boot_smoke.py (GATE-01)
- tests/smoke/__init__.py — package marker so pytest treats tests/smoke/ as a module
- tests/smoke/test_boot_smoke.py — 4 in-process FastAPI TestClient smoke tests:
* test_health_returns_ok — /health returns 200 + {status:ok, device:...}
* test_profiles_endpoint_lists_fixture_voice — /profiles surfaces the seeded
test-voice row (validates OMNIVOICE_DATA_DIR wiring → DB_PATH → init_db schema)
* test_system_info_includes_data_dir — /system/info resolves data_dir
* test_history_endpoint_empty — /history reaches DB and returns []
Test isolation env vars (OMNIVOICE_MODEL=test, OMNIVOICE_DISABLE_FILE_LOG=1)
set at module top BEFORE any backend import — pattern from tests/test_router_smoke.py.
Fixture is copied to a per-session temp dir so the test never mutates the
checked-in artifact (SQLite file-change counter + runtime subdirs like dub_jobs/
would otherwise dirty `git status` after every run).
Failure mode: if tests/fixtures/omnivoice_data/ is missing, pytest.fail at
import time with the regenerate command.
- .gitignore — tighten the GATE-01 allow-list to ONLY the seed-produced files
(README.md, omnivoice.db, voices/test-voice/profile.json, sample.wav).
Prevents future runtime subdirs the backend may create under the fixture
from being accidentally committed.
Verifies: `uv run pytest tests/smoke/ -q --tb=short` → 4 passed in 1.31 s
(target was < 30 s). `git status` clean after a test run.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs(triage): record post-planning GitHub state — PR #62, new issues, OOS deferrals
- GATE-06: mark #53 + #61 merged (2026-05-16); add #62 (Wave 1 quick wins) to gate set
- INST-01: note PR #62 implements setuptools pin (closes#58)
- INST-04: note PR #62 lands README docs for #56 workaround
- INST-12: new requirement for #65 Windows Triton/torch.compile OOM (filed post-planning)
- Out of Scope: defer #67/PR #68 (audio effects), #64 (custom model dir),
PR #66 zh-CN (i18n milestone), #63 (empty-template bug)
PR #62 is the user's own Wave 1 work landed as a separate PR while
GSD planning ran in parallel. Merging it eliminates duplicate work
in Phase 1.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* ci(00-gates): add cross-platform smoke matrix (GATE-02)
- New smoke-matrix job on macos-14, windows-2022, ubuntu-22.04
- needs: test, fail-fast: false, timeout-minutes: 10
- Pinned actions: checkout@v4, setup-python@v5, setup-uv@v3 (cache enabled)
- Per-OS ffmpeg + libsndfile install (brew/choco/apt via awalsh128 cache)
- UV_HTTP_TIMEOUT=120, UV_HTTP_RETRIES=5 for restricted-network resilience
- Narrow scope: uv run pytest tests/smoke/ -q --tb=short
- Existing `test` and `tauri-cross-platform` jobs untouched
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* ci: add workflow_dispatch to ci.yml so smoke-matrix can run on feature branches
* feat(00-gates): add --health-check CLI flag to backend entrypoint (GATE-03)
- argparse on __main__ block; --health-check boots uvicorn in a daemon
thread and polls http://127.0.0.1:3900/health every 5s for up to 60s.
- Prints 'OK — /health responded 200 after Ns' and exits 0 on first 200.
- Prints 'FAIL — /health did not respond 200 within 60s' to stderr and
exits 1 on timeout. Default invocation behavior unchanged.
- No new deps (stdlib argparse/threading/time/urllib.request/sys + uvicorn).
- Consumed by per-OS installer-smoke step in .github/workflows/release.yml.
Verified locally: exits 0 in 5s against tests/fixtures/omnivoice_data/.
* ci(00-gates): add per-OS installer smoke to release.yml (GATE-03)
Adds three matrix-leg-specific steps after 'Build + release (Tauri)',
each gated by runner.os with timeout-minutes: 5:
- macOS (macos-14): hdiutil attach DMG → locate bundled Python backend
inside *.app/Contents (NOT the Tauri WebView shell — RESEARCH Pitfall
#5: WebView hangs on headless runners) → invoke --health-check →
hdiutil detach. Falls back to *.app/Contents/Resources and hard-fails
with a directory listing if no backend binary found.
- Windows (windows-2022): msiexec /quiet install → find backend.exe
under 'C:/Program Files/OmniVoice Studio' → invoke --health-check in
background, wait, then taskkill //F //T //PID to cleanup orphaned
PyInstaller child processes on port 3900 (RESEARCH Pitfall #2).
- Linux (ubuntu-22.04): --appimage-extract (no FUSE on GH runners),
locate binary or AppRun, run under xvfb-run -a.
Bundle-only regressions (PyInstaller missing-module, Tauri sidecar
path mismatch) are invisible to ci.yml's in-process smoke matrix —
this step closes that gap before any release is published.
Verified: YAML parses; all three steps present; gating + timeout
correct; Pitfall #2/#5 mitigations preserved.
* ci(00-gates): publish SHA-256 checksums in release body + as asset (GATE-05)
- Add 'Compute SHA-256 checksums' step writing SHA256SUMS-<label>.txt
per matrix leg using native shasum/sha256sum (Git Bash on Windows).
- Add 'Append checksums to release + attach SHA256SUMS file' step using
softprops/action-gh-release@v2 with append_body: true so the hashes
land in the release body alongside tauri-action's content (not
replacing it) and the file is uploaded as a release asset for
'shasum -c SHA256SUMS-<label>.txt' verification.
- Both steps gated by 'github.event_name == push && refs/tags/v*' so
workflow_dispatch dry-runs do not attempt to attach to a non-existent
release (per CONTEXT.md L70 + RESEARCH Pitfall #7 deferral of any
aggregate cross-leg SHA256SUMS job).
- fail_on_unmatched_files: true to surface path-resolution errors loudly.
* docs(00-gates): document RC cadence + regression-fixture check in PR template (GATE-04)
* docs(setup): add HF token persistence guide for macOS/Windows/Linux (DOCS-05)
Covers two persistent paths:
- Method A — canonical ~/.cache/huggingface/token via huggingface-cli login
- Method B — shell env var (~/.zshrc / ~/.bashrc / Windows User scope)
Documents the v0.2.7 "session only" in-app behavior + notes that
Phase 1 AUTH-03 will make in-app pastes write to the canonical file.
Bundled with Phase 0 PR per user request. Strictly DOCS-05 scope —
zero code changes, no engine touches.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* spec(auth): redesign HF token resolution as 3-source cascade with fallback (AUTH-01..06)
Replaces the env_store.py file-based design with a SQLite-backed app
store + cascade resolver that checks app → env var → ~/.cache/huggingface/token
in priority order, with automatic fallback to next source on HTTP 401.
User-explicit design decision:
- App-stored token (SQLite settings table, AES-GCM encrypted) wins
- Env var ($HF_TOKEN) second
- Global huggingface-cli login file third
- All three sources visible in Settings → API Keys with "Active" badge
- Save action populates BOTH app store AND canonical HF file (defense in depth)
New requirement:
- AUTH-06 — on 401, auto-retry next source in cascade before erroring
Also: traceability count corrected (62 → 74 — undercount at planning +
INST-12 + AUTH-06 added post-planning). All 74 v1 reqs mapped.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(auth): backend recognizes HF token from canonical file, not just env var
Two call sites were only checking $HF_TOKEN env var, missing the canonical
~/.cache/huggingface/token file written by `huggingface-cli login` (or the
app's future Save action):
- system.py `/system/info` `has_hf_token` flag — UI showed "No HF token"
even when `huggingface-cli login` had populated the file.
- model_manager.get_diarization_pipeline — pyannote diarization silently
returned None when only the canonical file was set. This is the bug
behind issue #35 (speaker diarization setup failure).
Both fixes use the same pattern: env var > huggingface_hub.get_token()
(which reads the canonical file). Adds a local _has_hf_token() helper
to system.py with a comment marking it as prelude to the AUTH-01..06
cascade (Phase 1 token_resolver.py will layer SQLite app-store on top).
Closes#35 sub-issue (canonical token invisible to diarization).
Cross-cuts AUTH-02 + AUTH-06 design for Phase 1.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(dictation): make pill-widget mode reachable from GUI + scripts (INST-13)
The dictation widget infrastructure shipped in PR #40 but was only reachable
via the undocumented --pill CLI flag. Adds three discovery paths:
1. Tray menu: "Switch to Dictation Widget" (studio mode) — saves
launch_as_widget=true to config, relaunches with --pill, exits current.
Mirrors the existing "Open Studio" path in pill-mode tray.
2. Persistent config: AppConfig.launch_as_widget (bool, default false). Read
at startup via load_config_pre_app() (uses dirs-next, no AppHandle
required). CLI --pill still takes precedence when explicitly passed.
3. Tauri commands: get_launch_as_widget / set_launch_as_widget for the
Phase 2 Settings UI to bind a checkbox to.
4. Scripts: bun desktop-prod:pill / desktop-prod:run:pill — forward --pill
to the bundled app launch. macOS uses `open -n --args` to spawn fresh
instance with the flag.
Closes the GUI half of INST-13. Phase 2 closes the Settings UI half.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(dictation): show widget unconditionally on pill-mode launch + visible Suspense fallback
Before: pill mode set up correctly but the widget window stayed hidden
until ⌘⇧Space was pressed. New users saw absolutely nothing on launch
(no main window, no dock icon, hidden widget) and assumed the app
failed. If global-shortcut Accessibility permission wasn't granted,
they had no path to discover the widget at all.
Two changes:
1. lib.rs: in pill_mode_setup, explicitly show + position + focus the
widget window after hiding main. With per-call error logging so we
can diagnose failures (and a clear error log if widget window
wasn't created at all — points at tauri.conf.json regression).
2. main-app.jsx: Suspense fallback was `null`, which combined with
widget's transparent+decorations:false config made any lazy-import
delay or failure invisible. Now renders a dark pill saying
"Loading dictation…" so even if CaptureWidget lazy-import stalls,
the user sees the window exists.
Studio mode behavior unchanged — widget stays hidden until hotkey
or tray click triggers it (existing show() call in the shortcut/
menu handlers is preserved).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(dictation): create widget window programmatically; Tauri 2 silently dropped config-array creation
Root cause: declaring the widget window in tauri.conf.json's app.windows[]
silently failed in Tauri 2 — get_webview_window("widget") returned None
even though the config was syntactically valid. Probable culprit was the
transparent + decorations:false + visible:false combo, but Tauri offered
no error message either at startup or via webview_windows() enumeration.
Diagnosed by adding webview_windows() enumeration logging at setup start
(only ["main"] ever appeared) and a programmatic WebviewWindowBuilder
fallback that surfaces real Result errors.
Fix:
- tauri.conf.json: widget entry now has `create: false` to make the
config-vs-programmatic handoff explicit.
- lib.rs setup(): call WebviewWindowBuilder::new(app, "widget", ...).build()
with the exact same surface attributes the config used to declare.
- capabilities/default.json: include "widget" in windows array so the new
window inherits the same Tauri permissions as main.
- tauri.conf.json: remove the invalid `"url": "/?window=widget"` field —
WebviewUrl::App takes a path only, query strings aren't supported.
Both windows now load index.html.
- main-app.jsx: replace URL-query-based widget detection with
getCurrentWindow().label === 'widget' via @tauri-apps/api/window. This
is the Tauri 2-recommended pattern for multi-window apps and works
regardless of URL routing.
Closes the immediate UX bug behind the dictation widget being invisible.
Builds cleanly + manually verified: pill widget visible on screen at
top-center after `bun desktop-prod:pill`.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
WS dictation pipeline was producing exit-183 from ffmpeg on every
partial because MediaRecorder.start(250) ran before the WebSocket
handshake finished — the first chunk (WebM EBML header) was queued
only into chunksRef and never pushed to the WS, so concatenated
chunks 1..N decoded as malformed WebM. Fix:
- Construct the WebSocket BEFORE starting the recorder so wsRef is
set when the first ondataavailable fires.
- ondataavailable now queues every chunk through wsPendingRef when
the socket isn't OPEN; ws.onopen drains the queue.
- ws.onmessage('error'): fire HTTP fallback immediately instead of
waiting the full fallback-timeout window.
- ws.onclose without prior `final`: same — kick the HTTP path now
if the recorder has already stopped.
Mic permissions:
- New frontend/src-tauri/Info.plist with NSMicrophoneUsageDescription
+ NSCameraUsageDescription. Tauri 2 auto-merges the file at bundle
time (path is the same dir as tauri.conf.json — schema documents
this fallback). Without it, getUserMedia silently fails on macOS
10.14+ TCC.
- Mic-denial toast now includes platform-specific recovery (Settings
paths for macOS/Windows, audio-group check for Linux).
CI / release notes:
- release.yml extracts the matching `## [X.Y.Z]` section from
CHANGELOG.md and feeds it into tauri-action's releaseBody, so
v0.2.6+ tag pushes produce real release notes instead of the
placeholder "Auto-generated release. See commit log for changes."
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
AppImage was dropped earlier when linuxdeploy's AppImage runtime
couldn't FUSE-mount on GH Actions runners. Now viable again because:
1. `APPIMAGE_EXTRACT_AND_RUN=1` bypasses FUSE (extract-and-run).
2. The thin uv-venv installer is ~10 MB (vs the prior ~2 GB PyInstaller
payload that tripped linuxdeploy's internal size limits).
Matrix `bundles` for Linux: `deb,updater` → `deb,appimage,updater`.
tauri.conf.json `targets` also updated so dev builds can produce
AppImages locally.
Covers universal Linux — runs on any glibc-2.31+ host without a
package manager.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Apple shipped the last Intel Mac in June 2023 and Rosetta 2 runs the
ARM build natively at 85-100% of native speed. macos-13 runner backlog
was also blocking every v0.2.0 retag for ~10 min waiting on a hosted
Intel runner — measurable pain for no measurable user reach.
If we ever need Intel builds back, the matrix entry is one block of
five YAML lines.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two unrelated tweaks grouped into one PR to keep churn low.
## Ports
Backend 8000 → 3900, Vite dev 5173 → 3901, 3902 reserved for future
IPC. Port 8000 conflicts with Django/Rails/Jupyter/Airflow on most
dev machines; the uncommon 3900 range dodges that. Touched:
- frontend/src-tauri/src/lib.rs (BACKEND_PORT)
- frontend/src-tauri/tauri.conf.json (devUrl)
- frontend/vite.config.js (server.port)
- frontend/src/api/client.ts (hardcoded API base)
- frontend/src/App.jsx (PREVIEW_API fallback)
- backend/main.py (CORS allowlist + uvicorn.run default)
Rust sidecar launcher and FastAPI uvicorn port stay in sync via the
`BACKEND_PORT` constant + explicit port=3900.
## CI caches
Build time shaves across ci.yml and release.yml:
- `astral-sh/setup-uv@v3` → `enable-cache: true` keyed on uv.lock
(~45 s saved per run after uv.lock stabilises)
- `awalsh128/cache-apt-pkgs-action` for ffmpeg (~25 s saved)
- `actions/cache@v4` on `~/.bun/install/cache` keyed on bun.lock
(~15 s saved; applied to both test gate and build matrix)
Expected warm test job: ~45-60 s (was ~2-3 min).
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* ci: cache Rust deps for Tauri build (~5 min → ~1-2 min on warm runs)
Cargo dep compile is the long pole of each Tauri build now that
PyInstaller is out. Add Swatinem/rust-cache@v2 keyed by rust_target so
each matrix job (mac arm, mac intel, windows, linux) gets its own
cache. Caches ~/.cargo/registry + frontend/src-tauri/target.
Expected: cold first run stays ~5-7 min per platform; subsequent runs
on the same rust_target drop to ~1-2 min.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore(frontend): bump deps + TypeScript 6
- vite 8.0.8 → 8.0.9 (patch)
- eslint 10.2.0 → 10.2.1 (patch)
- eslint-plugin-react-hooks 7.0.1 → 7.1.1
- globals 17.4.0 → 17.5.0
- typescript 5.9.3 → 6.0.3 (major)
TS 6 warns on tsconfig's `baseUrl` ("deprecated, removed in 7.0"); add
`ignoreDeprecations: "6.0"` to keep the current path-alias setup until
we migrate off baseUrl in the next cycle.
Verified locally: `tsc --noEmit` clean, `vite build` produces bundles
identical in shape to the prior version.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Supersedes the PyInstaller tarball approach from PR #15. PyInstaller
never made it past iteration — even with CPU-only torch + strip +
optimize, the Linux .deb and Windows MSI both overshot GH Releases'
2 GB per-asset cap. Trying to keep it under the cap also meant CUDA
was off the table for users who did have a GPU.
Switch to the bootstrap pattern Unsloth uses:
- Installer ships the Tauri shell + frontend dist + repo's
pyproject.toml + uv.lock + backend/ source tree as Tauri resources.
DMG is 8.9 MB (verified locally). MSI / .deb should be similar.
- On first launch, src-tauri/src/lib.rs::ensure_venv_ready() downloads
the standalone `uv` binary (if not already on PATH), copies the
bundled pyproject.toml + uv.lock + backend/ into
`app_local_data_dir/project`, then runs `uv venv --python 3.11`
+ `uv sync --frozen --no-dev`. Subsequent launches skip.
- spawn_backend launches `{venv_python} -m uvicorn main:app
--app-dir {project/backend}` — no more PyInstaller binary.
- Dev mode still wins: if `.venv` at the source tree exists, reuse it
(matches `bun run dev` behaviour).
Release workflow drops: Setup Python, Install uv, CPU-torch reinstall,
PyInstaller freeze, backend tarball package, and backend tarball upload
steps. CI now just builds Rust + bundles resources; the heavy deps
install happens once on each user's machine.
User impact:
- Tiny installer → instant download + install (no 300–700 MB tarball).
- First launch: ~5–10 min setup while uv materialises the venv. This
happens behind the initial webview splash; subsequent launches are
normal.
- Users get the right torch wheel for their box — CPU by default,
CUDA if they already have the drivers (uv resolves from pyproject).
- Updates: bumping deps = bump uv.lock + ship a new installer; no
PyInstaller rebuild needed.
Known follow-ups:
- Progress UI during first-run bootstrap (React splash polling a
Tauri command). Right now the webview stays on the loading screen.
- Retry / repair flow if bootstrap fails (network drop, etc.).
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
All three desktop platforms previously built a single asset — installer +
PyInstaller backend bundled together — that overshot GH Releases' 2 GB
per-asset cap on Linux and Windows. The mac DMG got under the cap thanks
to HFS compression, but Linux .deb and Windows MSI couldn't. NSIS and WiX
both failed during their own size-bounded packaging steps too.
Split the two:
- Tauri installer ships WITHOUT the PyInstaller backend
(`tauri.conf.json` bundle.resources is now empty). Installer sizes drop
from ~1.8 GB to ~50 MB.
- CI packages the frozen backend as
`omnivoice-backend_<version>_<triple>.tar.gz` after tauri-action, and
uploads it to the same draft release via `gh release upload`. Each
tarball is gz-compressed + comfortably under 2 GB with the CPU-only
torch wheel + strip=True from earlier PRs.
- On first launch, `ensure_backend_ready()` checks three locations in
order: resource dir (legacy), app_local_data_dir (new home for the
downloaded backend), and the dev-mode `dist/` fallback. If none match,
it downloads the tarball matching the current platform + app version
from the GH Release and extracts into app_local_data_dir. Blocking on
first run, no-op thereafter.
- find_bundled_backend + backend_exe_name are platform-aware — they
append .exe on Windows and scan all three roots.
Dependencies added to src-tauri/Cargo.toml: ureq (HTTP), tar + flate2
(archive extract). No tokio — ureq is synchronous, which matches the
existing setup() flow.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* ci: opt JavaScript actions into Node 24 runtime
GH deprecates Node 20 for JavaScript actions on 2026-09-16. The
deprecation warning surfaces on every run right now. Setting
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24=true at workflow level makes
actions/checkout, actions/setup-*, astral-sh/setup-uv, and
oven-sh/setup-bun all run on Node 24 without bumping action versions.
This is a runtime override only — our own test script still pins
Node 22 via actions/setup-node@v4 (required for
--experimental-strip-types).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(release): strip symbols + filter CUDA/CUDA-provider binaries, report size
Previous slim pass (PR #11, CPU-only torch + module excludes) still left
the frozen backend above GH Releases' 2 GB per-asset cap. Two more levers:
1. strip=True on EXE + COLLECT. Strips debug symbols from ELF/Mach-O
native libraries. libtorch_cpu.so and friends drop ~25-30%. No-op on
Windows (MSVC stores symbols in separate .pdb files).
2. optimize=2 in Analysis. Compiles embedded bytecode with -OO:
docstrings + assertions removed. ~50-80 MB off the PYZ archive.
3. Post-hoc binary filter after collect_all. Even with nvidia wheels
excluded as Python modules, collect_all('torch')/('onnxruntime') can
still pull the CUDA-runtime shared libs via their linker hints.
Pattern-match them out of a.binaries before PYZ.
4. Log bundle size after freeze so CI runs can be compared without
downloading artifacts.
If this round still overshoots 2 GB, the next step is splitting the
payload (thin installer + post-install download of the Python bundle).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
GH deprecates Node 20 for JavaScript actions on 2026-09-16. The
deprecation warning surfaces on every run right now. Setting
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24=true at workflow level makes
actions/checkout, actions/setup-*, astral-sh/setup-uv, and
oven-sh/setup-bun all run on Node 24 without bumping action versions.
This is a runtime override only — our own test script still pins
Node 22 via actions/setup-node@v4 (required for
--experimental-strip-types).
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Linux .deb upload and Windows MSI build both hit GitHub Releases'
hard 2147483648-byte asset cap because the frozen backend was ~2.2 GB
on Linux/Windows. Root causes + fixes:
- PyPI's default torch/torchaudio wheels bundle the full CUDA runtime
(~1.8 GB of libcuda*, libcublas*, libcudnn*, libcufft*, libcusparse*,
etc.). We ship CPU-only inference from the desktop binary; GPU is
surfaced only when a user-installed driver is detected at runtime.
Re-install torch from download.pytorch.org/whl/cpu for the Linux and
Windows matrix jobs before PyInstaller freezes. macOS wheels don't
include CUDA so they skip this step.
- Expand backend.spec excludes: torch subpackages we never touch at
inference time (torch.distributed, torch._dynamo, torch._inductor,
torch._export, torch.testing, torch.onnx, torch.ao, torch.fx.
experimental, torch._functorch, torch.utils.tensorboard,
torch.utils.benchmark), torchaudio.prototype, and heavy pyproject
deps the backend never imports (gradio, tensorboardX, webdataset,
s3prl, funasr, pedalboard). Also drop test trees that collect_all
sweeps up (scipy.special.tests, numpy.f2py.tests, etc.).
Expected bundle size after trim: ~600-900 MB uncompressed on Linux /
Windows, well under the 2 GB cap for .deb and MSI.
Model weights were never bundled — they already download on first run
via the HF cache when the user hits the Dub / TTS / ASR flows. So no
user-visible behaviour changes; the app just ships without the libs
required for CUDA builds, which weren't callable on those runners
anyway.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Prior run (24797827082) showed tauri ignored tauri.conf.json's
bundle.targets filter: Windows build still ran makensis (NSIS) despite
the config listing only msi. Explicitly pass `--bundles` per platform
via tauri-action args:
- macOS: app,dmg,updater
- Windows: msi,updater (avoids NSIS's 2 GB stub limit)
- Linux: deb,updater (drops unreliable AppImage/linuxdeploy step)
Also removed `appimage` from tauri.conf.json's targets list to match,
keeping config + CLI in sync.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Linux (ubuntu-22.04 runner) was failing the linuxdeploy step because GH
runners disable FUSE. Setting APPIMAGE_EXTRACT_AND_RUN=1 tells AppImages
to extract-and-run instead of mounting via FUSE.
Windows (windows-2022) was failing makensis with "Internal compiler
error #12345: error mmapping file (1843463346, 33554432) is out of
range" — NSIS's 32-bit file handling can't build an installer whose
payload approaches the 2 GB boundary (the PyInstaller-frozen backend is
~1.7 GB). Switched the Windows bundle target from NSIS to MSI (WiX),
which uses cabinet archives that handle larger payloads.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`bun run <script>` auto-aliases `node` to `bun` in script bodies, so
`bun run test` fails with "node: bad option: --experimental-strip-types"
because bun doesn't support that flag. Call node directly from the CI
step instead of going through the package.json script.
Also add setup-node to release.yml's test gate — it was missing entirely,
relying on bun's node shim.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>