Closes#1713
Adds a separately identified per-user MSI and updater channel, non-administrator install/uninstall verification, and fail-closed WebView2 handling for current-user installs.
* feat(install): prebuilt-app installs by default, --source opt-in, --version picker
- install.sh: default mode now downloads the verified release asset
(dmg/AppImage + SHA256SUMS check) instead of cloning and building;
--source keeps the previous clone-and-build flow; --version pins a release
- install.ps1: same split — msi download with checksum verification and
setup wizard by default; -Source (or VOICESTUDIO_INSTALL_MODE) for the
source build; VOICESTUDIO_VERSION picks a release
- worker landing page documents the modes
* fix(install): CI smoke covers binary + source modes; hdiutil output parse
- drop -quiet from hdiutil attach (it suppresses the mount-point line the
script parses — caught by the macOS smoke)
- install.ps1 runs msiexec silently under CI, wizard interactively
- smoke verifies binary installs per OS (app bundle / AppImage / MSI
registry entry) and keeps full source coverage behind --source
* ci(install): check HKCU/WOW6432Node too — Tauri MSI registers per-user
* fix(install): rename $version — collides with bun installer's $Version under iex
* feat(install): one-command installer URL (.sh + .ps1) + 3-OS install smoke
- scripts/install.ps1: Windows source installer (winget deps, uv, bun,
clone, uv sync, frontend build); honors OMNIVOICE_PYTHON/OMNIVOICE_REGION
- infra/install-redirect: Cloudflare Worker serving /install with
User-Agent sniffing (curl -> sh, PowerShell -> ps1, browser -> landing
page); proxies live from main; /install.sh + /install.ps1 aliases
- scripts/install.sh: fix stale advertised URL (main/install.sh never
existed) and repo-root resolution so a local run no longer clones a
duplicate repo into ~/VoiceStudio (verified on macOS arm64)
- .github/workflows/install-smoke.yml: run both installers end-to-end on
ubuntu/macos/windows when they change
- docs-sync: install one-liners lead each platform guide; STRUCTURE.md
(#1626)
* fix(install): don't let a failed bun download pass silently
curl | sh runs an empty script and exits 0 when the download fails, so
a bun.sh hiccup surfaced much later as 'bun: command not found' (seen
on the macos-latest smoke runner). Fetch to a temp file, verify, fall
back to npm -g bun when node exists, and die with the manual command.
Same post-install verification for uv.
* fix(install): UTF-8 BOM for install.ps1 + quiet-style changelog entry
- tests/scripts/test_uninstall_ping.py requires shipped PowerShell
scripts with non-ASCII text to carry a UTF-8 BOM (Windows PowerShell
5.1 mis-decodes otherwise); same treatment uninstall.ps1 already gets
- test_changelog_style caps entries at ~400 chars
* test(shell): backend-lifecycle fault-injection harness
Runs spawn_backend_and_wait/supervise_backend against REAL dying child
processes and asserts the user receives the correct NAMED diagnosis —
not merely that recovery happens. Wrong/missing explanation was 61% of
the historical "can't reach the backend" class; this rig is the
permanent regression harness for every future lifecycle fix.
Seam: OMNIVOICE_BACKEND_CMD (JSON argv or whitespace form) runs any
command as "the backend" — venv bootstrap and ffmpeg resolution are
skipped, everything else (err-log run offsets, drainer threads, env
pinning, real OS pipes, spawn-failure diagnostics) stays real. Plus
OMNIVOICE_LOG_DIR (per-test log+marker dirs, also a support tool) and
harness-only timing overrides OMNIVOICE_STARTUP_BUDGET_S /
OMNIVOICE_SUPERVISOR_POLL_MS whose production defaults are pinned by
unit tests. Lifecycle fns genericized over tauri::Runtime for the
MockRuntime app; behavior-neutral with the env unset (unit-pinned).
Scenarios (tests/backend_lifecycle.rs, scenario children = this test
binary re-invoking itself; serial by mutex + CI --test-threads=1):
- port conflict (exit 78) → the detectHints-matchable port phrasing
- generic chained traceback → root cause survives into the diagnosis
and the crash marker
- spawn failure → spawn diagnostic reaches the user, NO bogus marker
- slow start past budget → timeout names the budget + last stderr
- post-Ready crash loop → 3 restarts announced, markers before restarts,
"kept crashing" diagnosis naming the last exit
- SIGKILL (unix) → named as signal 9
- deliberate kill → supervisor yields silently, no marker, never Failed
- deferred-startup FATAL → the named step reaches the user, forensics,
and the splash narration
CI: harness added to the 3-OS tauri-cross-platform matrix.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* chore(ci): temporary Windows loader bisect probe for the harness binary
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(shell): embed Common-Controls v6 manifest into Windows test binaries
Bisected on #1551: EVERY integration-test binary of this crate died at
load on Windows with STATUS_ENTRYPOINT_NOT_FOUND (0xc0000139) — cargo
gives test binaries no manifest, so the loader resolves comctl32 v5,
which lacks the TaskDialogIndirect entry point tauri's dialog/tray stack
imports. build.rs now embeds tests/windows-test.manifest via
rustc-link-arg-tests on Windows targets. Bisect probe removed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(test): scenario gate is PID-valued — the parent can't self-inject
CodeRabbit on #1551: in a parallel local `cargo test`, the parent's own
scenario_child test could observe the armed env and start playing the
backend in-process (binding the port, idling 600s). The gate value is
now the arming process's PID; a matching PID stays inert, so only the
spawned child — a different process — runs the scenario.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* fix(release): harden the AppImage repair step
All three review findings on #1544, fixed before the tag re-runs it:
- appimagetool pinned to the immutable 1.9.1 release with a verified
SHA-256 — a mutable 'continuous' binary must not execute with the
updater signing key and a release-write token in its environment
- the release tag reaches the script as env data, never interpolated
into shell source (zizmor template-injection)
- a failed latest.json download now fails the step unless the asset is
confirmed absent, and the patch refuses to upload unless at least one
linux signature was actually replaced — a repacked AppImage can never
ship paired with stale updater metadata
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(release): the updater manifest gets one final writer, after the matrix
CodeRabbit + Greptile on #1545: every tauri-action leg re-uploads the
shared latest.json, so patching it inside the Linux leg races the other
platforms — a later leg's upload could resurrect the stale pre-repack
signature. The manifest patch moves to a post-matrix job that runs once
after all legs: it aligns the manifest's linux entries with the .sig
asset that actually shipped (self-verifying — no cross-job state), and
no-ops when they already agree. The leg keeps asset repack/re-sign only.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(release): a failed .sig download fails the manifest-align job
Same fail-closed rule as the manifest itself: absence is decided by the
asset list; any other download failure must not exit 0 with a stale
signature left in latest.json.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
The v0.5.0 tag build — the first real release since #1518's guard — proved
the files-map fix loses: linuxdeploy re-links .DirIcon to an ABSOLUTE
build-machine path after tauri places the real bytes, and the guard
correctly refused to publish. tauri-action's atomic build+sign+upload
leaves only a post-upload seam, so the Linux job now repairs the packed
artifact: extract, replace .DirIcon with the icon bytes as a regular file
(nothing left to dangle), repack with appimagetool, re-sign with the
updater key, clobber the draft release's asset and patch the linux
signature inside latest.json. The existing smoke then validates the
repaired AppImage. No-ops cleanly when .DirIcon already resolves.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* feat(gallery): save gallery voices as profiles, with validated audio references
Work-in-progress lifted from the concurrent gallery session at the
owner's request (its uncommitted working tree, preserved verbatim from
base 92b1ee5d; safety snapshot remains at rescue/gallery-wip):
- gallery voices can be saved as local profiles: audio is copied into
the profile store with content-addressed filenames, existing profiles
are detected and refreshed only when the source clip changed
- backend/core/audio_validation.py: symlink-rejecting, root-contained
resolution for persisted profile WAV references, with tests
- archetype/community routers and the Voice Gallery UI updated for the
save-as-profile handoff (spec: docs/specs/longform/26-gallery-use-handoff.md)
- locale updates for the new gallery strings across all 21 files
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* chore: drop a stray local screenshot script that rode in with the tree copy
* fix(community): explain the tolerated Content-Length parse failure; drop an unused import
CodeQL on #1542: the empty except now says why it is safe (the streamed
byte counter enforces the same cap regardless), and the test file loses
an unused Path import.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(gallery): review findings — copy outside the write lock, no stale completions
CodeRabbit on #1542, all findings addressed:
- the profile-audio copy stages to a .part temp BEFORE BEGIN IMMEDIATE
and publishes via atomic os.replace inside it — other backend writers
no longer block for the duration of an audio copy; a mid-copy failure
leaves no temp droppings and no profile row (both pinned by tests)
- VoiceGallery async ops carry per-operation generation tokens: a
preview or save-as-profile that resolves after unmount (or after a
newer operation) can no longer play audio, redirect into a workspace,
or touch state — three fail-before regression tests
- VoiceGalleryActions imports the page at test runtime; the e2e locator
uses a stable data-testid instead of a translated string; symlink
tests skip cleanly where the OS can't create symlinks; the changelog
line carries its PR ref
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* ci: static ffmpeg fallback when the chocolatey feed is down
Third feed outage to break a PR run (2026-07-20, 2026-07-28, today —
three attempts, three 'installed 0/1'). Chocolatey is a distribution
channel, not the dependency: after the retry loop exhausts, fetch the
static gyan.dev build from its GitHub release mirror and put it on
PATH — same binary, no feed in the path. URL verified live (HTTP 200).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* feat(demos): ship the demo audio and video the app already advertises
Every demo asset in the app was a dead link on anything but a Mac.
`personalities.py` has carried a `preview_url` for each of the seven
voice-design presets since they were added; DictationDemo.jsx posts three
bundled WAVs to /transcribe so the feature can be shown without microphone
permission; the Dub workspace reads a manifest and plays a source video plus
four dubbed languages. None of those files were committed, because the tooling
that renders them (scripts/build_demos.sh, scripts/build_dub_demo.sh) hard-
requires macOS `say` — it even carries a `TODO: add espeak-ng path for Linux
contributors`. So the presets returned 404, the replay buttons did nothing, and
the dubbing demo never loaded.
Rendered with VoiceStudio's own engine, which runs wherever the app does:
- 7 voice-design previews (2.2 MB)
- 3 dictation replay clips (1.1 MB) — verified by transcribing them back:
the conversational and French clips round-trip exactly
- dubbing demo: source + 4 dubbed videos with subtitles and manifest (9.6 MB)
Tooling fixes this turned up:
- build_dub_demo.sh wrote to backend/assets/demo/dubbing, but main.py mounts
backend/assets/samples at /demo_audio — so the frontend's
/demo_audio/demo/dubbing/manifest.json could never have resolved even after
a successful Mac build. Output moved under the mount.
- `say` is now the fallback rather than the requirement: the new
scripts/render_dub_demo_audio.py renders the five tracks with the engine and
the shell script picks them up.
- The five demo paragraphs lived in two files. They are now one JSON both read
— two copies is one edit away from a video whose subtitles disagree with it.
- render_demos_omnivoice.py peak-normalized, which a single-sample transient
defeats: the Helpdesk preset landed at -30 dB RMS against -17 dB for its
neighbours, so the preview row played at wildly different volumes. Now EBU
R128 at -18 LUFS with a -1.5 dBTP ceiling.
- …and pinning the output rate, because loudnorm resamples to 192 kHz
internally and writes there unless told otherwise, which turned 2.1 MB of
previews into 17.5 MB of identical-sounding audio.
- update_manifest() looked for a manifest at a path nothing writes, so it
always printed "not found" and did nothing.
- Dictation is rendered here now too. It was excluded on the grounds that
`say` was good enough and engine TTS was overkill — true only on macOS.
tests/test_demo_assets_exist.py resolves every advertised URL against the
directory main.py actually mounts, and checks each dubbing subtitle matches the
script its manifest entry claims. A missing static file is not an import error
and not a failing request; nothing would have caught this otherwise.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* docs(changelog): stamp the demo-asset entries with their PR ref
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(demos): watermark rendered demo audio, and harden the render scripts
Review findings on #1517:
- Greptile P1: the renderers wrote engine output straight to disk, so a
re-render shipped demo audio with no provenance mark. These clips play
back to users as VoiceStudio output — they are synthetic audio leaving
the app like any other, and now go through mark_synthetic (#1169), the
one chokepoint every producing route uses. It runs on the file AFTER
loudnorm, since loudnorm re-encodes what it is handed, and says so
loudly when marking is unavailable rather than committing an unmarked
asset. The dubbing renderer shares the same helper.
- CodeRabbit: build_dub_demo.sh checked only source.src.wav before
deciding it could run without macOS `say`, so a Linux or Windows run
with four of five tracks present reached a missing one, called `say`,
and left a half-built bundle. It now requires all five.
- CodeRabbit: shutil.move over an existing path delegates to os.rename,
which raises FileExistsError on Windows — os.replace overwrites
atomically everywhere.
- CodeRabbit: the preview test discovered presets in a parametrize
argument, importing app code at collection time and leaving
core.personalities in sys.modules for later tests. Discovery moved into
the test body.
CI: the rendered dub bundle's zh/ja subtitles, its manifest and the
script source are dubbing CONTENT, not UI strings — allowlisted in
test_no_hardcoded_cjk.py with that justification.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(demos): a render that cannot be watermarked fails instead of warning
CodeRabbit and Greptile, #1517: mark_synthetic degrades rather than
raising — correct for generation, wrong for a render script, whose whole
job is to produce files a human then commits. A printed warning on a
scrolling console is not a gate, so both scripts exited 0 with unmarked
assets sitting on disk ready to commit. They now raise, with the reason
and the fix; OMNIVOICE_DEMO_ALLOW_UNMARKED=1 stays for a local listen.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* ci: stop a flaky dependency fetch from failing green runs
en-core-web-sm resolves to a direct GitHub release URL, and github.com
intermittently answers `http2 error: refused stream before processing
any application logic`. uv's own three retries all land within the same
few seconds and fail together, so the whole job dies on a dependency
that has nothing to do with the change under test — it cost #1518 and
#1517 an otherwise-green run tonight.
Two changes: back off between whole `uv sync` attempts, which is what
actually clears it, and pass --no-sync to the pytest steps. `uv run`
re-resolves the environment before running, so every test step was a
fresh chance to hit the same fetch even though the install step had
already synced — that is exactly how #1518 failed, in the isolated
backend/tests step, with all 5467 tests already passed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* ci: one retry seam for every uv sync, not just the job that failed last
en-core-web-sm resolves to a direct GitHub *release* URL rather than a
package index, and github.com intermittently answers `http2 error:
refused stream before processing any application logic`. uv's own
retries all land inside the same ~10 seconds and fail together, so a job
dies on a dependency unrelated to the change under test. Tonight that
cost four otherwise-green runs across #1515, #1517 and #1518 — and the
first fix only covered the Tests job, so the next failure simply moved
to Smoke (Linux), which syncs separately.
The fetch is per-job, so the fix has to be per-job: scripts/uv-sync-retry.sh
backs off between whole attempts (15s, 45s, 90s) and every workflow that
syncs now goes through it — ci.yml (tests + the platform matrix),
release.yml, security.yml, evals.yml. It still fails loudly after four
attempts, so a genuinely broken lockfile is not disguised as a flake.
The Tests job also lacked the UV_HTTP_TIMEOUT / UV_HTTP_RETRIES the smoke
matrix has always set, which is part of why it was the one that kept
dying; it has them now.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* test(ci): pin the Intel-Mac contract by intent, not by command spelling
test_ci_verifies_intel_mac_as_the_documented_remote_only_host asserted
the literal line `run: uv sync --extra pockettts`, so routing every sync
through scripts/uv-sync-retry.sh read as a broken Intel-Mac contract. The
contract it exists to protect is that the pockettts extra installs ONLY
on backend_supported legs — which the regex now pins, while leaving how
the sync is invoked free to change.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* ci: keep every uv run out of the resolver, and bound the retry budget
CodeRabbit, #1517:
- `uv run` re-resolves before running, so the smoke suite, the
worker-artifact tests, the release test run and the eval run were each
a fresh chance to hit the flaky direct-URL fetch outside the retry
loop. All of them pass --no-sync now; the environment is already
synced by the step that owns the retries. security.yml's
`uv run --with pip-audit` is deliberately left alone — it layers an
ephemeral package rather than running the project's own tests.
- The retry count multiplied uv's own budget (UV_HTTP_RETRIES=5 with a
120 s timeout on the smoke matrix). Three attempts and 60 s of total
backoff outlast the refusals actually observed while staying well
inside the jobs' timeout-minutes.
- The Intel-Mac contract test pinned the smoke command literally too, so
--no-sync tripped it exactly like the sync line did. Same fix: assert
the contract (smoke runs only on backend_supported legs), not its
spelling.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three conflicts, all additive on both sides — resolved by keeping both
rather than choosing, since either side's entries were real shipped work:
* CHANGELOG.md — remote-GPU entries against branding, IndexTTS 2.5 and
the recording-input work
* setup/download.py — the per-target progress reset against main's
active-install tracking; both belong in the same finally block
* docs/features.yaml — the remote-worker and model docs against
docs/branding.md
Backend 5349 passed, frontend 1871 passed. `bun install --frozen-lockfile`
reports no changes, so the Docker build sees the same tree CI does.
The remote-GPU line, verified on hardware rather than asserted.
**Dubbing renders on the worker.** dub_generate.py dispatches the coarse
`dub_segments` operation through the gateway, following the audiobook
pattern: per-unit local fallback after consecutive remote failures, one
aggregated notice rather than one per segment. A 40-minute dub that loses
its worker at segment 200 degrades instead of producing 200 error rows.
**An out-of-date worker is now refused by name.** This was the worst
defect in the plan and it was silent: an un-upgraded worker registered
cleanly, then ignored `inputs` and rendered a clone with NO reference
audio — returned as success. A plausible wrong result with nothing
anywhere to surface it. Workers now declare features, and one missing
them is turned away with the features named and `no task was run`.
Verified live: a worker one commit behind was correctly refused.
**"Offline" and "cannot run this" are different facts.** Asking a live
worker for an engine it lacks answered "is offline or cannot be reached.
Wake the selected worker" — while that worker reported ready, one free
slot and 3.6 ms latency. The user was sent to wake a machine that was
already awake. The scheduler now distinguishes absent from present-but-
incapable, and names the engine rather than the operation, because the
engine is the thing a user can install.
**An engine with no catalog entry is no longer hidden.** A `repo_ids`
non-emptiness check had been implemented as a runtime filter, so a worker
silently refused to advertise any engine lacking a models.yaml entry —
which is four registered engines, including CosyVoice. Users with those
already installed would have lost remote support with only a log line.
Empty `repo_ids` now means "not downloadable here", never "not runnable".
**And a script so this stops being done by hand.**
scripts/verify-remote-worker.sh runs the per-phase acceptance checks
against a live worker, non-destructively. Its preconditions are the
mistakes that cost the most time: exactly one listener on the control
port (two instances silently shared it), and never detecting the worker
with a pgrep pattern that matches the ssh shell running it.
Its first real run found the dubbing picker claiming remote placement.
That turned out to be the CHECK being stale, not the picker — the port
had landed since it was written. It now asserts self-consistency instead:
the picker may claim remote only for an operation the control plane
actually advertises as remotely producible, which cannot rot the next
time an op is ported.
Backend 5291 passed, frontend 1812 passed. Acceptance script: no
automated failures across Phases 4-8 on an RTX 4090. Four checks remain
MANUAL by design — true airplane mode, concurrent downloads, killing a
worker mid-audiobook, and the model-list UI — and are reported as
unverified rather than passed.
Renames what users see. The app, the installers, the window title, the
docs and all 21 locales now say VoiceStudio, with "(previously
OmniVoice-Studio)" noted near the title of each doc surface so people
recognise it.
Deliberately NOT renamed, because renaming any of them silently breaks
an existing install — there is no legacy-path fallback anywhere in this
codebase:
- bundle identifier com.debpalash.omnivoice-studio (MSI UpgradeCode,
macOS TCC grants, managed venv, WebView localStorage, the
single-instance lock)
- data directories OmniVoice / .omnivoice and omnivoice.db
- the ~150 OMNIVOICE_* environment variables
- the X-OmniVoice-* HTTP headers (a wire protocol)
- the published Docker image paths
- the OmniVoice ENGINE, which is a model name and not this product
tests/test_identity_paths_survive_the_rename.py pins every one of those
so a future well-meaning sweep cannot orphan a user's library.
Linux .deb users install a new package name and should apt remove
omnivoice-studio; that note is in the changelog.
The repository was renamed. 724 references across 59 files now point at the new URL — README badges, docs, install guides, the updater's releases API call, CONTRIBUTING, the Colab link and the probe harness. GitHub redirects the old URLs, so nothing was broken in the meantime.
Deliberately NOT renamed, because each breaks something on a user's machine: the Tauri bundle identifier (the path to every existing user's data), /usr/lib/omnivoice-studio and the compose container names, and the published Docker image paths.
The image path needed a code change to STAY still: docker.yml derived it from github.repository, so the next build would have published to ghcr.io/debpalash/voicestudio while Docker Hub, a hardcoded literal, stayed put — everyone pulling the documented GHCR path would have kept receiving the last pre-rename image forever. It is now pinned, with a test that fails if it ever derives from the repo name again.
Also makes the probe's repo-name assertion shape-based: it hardcoded the old name and failed on every PR after the rename while the code it tests worked perfectly.
The nightly preview build had been refusing to publish its own healthy manifest since 2026-08-05 — all four matrix legs green, but the macOS bundles uploaded a few minutes ahead of the slowest versioned artifact, and the freshness check compared the version-less darwin tarballs against their siblings with two minutes of slack. Legs finishing minutes apart is normal, so the comparison itself was wrong, and Preview-channel users quietly stopped getting builds.
The tarballs are now tied to the run that produced them: anything uploaded after this run's first job began executing belongs to it. A concurrency group serializes preview runs so that holds, and the anchor is the earliest job start rather than the run's created_at (which is stamped while a run is still queued, and would let a queued run claim the previous run's uploads). The preview-notes job also gains the actions: read scope its run-metadata lookup needs, with a warning-and-degrade path so a permissions regression cannot take the channel down again.
Regression tests cover the 2026-08-05 shape, the genuinely stale case, clock skew at the boundary, the no-timestamp fallback, per-ref concurrency scoping, and the required permission.
A meticulous report from an LXC/CPU-only source install surfaced three real defects: the build script deleted the libggml shared libraries a dynamically-linked build needs (first spawn died with exit 127), the hardcoded 120s per-spawn kill switch reaped legitimate CPU-only renders, and OMNIVOICE_ALLOWED_ORIGINS — the only fix for cross-origin browser access — was documented nowhere.
All platform branches of scripts/build-omnivoice-tts.sh now copy the shared libs next to the binary, the CI artifact glob uploads them, and the backend puts bin/ on the loader path for every spawn of the engine binary. The timeout defaults to 600s (above the pool guard's well-diagnosed 300s deadline), is tunable via OMNIVOICE_GGUF_GENERATE_TIMEOUT_S with non-finite values rejected, and the timeout error names the knob. CORS documented in api-auth.md with a pointer from remote-gpu.md. Regression tests pin the spawn-env rule, the per-branch copy rule, the artifact glob, and the timeout behavior.
* fix(release): rebuild + cryptographically verify the preview updater manifest
Since ~2026-07-13 every nightly matrix leg logs 'Signature not found for
the updater JSON. Skipping upload...' - tauri-action uploads the bundles
and .sig companions but never refreshes latest.json. Combined with the
'Clear this arch's stale preview updater bundle' step (which deletes and
replaces the version-less macOS tar.gz every night), the preview
manifest's darwin signatures no longer match the published files: macOS
Preview users hit 'The signature verification failed' on every update
(latest.json frozen at 2026-07-13, tar.gz replaced nightly).
Two changes, both in the single post-matrix preview-notes job (no
per-leg race):
1. Rebuild latest.json from the release's real assets and their .sig
companions, then clobber-upload. The manifest can no longer drift
from the files it describes, regardless of what tauri-action's own
updater-JSON path does or skips.
2. Extend the existing manifest verification with a cryptographic
check: every signature in latest.json must verify (minisign
file sig + trusted-comment sig) against the artifact it points at,
using the updater pubkey from tauri.conf.json. Parity and version
format both passed for 2+ weeks while every darwin entry was
unverifiable - this is the check that was missing.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* ci(release): refuse a preview manifest built from two different runs
Bot review findings on this branch, all fixed here:
- The AppImage and MSI were picked independently by highest run number and
the larger N became *the* version, so a matrix where one leg failed or
was re-run published a manifest advertising X.Y.Z-5 while handing Windows
users the -4 MSI. That is the same manifest/artifact drift this job
exists to end, reintroduced by the fix for it. Require both legs to come
from one run and fail loudly otherwise: leaving the previous manifest in
place is a visible, already-understood state; shipping a mismatched one
is not. The darwin tarballs carry no run number, so the signature check
in the following step is what pins those to the published bytes.
- persist-credentials: false on the checkout — nothing here pushes to git.
- Floor-pin the cryptography install; this step decides whether a signed
manifest is trustworthy, so it is the one dependency worth a bound.
tests/test_release_preview_manifest_rebuild.py runs the step body extracted
from release.yml against stubbed gh, so it cannot drift from the workflow.
Fails before / passes after on the mismatch case.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* docs(changelog): note the preview updater manifest fix (#1327)
Co-Authored-By: Pinkers01 <pinky.bouw@gmail.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* ci(release): verify the preview manifest before publishing it, not after
Two more review findings on this branch, both valid, both about the
manifest being wrong in a way the existing checks structurally cannot see.
greptile P1 — verification ran AFTER the clobber-upload. A manifest that
failed the check was already live and stayed served; the job merely went
red, and every macOS Preview user stayed broken until someone noticed.
Verification now runs against the file about to be published, and the
upload is the last thing in the step. A refusal leaves the previous
manifest in place, which is a visible, already-understood state.
CodeRabbit — the darwin entries were not tied to this run. The version
comes from the AppImage name; the macOS tarballs were only checked for
existence. Signature verification cannot help there, because a stale
tarball and its stale .sig match each other perfectly — so a run whose
macOS legs never uploaded would advertise this version while serving Mac
users the previous build, and since those clients keep reporting the old
version the updater would re-offer it forever. They are now bound by
upload time, with two minutes of slack for legs that finish apart.
The selection rules move out of the YAML heredoc into
scripts/build_preview_manifest.py. Three findings in a row have been about
WHICH artifacts may be described together, and a heredoc can only be
tested by extracting it and stubbing a shell — which is what the previous
test file did, asserting against gh stubs rather than against the rules.
build_manifest is pure: assets in, manifest out, ManifestRefused on
anything it will not describe.
14 tests, including both new refusals and two that pin the workflow still
calls the module and still uploads last — an inline copy would pass every
other test and ship the original bug.
Co-Authored-By: Pinkers01 <pinky.bouw@gmail.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Pinkers01 <pinky.bouw@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* fix(linux): AppImage blank window on Mesa 26.1+ hosts (#1258, #1244)
The AppImage bundles an Ubuntu-built WebKitGTK but ships no libEGL, so that
bundled WebKit runs against the HOST's Mesa. On Mesa >= 26.1 it calls
eglGetPlatformDisplay() in a way the newer driver rejects and the app dies
before it paints:
Could not create default EGL display: EGL_BAD_PARAMETER. Aborting...
No environment variable helps, because the failure is in EGL display creation
— before WebKit consults any rendering-path flag. #1258 confirmed
WEBKIT_DISABLE_DMABUF_RENDERER, WEBKIT_DMABUF_RENDERER_FORCE_SHM,
WEBKIT_SKIA_ENABLE_CPU_RENDERING, EGL_PLATFORM=surfaceless and
MESA_LOADER_DRIVER_OVERRIDE=swrast all fail identically.
Chasing the build runner's WebKit (#961 bumped 22.04 -> 24.04) cannot fix this
class: what we bundle is frozen and host Mesa keeps moving. So when the host
has a WebKitGTK at least as new as ours, let it win — the bundle still fills
every gap, and a host without WebKitGTK is untouched. That is exactly why
building from source works on the hardware where the AppImage does not.
The compositing workaround is re-decided against whichever library ends up
running, and AppRun.test.sh — which had never been wired into CI — now runs
there, so this logic stops being a regression test nothing executes.
* fix(review): the ordering change was a no-op; name the host libdir explicitly
CodeRabbit Major — correct, and it made the whole fix inert. LD_LIBRARY_PATH is
searched AHEAD of the linker's default paths no matter where in that variable a
directory sits, so on a normal launch (empty LD_LIBRARY_PATH) the bundle
remained the only explicit search directory and still won. Merely appending it
changed nothing. The host's WebKit libdir is now named explicitly, ahead of
ours. The new tests fail 3/3 against the previous version.
Greptile P1 — a host with the runtime but no -dev package has no .pc file, so
pkg-config can't answer and the check rejected a perfectly good system WebKit.
The libdir probe now falls back to ldconfig, and OMNIVOICE_PREFER_SYSTEM_WEBKIT
gives those users an explicit opt-in (=0 opts out) rather than gambling on an
unverified version, which would risk the #961 regression.
CodeRabbit — my changelog script had also inserted the CI entry into the
published 0.4.0 section. Removed; it belongs only under Unreleased.
CodeRabbit — the docs' source-build fallback used 'cd frontend', not the
repo-root flow the rest of the page documents. Fixed.
The Windows-only 25 came from a warm-cache measurement: Linux and macOS finish
in ~65s when setup-uv restores its cache, so 10 looked generous. Run
30439640107 then hit "Failed to restore: Cache service responded with 400",
Linux installed torch from scratch, and the leg was killed at 10m17s. The 65s
was the cache, not the platform — my per-leg split was reasoning from the
wrong baseline.
A cache miss is not rare enough to treat as an outage (the cache service 400s,
a lockfile change invalidates the key, a new runner image starts empty), and a
timeout here is self-perpetuating: the leg dies before the post-step saves the
cache, so the next run is cold too.
25 everywhere, still bounded.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* ci(windows): make the ffmpeg retry test the outcome, not choco's exit code
The chocolatey feed 503'd; choco printed "Unable to find package 'ffmpeg'"
and "installed 0/0 packages" — then exited 0. The retry loop added on
2026-07-20 for this exact class was `choco install ... && break`, so it broke
out on attempt 1, no backoff ran, and the job died one line later on
`ffmpeg: command not found`. It took #1281 red on an unrelated change.
A retry that trusts a lying exit code is not a retry. The loop now exits on
`command -v ffmpeg` and still fails the job loudly when ffmpeg never arrives.
Tests extract the real step body from ci.yml and run it against a stubbed
choco; 2 of the 4 fail against the previous loop.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* test(ci): pin PATH to the stub dir so the retry test can't false-green
The harness inherited the ambient PATH, so a real ffmpeg satisfied
`command -v` and the loop exited on attempt 1 — every assertion passed
against a broken workflow. It happened twice: /opt/homebrew/bin locally, then
/usr/bin on the Linux runner, which is what took this PR red.
PATH is now the stub dir alone, with the few real tools the stubs need
symlinked in, and stub shebangs are absolute (`/usr/bin/env bash` cannot
resolve bash when PATH is one directory). test_harness_actually_hides_ffmpeg
asserts the sandbox is a sandbox, so the next leak fails loudly instead of
quietly passing.
2 of 5 fail against the old `&& break` loop.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* ci: give the Windows smoke leg its own timeout, per-leg not shared
Smoke (Windows) has been dying at 10m08s inside `uv sync`, and the shared
10-minute budget made it self-perpetuating: the leg is killed before the
post-step saves the uv cache, so the next run starts cold and dies the same
way. Nothing primes the cache, so it never gets faster.
Measured on run 30385710466 — Linux 65s, macOS 65s, Windows still installing
torch when the job was killed. Windows now gets 25 minutes, priced for one
cold install to finish and populate the cache; warm runs land nowhere near it.
Per-leg rather than raising the shared value, so a genuine hang on Linux or
macOS still fails fast instead of inheriting Windows' allowance.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* ci(windows): skip the backoff after the final attempt; tighten the tests
CodeRabbit, both valid:
- The loop announced "retrying in 90s" and slept after attempt 3, though no
fourth attempt exists — 90s added to an already-doomed job.
- The retry tests asserted `attempts >= N`, so a regression that kept going
after ffmpeg appeared would still pass. Pinned to exact counts, plus a case
asserting the final attempt announces no retry.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* fix(engines): show the under-provisioned-VRAM warning instead of discarding it
Four of the open low-VRAM reports (#1240, #1246, #1248 on 4 GB cards; #1277
on 6 GB) share one shape: the user generates, waits out the entire 300s
compute budget, and is then told the job "was too heavy for the available
compute".
The warning existed the whole time. Routing computes it (#1226's `_caveat`:
"…has 4.0 GB VRAM; this engine wants about 6 GB. It will run, but expect slow
generations that may time out"), and `/engines/select` echoes it in
`routing_reason` — but notifyEngineSelected only surfaced a reason when
`routing_status === 'cpu_fallback'`. The VRAM caveat rides on an ACCELERATED
verdict, so it fell through to the green "switched" success toast and was
thrown away. The user was told everything was fine, then waited five minutes
to find out it wasn't.
Now any caveat on the echo raises a warn-tone toast naming it, with a longer
duration since it lists the ways around the limit. This covers the kernel-risk
caveat on the same path.
Deliberately still ADVISORY, not blocking — matching the routing layer's
documented contract (the driver can page to system RAM, and short inputs fit
where long ones don't). The engine is still selected; the user just finds out
now instead of after the timeout. This is the first-run path too: the wizard's
library step shares notifyEngineSelected.
Fail-before verified: both new tests fail against the previous version.
Known remaining gap: a user whose engine is already selected sees this only
when they re-pick. A generate-time preflight would close that, but it needs a
"once per session, not per generate" design — filed as follow-up rather than
guessed at here.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* ci(release): stop the macOS preview updater bundle colliding with itself
The nightly preview run has failed on both macOS legs since early July:
Uploading OmniVoice Studio_x64.app.tar.gz...
##[error]Validation Failed: {"resource":"ReleaseAsset",
"code":"already_exists","field":"name"}
`preview` is a ROLLING release, reused every night, and macOS updater
artifacts are the only ones Tauri names without the version:
OmniVoice Studio_0.4.1-103_x64.dmg unique per run — uploads fine
OmniVoice Studio_x64.app.tar.gz constant — collides on run 2+
Consequences, verified against the live release: the macOS updater bundles on
`preview` were last written 2026-07-04 (x64) and 2026-07-05 (aarch64), and
latest.json 2026-07-13 — three weeks stale as of today. Preview-channel macOS
users had no working update path. The failure also lands AFTER the dmg
upload, so each run looked partly successful while going red.
Deletes this arch's updater bundle before the upload. Matches the STORED
asset name by querying the release rather than guessing the spelling — GitHub
rewrites spaces to dots, so "OmniVoice Studio_x64.app.tar.gz" is stored as
"OmniVoice.Studio_x64.app.tar.gz" and a literal delete-asset by the uploaded
name would silently no-op.
Scoped to the preview path (a v* tag creates a fresh release with nothing to
collide with) and to the job's own arch, so the parallel aarch64/x64 legs
can't touch each other's assets. Verified the filter against all 209 live
preview assets: it matches exactly the 4 colliding updater files and no
versioned artifact.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* ci(release): fail loud when the preview asset sweep can't do its job
The cleanup step treated every `gh` failure as "nothing to clear" — 401, 403,
429 and network errors included. That reintroduces the outage it was written
to fix, with the evidence removed: the stale bundle survives, the Tauri upload
dies with `already_exists`, and the one step that could have explained why is
green. Three weeks of broken macOS Preview updates started exactly this way.
Only an absent release/asset is benign now. A 404 on view means "no preview
release yet" (GH_TOKEN is scoped to this repo, so 404 really is absence); a
404 on delete means someone already removed it, which satisfies the goal. Every
other failure fails the step with the reason printed. An unexpected arch is
also fatal rather than a silent skip — same class of blind spot.
Adds tests/test_release_preview_asset_cleanup.py, which extracts this step's
real shell body from release.yml (so it cannot drift) and runs it against a
stubbed `gh`: 6 of the 8 cases fail against the previous version.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Keep the ranking (avatars still ordered by PR count) but not the explanatory
sentence — just "## Contributors" + "Thank you all 💜" + avatars. Live v0.4.0
release updated to match.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Stable v* releases previously credited nobody: the Contributors avatar strip
was wired only for the preview channel (preview-notes job), so the release
page showed just GitHub's native widget — which lists only the externally
@-thanked PR authors, never the owner, and can't be ordered.
Add a `contributors-strip` job that appends one "## Contributors" strip to the
stable release, crediting every PR author for the tag including the owner,
ranked by merged-PR count (desc, ties by handle). It suppresses GitHub's
duplicate native widget by neutralising the inline "— thanks @user!" text
mentions in the RELEASE body only (the repo CHANGELOG keeps the @handles); the
strip's own @handles sit in HTML attributes, which GitHub does not count as
mentions, so avatars stay linked.
Appends via `gh release edit` on the existing release (never a second softprops
publish — that splits installers across two releases), runs once (no matrix
race), and is idempotent (strips any prior block + inline @thanks before
re-appending). Docs: RELEASING.md §5b release-body row updated.
The live v0.4.0 release has been corrected to match (single ranked strip,
native widget gone).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The mount chain (StrictMode → QueryClientProvider → RemoteAuthGate → App) had
no error boundary above App. App's per-tab <ErrorBoundary> wraps only cover
their own subtrees, so a throw in App's own render — its top-level hooks, store
access, restoreProjectExtras, or the header/sidebar chrome that renders before
any tab — escaped every boundary and left #root empty. The shell's blank_guard
could then only reload three times and paint a dead-end failure page: exactly
the "#root children = 0 after 3 reloads" a user hit.
Wrap the mount root in <ErrorBoundary name="app-root"> so such a throw shows the
in-app, recoverable error card (Reload / Report, data untouched) instead of a
blank window. The shell guard stays as the last resort for the rarer case where
even the boundary can't render.
Recurrence-proofing (the throw only had to happen once to blank the app, and the
#1178-class variant only bites the MINIFIED bundle that dev + jsdom never run):
- main-app.test.jsx: fail-before/pass-after — a throwing app tree must leave
#root non-empty (the recovery card), not blank.
- Wire the existing production-bundle smoke (e2e-prod/prod-bundle-smoke.spec.ts)
into CI so a pre-render crash in the shipped bytes fails the pipeline, not a
user's launch. Fixed its IGNORABLE list to treat the no-backend WebSocket
handshake error as the harness artifact it is.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Only the TTS model (~2.4 GB) is required on first run; ASR models are
per-platform curated picks (curated_on in models.yaml) installed on demand.
Every transcription surface returns a typed asr_model_missing error with a
one-click download CTA instead of silently pulling multi-GB Whisper weights.
Settings -> Models is a grouped, platform-aware catalog. New guided
permissions UX (wizard System Check + Settings -> Permissions + mic
pre-flight) with native mic-state checks and OS settings deep-links. New
parakeet-mlx engine brings Parakeet TDT v3 to Apple Silicon (language-gated
capture preference so multilingual dictation never regresses). Docs:
expressive-speech page, Flush/Unload + CPU-fallback triage, clone-length FAQ.
Hardening: preflight fails open for custom model pins, ROCm curation no
longer inherits NVIDIA picks, Windows mic probe reads the NonPackaged
consent key, CaptureWidget setup race fixed, offline-cache CI simulation
fixes so empty-cache runners stay green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The two builds flip-flopped against the runner disk ceiling: CUDA hit
ENOSPC this morning (fixed by adding the reclaim step), then ROCm hit it
this afternoon even WITH the original list. Reclaim everything neither
job can use (~40-45 GB: full hostedtoolcache, swift, boost, global
node_modules, apt cache) and keep the list byte-identical in both jobs
so they can't drift apart again.
The ROCm job got the reclaim step when it shipped; the CUDA job kept
running on default free space and finally hit 'No space left on device'
during uv pip install (2026-07-16, two consecutive main builds red).
Mirror the same ~25-30 GB toolchain cleanup.
A release now has an explicit channel checklist (docs/RELEASING.md §5b):
GH Release + stable updater manifest, preview updater channel, GHCR +
Docker Hub in both CUDA and ROCm flavors, and the Docker Hub overview
sync (whose continue-on-error step must be verified by step log — it
403s silently on tokens without description-edit scope).
Preview/RC policy is now enforced, not just documented: release.yml's
preview-gate fails publish_preview dispatches from any branch but main,
since the preview manifest and rolling Docker tags all track main.
Also fixes docs/RELEASING.md §4-5, which still described the pre-2026-06
versioning scheme (tauri.conf.json + Cargo.toml as sources, 'Tauri
ignores package.json') — the exact opposite of the current single-source
rule — and docs/update-channels.md, which invited previews off feature
branches.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The Docker image was CUDA-only, so AMD GPUs (e.g. RX 7900 XTX under
Podman) silently ran on CPU. Every preview and release now also ships a
ROCm variant built from the same Dockerfile:
- deploy/Dockerfile: parameterize the runtime base with a BASE_IMAGE
build-arg (default unchanged: pytorch/pytorch 2.8.0 CUDA). Add
PIP/UV_BREAK_SYSTEM_PACKAGES for the ROCm base's PEP-668-marked
Ubuntu 24.04 Python (no-op on the conda CUDA base), and a build-time
GPU_FLAVOR guard asserting the dependency install did not clobber the
base image's GPU torch/torchaudio — a future dep bump that forces a
torch reinstall now fails the build instead of shipping a CPU-only
"ROCm" image.
- .github/workflows/docker.yml: new build-and-push-rocm job (separate
job for runner disk — the ROCm base is ~25 GB unpacked, so it frees
the preinstalled toolchains first). Tags mirror the CUDA semantics
with a -rocm suffix (:rocm rolling preview, :stable-rocm, :X.Y.Z-rocm,
:X.Y-rocm, :sha-xxxx-rocm) on both GHCR and Docker Hub, same secret
gating. flavor latest=false so release tags can't clobber :latest.
No cache-to: the ROCm layers would blow the 10 GB GHA cache budget.
- deploy/docker-compose.yml: new opt-in 'rocm' profile passing the GPU
through via /dev/kfd + /dev/dri, with HSA_OVERRIDE_GFX_VERSION=11.0.0
documented (user-set, not baked in — backend auto-sets it for known
consumer GFX IDs).
- Docs-sync: docker.md (ROCm quick start incl. Podman/Quadlet, tag
table, troubleshooting), dockerhub-overview.md, README AMD note,
linux.md ROCm section cross-link, CHANGELOG [Unreleased].
Base image: rocm/pytorch:rocm7.2.4_ubuntu24.04_py3.12_pytorch_release_2.8.0
— torch 2.8.0 exactly matches the CUDA image (identical resolution, so
uv keeps it), py3.12 satisfies requires-python >=3.11 (the ubuntu22.04
variants are py3.10 and do not).
Closes#1165
Co-authored-by: mergetest <nizam4103@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* fix(shell): version-gate crash markers, pin the WebView repair contract, run the Rust suite in CI
Two deferred items from the recurrence audit, plus the CI gap that made
them possible:
- crash.rs: a persisted "backend crashed" marker now only surfaces for
the release that wrote it. After an upgrade, markers from the previous
version (quite possibly the build whose crash the upgrade fixed) are
ignored and pruned on read instead of resurfacing unacknowledged as if
the new build had crashed. backend_version gains #[serde(default)] so
legacy version-less markers still deserialize — as "", which the gate
treats as stale by design. Preview stamps (X.Y.Z-N) count as their
release.
- commands.rs: the #879 WebView2 cache repair's filesystem half is
extracted into clear_webview_cache_at() (paths + retry policy as
parameters, zero behavior change) and its contract is pinned by tests:
no marker → nothing touched; marker consumed first, unconditionally
(one-shot — a failing repair can never loop across launches); missing
cache is success; a locked cache is retried then abandoned with a log,
never bricking startup.
- ci.yml: the Tauri shell check only ran `cargo check`, which neither
compiles nor runs #[cfg(test)] code — so the shell's ~90 unit tests
(crash.rs, reset.rs, bootstrap.rs, …) never executed anywhere in CI.
`cargo test --lib` now runs them natively on all three OSes.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(shell): crash-notice read path is strictly read-only — a prune-save there could destroy a fresh marker
Greptile's P1 is real, and hotter than stated: get_last_backend_crash is
not just a startup check — streamDropError (#1119) polls it every second
for 8 s after a stream drops, which is exactly when the death watcher is
inside record_crash's load→push→save. The previous commit's read path did
load→prune→save when stale-version markers existed (the post-upgrade
state), so a poll could load the pre-crash snapshot, lose the race, and
save over the freshly recorded marker — silently deleting the only
evidence of the crash it was being polled to find.
Smallest fix: reads never write. The read path (extracted as
read_notice_from(path, version) so the contract is testable) filters
stale-version markers in memory only; disk pruning stays on the write
paths (record_crash, acknowledge_backend_crash), where load-modify-save
already existed pre-PR and is paced by a crash or a user click rather
than a 1 Hz poll. Regression test pins the file as byte-identical across
reads, stale markers filtered and current ones surfacing as before.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: mergetest <nizam4103@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* feat(analytics): wire posthog-js — consent-gated, autocapture OFF
The owner supplied the standard snippet:
posthog.init(TOKEN, { api_host, defaults: '2026-05-30' })
Shipping that verbatim would have broken the guarantee we just made, twice:
1. It initialises AT MODULE LOAD — it starts tracking every user before they
have consented to anything. The README now says "OmniVoice sends nothing out
of the box"; this would have made that false on the very next release.
Analytics is therefore started ONLY after the user opts in (Settings →
Privacy), and the stored consent is what restores it at launch.
2. posthog-js AUTOCAPTURES by default, and `defaults: '2026-05-30'` turns that
on. Autocapture sends the text content of the DOM elements a user interacts
with. In THIS app the DOM holds the script they are about to synthesise,
their voice names and their file names — exactly the content we promise never
leaves the machine. It is explicitly disabled, along with session recording
(which records the screen) and pageview capture.
utils/analytics.ts: hardenedConfig() — autocapture false, disable_session_recording
true, capture_pageview/pageleave false, mask_all_text + mask_all_element_attributes
as defence in depth, and opt_out_capturing_by_default so init alone can never
capture. Events pass sanitizeProps(), mirroring the backend allowlist: a key not
on it is DROPPED and long strings refused, so a future caller cannot leak content
by adding a field. Backend down / no consent / no destination → stays off.
The token is taken from VITE_POSTHOG_KEY at BUILD time and is never committed —
a token-shaped literal trips the secret scanner and is a bad habit regardless.
release.yml injects it from a repo secret; the backend already reads
POSTHOG_PROJECT_TOKEN the same way. No token => no destination => the Privacy
toggle isn't offered and nothing can be sent, which is the right default for a
source build. A test fails if a phc_ literal is ever committed to that file.
posthog-js added to frontend/package.json; root bun.lock regenerated and
`bun install --frozen-lockfile` verified (the Docker gate).
11 tests: autocapture/session-recording/pageview off, starts opted-out, allowlist
drops text+paths+names, long strings refused, consent honoured in all three
failure directions, and no token literal in source. Frontend suite 1229 passed.
* test(analytics): guard the committed-token rule in the suite, not just in the scanner
The frontend typecheck failed on the guard I added: it reached for `node:fs`,
which has no type definitions in the frontend tsconfig (and would have been
cwd-dependent at runtime anyway). Wrong layer.
Source-scanning guards in this repo are Python tests (test_no_hardcoded_cjk,
test_no_literal_borders), so this one moves there — and gets strictly stronger
in the process: it scans every tracked file rather than analytics.ts alone, and
matches a PostHog key by SHAPE (phc_[A-Za-z0-9]{20,}), so a *different* key
can't slip through where the old test only knew about the one gitleaks caught.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: mergetest <nizam4103@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
v0.3.20 shipped with ONLY the Linux AppImage — the macOS dmg and Windows msi
were missing from the published release. Root cause: the uninstall-scripts job
(added in #1097) ran softprops/action-gh-release@v2 as a SECOND publish for the
tag, which raced tauri-action's per-matrix draft and split the platform
installers across two releases (a draft holding mac/windows, a published one
holding linux + checksums + the scripts). The updater manifest split too — each
release's latest.json covered only its half of the platforms.
Fix: attach the scripts with `gh release upload <tag> … --clobber` (which adds
assets to the EXISTING release and can never create a second one) instead of
softprops. `needs: [build]` guarantees the release exists first; --clobber keeps
a re-run idempotent.
(v0.3.20 itself was already repaired by hand — the mac/windows bundles were
re-attached from the draft, the latest.json manifests merged into one covering
all 8 platform keys, and the stray draft deleted. This prevents recurrence.)
Co-authored-by: mergetest <nizam4103@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* feat(settings): in-app uninstall — Settings → Storage → "Remove all data"
The v0.3.19 uninstaller was a SCRIPT, which never reaches the people who need
it: anyone who installed the .dmg / .msi / AppImage has no repo to run
scripts/uninstall.sh from — exactly the reporter in #1089, an AppImage user.
"Where is uninstall in the app?" had no answer. Now it does.
New Tauri commands (uninstall.rs):
- uninstall_scan — every folder this install owns, with real sizes, resolved
through the same setup.rs helpers the app itself uses, so custom + portable
locations are cleaned instead of the defaults being assumed.
- uninstall_purge — stops the backend (marking the kill intentional so the #567
supervisor doesn't respawn one into the directories being deleted), removes
the folders, and lets the UI quit the app: the Python env it runs on is gone,
so there is nothing to return to.
This lives in the Rust shell, not the backend, because the biggest thing to
remove is the managed Python environment and the backend is RUNNING FROM IT — a
process can't delete its own interpreter (and Windows locks the files).
Safety: every path must pass is_recognizably_ours() before any remove_dir_all —
absolute, not `/` or $HOME, and carrying an OmniVoice-owned component (unit
tested both ways). The shared Hugging Face cache is reported separately and is
OPT-IN behind its own checkbox with the caveat spelled out: it's the standard HF
cache other ML tools share, so sweeping it up silently would delete models this
app never downloaded. Deleting voices/projects is irreversible, so the confirm
requires TYPING the word, not just a click.
Also fixes a real bug in what shipped in v0.3.19: the scripts and docs missed
where the BACKEND writes its logs — ~/.local/state/OmniVoice on Linux and
%LOCALAPPDATA%\OmniVoice\Logs on Windows (backend_log_path(), backend.rs) — so
every Linux/Windows uninstall left a stray log dir behind. Covered now in the
scripts, the docs, and the in-app scan.
And the scripts now ship as release assets, so cleanup is possible without
launching the app at all.
Rust: 2 new guard tests. Frontend: 6 new tests (the size on the confirm button
must equal what actually gets deleted); suite 1182 passed. Docs synced.
Refs #1089
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(settings): drop token border utilities from UninstallPanel (design guard)
tests/test_no_literal_borders.py::test_no_token_border_utilities_in_jsx is a
backend guard that scans JSX — so a frontend-only test run misses it. It forbids
`border-[var(--chrome-border)]` structural utilities: the app-wide border removal
converted every panel/row frame away from them, and they render a stray hairline
the moment the token doesn't resolve transparent.
Row dividers → spacing + an alternating `--chrome-hover-bg` tint; the opt-in
checkbox card → a background tint; the confirm input → the sanctioned arbitrary
`[border:1px_solid_var(--chrome-border)]` property form the other settings inputs
already use (explicitly not flagged by the guard).
Guard green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: mergetest <nizam4103@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Four pieces of test debt, root-caused and hardened:
1. exports.py test coverage (was: zero dedicated tests): new
tests/test_exports_api.py (26 tests) covering /export, /export/record,
/export/history, /export/reveal — happy paths, traversal/containment
guards (incl. symlink escape), destination validation, error mapping,
and the mp4 watermark-overlay branch with its plain-copy fallback.
Two real bugs found and fixed in the router:
- _safe_destination checked isabs() on realpath()'s output, which is
always absolute — dead check; a relative destination silently exported
to a cwd-dependent location instead of the documented 400.
- _safe_source let "." / ".." through the basename guard (caught only
later by realpath containment as a confusing 404); now 400 up front.
2. CI-Linux fp16 default-dtype leak (test_prefers_vocals_over_mix,
test_final_dub_track_and_seg_wav_are_watermarked): not reproducible on
macOS — instrumenting torch.set_default_dtype across both tests records
zero non-fp32 sets locally. Both tests now carry an opt-in
torch_dtype_isolation fixture (save/restore, so the leak can never
spread), and the conftest guard is demoted to pure insurance. A cheap
permanent recorder wraps torch.set_default_dtype /
set_default_tensor_type once torch appears and captures the setter's
stack only on a non-fp32 set; both fixtures print that stack when they
fire, so the next CI occurrence names the exact culprit call chain.
3. Test-order pollution (both reported combos): root cause was
collection-time sys.modules stubbing in backend/tests — seven modules
installed bare ModuleType stubs for core.config (and test_capture_ws.py
for services.model_manager/asr_backend/ffmpeg_utils, now all lazily
imported by the router anyway). pytest imports test modules during
collection, so the stubs leaked process-wide before any test ran:
- combo (a): monkeypatch.setattr("core.config.OUTPUTS_DIR", ...) in
test_longform_e2e died with AttributeError (core never gets a .config
attribute when the import is satisfied straight from sys.modules).
- combo (b): test_router_smoke's `from main import app` died with
ImportError: cannot import name 'find_ffmpeg' (unknown location).
Fix at source: new backend/tests/conftest.py sets a hermetic
OMNIVOICE_DATA_DIR (mirroring tests/conftest.py, #878) and the real
core.config is imported everywhere — zero sys.modules surgery. New
backend/tests/test_no_module_stubs.py guards the whole class (verified
fail-before/pass-after against the old stub). Stale rationale comments
in pyproject.toml and ci.yml updated to match.
4. batched_tts.py TODO(#312): investigated, comment corrected only —
#312 is closed (the live routes are engine-aware); this module has zero
call sites and stays an unintegrated experiment. See PR notes.
Full tests/ suite: 2796 passed. backend/tests standalone: 130 passed.
Both pollution combos re-run green in the reported orderings.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
The AppImage bundles whatever libwebkit2gtk-4.1-dev the build runner's
apt repos resolve at build time (see the "Linux system deps" step) —
AppRun's LD_LIBRARY_PATH then makes that bundled copy take priority
over the host's system WebKitGTK at runtime. ubuntu-22.04's version
was stale relative to what current distros (Ubuntu 24.04+, Fedora 44)
ship, which is why a from-source build (linking straight against the
host's healthy system library) worked fine on the exact machine where
the shipped AppImage white-screened — the released binary was running
an older, buggier WebKitGTK under the hood regardless of the host.
Bumped the Linux release matrix entry to ubuntu-24.04, and ci.yml's
Tauri shell-check job to match (its own comment already says "Mirror
release.yml" — now it actually does, so a green PR check accurately
predicts the release build will also succeed).
Raises the AppImage's glibc floor from 2.35 to 2.39 (Ubuntu 24.04+) —
README's system-requirements table corrected from the now-false
"Ubuntu 20.04+" claim. No reports of anyone on a pre-2022 distro.
This does not fix the AppRun launcher's separate, related bug (its
WebKitGTK-version auto-detection reads the *system's* pkg-config
version, not the version actually bundled and running) — that would
need a reliable way to read the bundled .so's version from within the
AppImage, which isn't straightforward (WebKitGTK's soname doesn't map
1:1 to its release version) and isn't verifiable without a real Linux
build environment to test against. Left as a known, separate gap.
Cannot be verified from here on a real Ubuntu 26.04 machine — shipped
on the strength of the root-cause diagnosis, pending the reporter's
confirmation.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
GitHub's release-list sidebar clips the title mid-string, hiding the version
when it trails 'OmniVoice Studio'. Name stable releases 'vX.Y.Z — OmniVoice
Studio' and the preview 'Preview — OmniVoice Studio'. Existing releases were
renamed to match.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
torch >=2.3 ships no macOS x86_64 wheels (transformers 5.x needs torch >=2.6),
so `uv sync` can never resolve on an Intel Mac — per the platform-parity rule
the honest option is declaring the platform unsupported, not letting first
launch die in a raw resolver error:
- bootstrap.rs: pre-check on macOS x86_64 before any venv create / uv sync
(first-run AND repair paths) fails fast with an actionable message
(remote-backend escape hatch + docs link); healthy pre-torch-bump venvs are
deliberately untouched. Unit test pins the message's load-bearing phrases.
- BootstrapSplash: routes the failure to a dedicated localized hint
(bootstrap.hint_intel_mac, all 21 locales) and suppresses the useless
Retry-oriented hints for it.
- README + docs/install/macos.md (+ troubleshooting #9): every Intel-Mac
support claim now says UI-installs-but-backend-cannot-run, including the
from-source path (also broken); remote backend documented as the only use.
- release.yml: #889 note on the macos-15-intel leg — artifact is UI-only;
keep-or-drop is an owner call, deliberately not changed here.
- docs/install/windows.md: new "Portable install (Windows)" section promised
in #766 — custom MSI wizard folder / msiexec INSTALLDIR=..., what lives in
OmniVoiceStudio-Data next to the exe, and the Program-Files-greyed-out why.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* Revert "chore(version): main -> 0.3.9 after v0.3.8 release"
This reverts commit 7489bef085.
* chore(release): gate the post-release version-bump behind AUTO_VERSION_BUMP (owner controls bumps)
Owner decision (2026-07-01): keep main pinned to the released version and bump
only on explicit request. The version-bump job now runs only when the repo
variable AUTO_VERSION_BUMP == 'true' (default off), so releasing no longer
auto-rolls main to +1. Documented the override in CLAUDE.md's versioning rule.
---------
Co-authored-by: mergetest <test@local>
Adds oxfmt (Rust formatter, Prettier-conformant) — the repo had no formatter, so
this is a one-time normalization of the JS/TS/JSX code (257 files; purely
cosmetic — full suite stays 638/638).
Scope is deliberately narrowed in .oxfmtrc.json to JS/TS/JSX only:
- singleQuote:true + jsxSingleQuote:false — preserve the project's existing
style (single-quoted JS, double-quoted JSX attrs), not oxfmt's double-quote
default. (Flipping quotes globally also broke a source-string-parsing test;
preserving them keeps featureCoverage green.)
- Excludes **/*.css (the CSS→Tailwind migration will rewrite those — formatting
them now is wasted churn), **/*.json (avoids reformatting 20 i18n locale
files + config), **/*.toml, and src-tauri/** (Rust/Tauri config — out of scope
for a frontend JS formatter; oxfmt was reformatting Cargo.toml/tauri.conf.json).
Tooling:
- `bun run format` (write) / `bun run format:check` (verify).
- ci.yml: new "Frontend format check (oxfmt)" gate after the oxlint gate.
Verified: format:check clean; oxlint 0 errors; vite build; full suite 638/638;
bun install --frozen-lockfile in sync.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>