chore: retire legacy Tauri app and UI

This commit is contained in:
Palash Debnath
2026-09-26 11:26:48 -07:00
parent 930d814aa9
commit d6348b56f2
243 changed files with 401 additions and 48750 deletions
+4 -7
View File
@@ -81,15 +81,13 @@ cause doesn't scroll away with the terminal. The same death is also reported
as a crash notice in the UI the next time the backend starts (see
[docs/install/troubleshooting.md §14c](docs/install/troubleshooting.md)).
### Archived desktop (Tauri)
### Retired desktop (Tauri)
Tauri is sunset after v0.5.3 and receives no further development or backports.
Use Electron for desktop contributions and reproduce desktop bugs there.
Existing users should follow the [migration guide](../docs/electron-migration.md).
Archived sources remain for history and migration; shared backend/web code and
native helpers still used by Electron remain maintained.
The root package exposes no Tauri launch or build command, and active CI does
not compile the archived shell.
The Tauri shell and its legacy UI entry points have been removed. Shared
modules and native helpers used by Electron remain maintained.
---
@@ -104,13 +102,12 @@ VoiceStudio/
│ └── tts_backend.py # ← Multi-engine TTS registry
├── electron/ # Active Electron desktop: main, preload, renderer
├── native/ # Desktop helpers used by Electron
├── frontend/ # Web UI, shared modules, and archived desktop
├── frontend/ # Transitional modules shared by Electron
│ ├── src/
│ │ ├── components/ # UI components
│ │ ├── hooks/ # Custom React hooks
│ │ ├── stores/ # Zustand state slices
│ │ └── utils/ # Shared utilities
│ └── src-tauri/ # Archived Rust/Tauri desktop shell
├── deploy/ # Docker, CI configs
├── docs/ # Screenshots, MCP config
└── scripts/ # Build & release scripts
+1 -1
View File
@@ -31,7 +31,7 @@
- [ ] I've tested this locally
- [ ] I've updated relevant documentation (if applicable)
- [ ] No local machine paths, logs, or personal env details in this PR
- [ ] Maintained version files are in sync (if an owner-requested bump): `frontend/package.json`, `pyproject.toml`, `backend/core/version.py`, and lockfiles; archived Tauri versions remain frozen
- [ ] Maintained version files are in sync (if an owner-requested bump): root `package.json`, `pyproject.toml`, `backend/core/version.py`, and lockfiles
- [ ] If this PR changes runtime behavior, the regression fixture at `tests/fixtures/omnivoice_data/` still loads green on the `smoke-matrix` CI job (macOS + Windows + Linux)
## Release cadence
+5 -57
View File
@@ -103,14 +103,6 @@ jobs:
env:
HF_HUB_OFFLINE: "1"
# Docs-drift CI gate (Phase 1 INST-06). The validator extracts code
# blocks tagged `<!-- validate -->` from docs/install/*.md and asserts
# each line appears in scripts/desktop-prod.sh after normalisation.
# Its own correctness is enforced by tests/scripts/test_validate_install_docs.py
# (checker B-5) — those tests run in the previous step.
- name: Validate install docs against desktop-prod.sh
run: python scripts/validate-install-docs.py
# `backend/tests/` mounts routers on bare FastAPI apps (no heavy main
# import chain) with a hermetic data dir from its conftest.py. It no
# longer stubs sys.modules, so mixed sessions with tests/ are safe;
@@ -126,70 +118,26 @@ jobs:
uses: actions/cache@v4
with:
path: ~/.bun/install/cache
key: ${{ runner.os }}-bun-${{ hashFiles('frontend/bun.lock', 'bun.lock') }}
key: ${{ runner.os }}-bun-${{ hashFiles('bun.lock') }}
restore-keys: |
${{ runner.os }}-bun-
- name: Install frontend deps
working-directory: frontend
# --frozen-lockfile so a frontend/package.json change that forgets to
- name: Install Electron deps
# --frozen-lockfile so an Electron workspace change that forgets to
# regenerate the root bun.lock fails HERE (fast) instead of only in the
# Docker build (deploy/Dockerfile), which is what reddened main on #485.
run: bun install --frozen-lockfile
# checkJs is true in tsconfig for IDE feedback, but 947 pre-existing
# JS errors remain. Override to false in CI so only .ts files block.
# Sourced from `typecheck:ci` in frontend/package.json so the release
# workflow runs an identical command — drift broke v0.3.x release runs.
- name: Frontend typecheck
working-directory: frontend
run: bun run typecheck:ci
# oxlint gate — fast Rust linter, blocks on errors so lint debt can't
# re-accumulate (warnings, incl. the react-compiler advisories in
# `lint:hooks`, are non-blocking). See frontend/.oxlintrc.json.
- name: Frontend lint (oxlint)
working-directory: frontend
run: bun run lint
# oxfmt format gate — JS/TS/JSX only (CSS/JSON/Tauri excluded; see
# frontend/.oxfmtrc.json). `bun run format` fixes locally.
- name: Frontend format check (oxfmt)
working-directory: frontend
run: bun run format:check
# `bun run test` (frontend/package.json), not `bunx vitest` — bunx
# resolves by npm package name and can miss workspace-hoisted bins,
# then falls back to fetching from npm (#962 class).
- name: Run Vitest (frontend)
working-directory: frontend
run: bun run test
# Legacy node:test runner for tests/frontend/*.test.mjs
- name: Run frontend node:test (legacy)
working-directory: frontend
run: node --experimental-strip-types --no-warnings --test ../tests/frontend/*.test.mjs
# Electron used to be built only after a release started, so renderer,
# preload and packaging regressions could pass the required PR gate.
# Keep this command shared with electron-release.yml through the root script.
- name: Electron typecheck, tests and production contract
run: bun run check:electron
# Production-bundle blank-screen gate. Everything above runs UN-minified
# (dev server + Vitest/jsdom), so a crash that exists ONLY in the minified
# release bundle — a TDZ reorder that throws before React mounts — passes
# every check and ships a black screen. That is how v0.3.22 went out (#1178),
# and it recurred pre-0.3.23. This builds the real dist/ and asserts the app
# actually mounts into #root. See frontend/e2e-prod/prod-bundle-smoke.spec.ts.
# (The in-app root <ErrorBoundary> in main-app.jsx catches such throws at
# runtime; this gate stops them reaching a release in the first place.)
# Browser workflow smokes exercise the maintained Electron renderer after
# the production build/typecheck/test contract above succeeds.
- name: Install Playwright chromium
working-directory: frontend
run: bunx playwright install --with-deps chromium
- name: Production-bundle smoke — no blank screen
working-directory: frontend
run: bun run test:prod-bundle
- name: Electron renderer workflow smokes
shell: bash
run: |
+1 -1
View File
@@ -5,7 +5,7 @@
# job updates (or creates) ONE issue labeled `docs-drift` in place — no issue
# spam — and closes it automatically when the check is clean again.
#
# Companion to the PR-gating validate-install-docs.py step in ci.yml.
# Scheduled documentation-link and structure audit.
# Spec: docs/competitive-analysis.md Spec 9a / parity program Wave 0.1.
# Rolling-issue pattern adapted from Patter (MIT).
+1 -1
View File
@@ -56,7 +56,7 @@ jobs:
if [ -n "$RELEASE_TAG_OVERRIDE" ]; then
test "$WORKFLOW_REF" = refs/heads/main || { echo "Tag overrides require the workflow from main"; exit 1; }
fi
VERSION=$(node -p "require('./frontend/package.json').version")
VERSION=$(node -p "require('./package.json').version")
test "$REF" = "refs/tags/v$VERSION" || { echo "Select the exact version tag"; exit 1; }
test "$(git rev-parse HEAD)" = "$(git rev-parse "$REF^{commit}")" || { echo "Checkout does not match the release tag"; exit 1; }
package:
File diff suppressed because it is too large Load Diff
+4 -4
View File
@@ -6,7 +6,7 @@
# • CodeQL — Python + JS/TS SAST. Results land in the Security tab.
# • bandit — Python SAST (SARIF → Security tab). Reporting, non-gating.
# • pip-audit — Python dependency advisories. Reporting, non-gating.
# • bun audit — frontend dependency advisories. Reporting, non-gating.
# • bun audit — Electron dependency advisories. Reporting, non-gating.
#
# Only the secret scan gates the PR. Dependency advisories and bandit findings
# are surfaced as signal (Security tab / job log) rather than blocking every PR
@@ -183,11 +183,11 @@ jobs:
with:
bun-version: "1.2"
# `bun audit` reports advisories against the frontend lockfile. Non-gating
# `bun audit` reports advisories against the Electron lockfile. Non-gating
# for the same reason; also tolerant of older bun without the subcommand.
- name: bun audit (frontend)
- name: bun audit (Electron)
continue-on-error: true
working-directory: frontend
working-directory: electron
run: |
bun install --frozen-lockfile
bun audit
+9 -10
View File
@@ -1,13 +1,12 @@
## Active desktop: Electron only
Electron (`electron/`) is the only maintained desktop app. Tauri is sunset and
archived; do not add features, repair its UI, or backport Electron changes to it.
Triage Tauri-only reports toward the Electron migration guide; investigate shared
backend defects only when they affect Electron or the supported API. Keep archived
source, migration data, and existing compatibility contracts intact. Some code under
`frontend/` is still shared with Electron or serves the web app: trace consumers
before editing or removing it. New desktop UI, IPC, setup instructions and tests
belong in Electron; validate Electron on macOS, Windows and Linux.
Electron (`electron/`) is the only desktop and web UI. The Tauri shell and legacy
UI entrypoints are removed; do not restore build, runtime, release, or CI paths for
them. Triage reports from final Tauri installations toward the migration guide and
preserve their immutable updater feeds. Some modules under `frontend/src/` remain
temporarily shared by Electron; they are not a runnable app. New UI, IPC, setup
instructions, tests, and browser assets belong in Electron; validate Electron on
macOS, Windows and Linux.
# Agent Rules — VoiceStudio
@@ -41,8 +40,8 @@ Binding for every AI agent (Claude, Codex, Cursor, review bots, …). CLAUDE.md
- Every user-facing string via i18n, present in ALL 21 `frontend/src/i18n/locales/*.json` with real translations.
- Docs-sync in the same PR. CHANGELOG Unreleased: quiet one-liners ending `(#N)` + `— thanks @user!` for community work, under a short `**Highlights**` list.
- Tagged release announcements lead with the biggest user-visible change; redesigns need real UI screenshots and migrations need installer links and steps. Verify all contributor credits from the tag comparison and included PRs; list authors and bug reporters separately (see `docs/RELEASING.md`).
- Versioning: `frontend/package.json` is the single source of truth; never bump without the owner asking.
- `frontend/package.json` dep changes require regenerating root `bun.lock` (Docker runs `--frozen-lockfile`).
- Versioning: root `package.json` is the single source of truth; never bump without the owner asking.
- JavaScript dependency changes require regenerating root `bun.lock` (Docker runs `--frozen-lockfile`).
- Issues: absorb or decline — never defer to a future version. Check the open-PR queue before implementing community-reported fixes.
## Shared select controls
+10 -11
View File
@@ -1,20 +1,19 @@
## Active desktop: Electron only
Electron (`electron/`) is the only maintained desktop app. Tauri is sunset and
archived; do not add features, repair its UI, or backport Electron changes to it.
Triage Tauri-only reports toward the Electron migration guide; investigate shared
backend defects only when they affect Electron or the supported API. Keep archived
source, migration data, and existing compatibility contracts intact. Some code under
`frontend/` is still shared with Electron or serves the web app: trace consumers
before editing or removing it. New desktop UI, IPC, setup instructions and tests
belong in Electron; validate Electron on macOS, Windows and Linux.
Electron (`electron/`) is the only desktop and web UI. The Tauri shell and legacy
UI entrypoints are removed; do not restore build, runtime, release, or CI paths for
them. Triage reports from final Tauri installations toward the migration guide and
preserve their immutable updater feeds. Some modules under `frontend/src/` remain
temporarily shared by Electron; they are not a runnable app. New UI, IPC, setup
instructions, tests, and browser assets belong in Electron; validate Electron on
macOS, Windows and Linux.
<!-- GSD:project-start source:PROJECT.md -->
## Project
**VoiceStudio**
VoiceStudio is an open-source, fully-local ElevenLabs alternative — a desktop app for voice cloning, voice design, video dubbing, and real-time dictation across 646 languages. It runs entirely on the user's machine (CUDA/MPS/ROCm/CPU auto-detect), with no API keys, no accounts, and no cloud dependencies. It's an active beta with a growing user base who hit it with real workloads (50-video batches, multi-engine setups, edge-OS platforms) and report friction in GitHub Issues and Discord. The current version lives in `frontend/package.json` (the single source of truth — see Versioning); the latest stable tag is on the [Releases page](https://github.com/debpalash/VoiceStudio/releases/latest). With `AUTO_VERSION_BUMP` off (the current owner setting), `main` holds at the released version between releases.
VoiceStudio is an open-source, fully-local ElevenLabs alternative — a desktop app for voice cloning, voice design, video dubbing, and real-time dictation across 646 languages. It runs entirely on the user's machine (CUDA/MPS/ROCm/CPU auto-detect), with no API keys, no accounts, and no cloud dependencies. It's an active beta with a growing user base who hit it with real workloads (50-video batches, multi-engine setups, edge-OS platforms) and report friction in GitHub Issues and Discord. The current version lives in the root `package.json` (the single source of truth — see Versioning); the latest stable tag is on the [Releases page](https://github.com/debpalash/VoiceStudio/releases/latest). With `AUTO_VERSION_BUMP` off (the current owner setting), `main` holds at the released version between releases.
**Core Value:** **A first-run that actually works.** A user who downloads the installer (or clones the repo) should reach a working voice-cloning or dubbing output without hitting a wall — and when something does go wrong, the error or docs should tell them exactly what to do.
@@ -41,13 +40,13 @@ The May-2026 stack research that used to live here served five capabilities that
- **Don't adopt Material for MkDocs** for any future docs site (maintenance mode since Nov 2025) — Astro Starlight is the precedent if docs ever outgrow the repo.
- **`hf_transfer` is deprecated** — default `huggingface_hub` (hf-xet) handles downloads.
For anything new: prefer what's already pinned in `pyproject.toml` / `frontend/package.json`, and check `uv tree` for conflicts before adding a dependency.
For anything new: prefer what's already pinned in `pyproject.toml` / `electron/package.json`, and check `uv tree` for conflicts before adding a dependency.
<!-- GSD:stack-end -->
<!-- GSD:conventions-start source:CONVENTIONS.md -->
## Conventions
**Versioning (hard rule, owner-set 2026-06-11; single-source 2026-06-16):** **`frontend/package.json` is the SINGLE SOURCE OF TRUTH for the maintained Electron app version** — Electron's builder reads it directly and Vite injects `__APP_VERSION__` for the UI and bug reports. Two toolchain-required **mirrors** are kept equal to it and bumped in lockstep: `pyproject.toml` and `backend/core/version.py`'s `_FALLBACK_VERSION`. The archived Tauri manifests stay frozen at their final release and are not version mirrors. Guarded by `tests/test_app_version.py`. Version bumps are manual and happen only when the owner asks; until then, `main` may equal the latest released version. Consequences:
**Versioning (hard rule, owner-set 2026-06-11; single-source 2026-09-26):** **The root `package.json` is the SINGLE SOURCE OF TRUTH for the maintained Electron app version** — Electron's builder reads it directly and Vite injects `__APP_VERSION__` for the UI and bug reports. Two toolchain-required **mirrors** are kept equal to it and bumped in lockstep: `pyproject.toml` and `backend/core/version.py`'s `_FALLBACK_VERSION`. Guarded by `tests/test_app_version.py`. Version bumps are manual and happen only when the owner asks; until then, `main` may equal the latest released version. Consequences:
- Every PR and preview build uses the canonical version. Preview artifacts add the workflow run number without changing the checked-in version.
- Releasing = obtain owner approval for the version, bump the canonical version and maintained mirrors together, then tag that exact version from `main` after validation.
- Docker: `ghcr.io/debpalash/omnivoice-studio:latest` = **main** (rolling preview); `:X.Y.Z` + `:X.Y` + `:stable` = tagged releases. `:latest` is the preview channel by design — stable users pin `:stable` or a version tag.
+1 -1
View File
@@ -114,7 +114,7 @@ Add `-- --install` for the networked managed-runtime installation check.
</details>
> **Electron is the only maintained desktop app.** Version 0.5.3 was the final Tauri release. Existing Tauri users must [install Electron separately](docs/electron-migration.md). Root development, build, test, and release commands target Electron; Tauri source is archived and receives no further updates.
> **Electron is the only desktop app and web UI.** Version 0.5.3 was the final Tauri release. Existing Tauri users must [install Electron separately](docs/electron-migration.md). The retired Tauri shell and legacy UI entry points have been removed.
## Documentation
+7 -64
View File
@@ -1,33 +1,12 @@
"""Project repo URL resolver — single source of truth for deeplinks.
Owned by Plan 01-02 (checker B-6 resolution). Read by:
- backend/core/error_docs_map.py — error → docs URL mapping
- (future) backend/services/bug_report.py — prefilled GitHub Issues URL
Resolution order (highest → lowest):
1. `frontend/src-tauri/tauri.conf.json` `plugins.updater.endpoints[0]`
— this points at the desktop app fork (e.g. github.com/debpalash/
VoiceStudio), which is where docs deeplinks should resolve.
2. `pyproject.toml [project.urls].Repository` — fallback to the upstream
model repo URL when the Tauri config is unreadable.
The resolved URL is cached at import time so callers can use the module
constants directly without re-reading files.
"""
"""Canonical project repository URL used by backend documentation links."""
from __future__ import annotations
import json
import logging
import re
import sys
from pathlib import Path
from typing import Optional
logger = logging.getLogger("omnivoice.core.links")
# Walk up from this file to find the repo root (the dir containing
# `pyproject.toml`). This lets the module work whether the backend is
# imported under `--app-dir backend` or installed as a wheel.
_THIS = Path(__file__).resolve()
@@ -35,54 +14,23 @@ def _find_repo_root() -> Path:
for ancestor in (_THIS.parent, *_THIS.parents):
if (ancestor / "pyproject.toml").exists():
return ancestor
# Fallback — two levels up from backend/core/links.py
return _THIS.parent.parent.parent
_REPO_ROOT = _find_repo_root()
_TAURI_CONF = _REPO_ROOT / "frontend" / "src-tauri" / "tauri.conf.json"
_PYPROJECT = _REPO_ROOT / "pyproject.toml"
_GITHUB_REPO_RE = re.compile(r"https?://github\.com/([^/]+)/([^/]+?)(?:/|\.git|$)")
def _from_tauri() -> Optional[str]:
"""Parse the updater endpoint and pull `github.com/<owner>/<repo>` out."""
try:
text = _TAURI_CONF.read_text(encoding="utf-8")
conf = json.loads(text)
except Exception:
logger.debug("links: tauri.conf.json unreadable", exc_info=True)
return None
try:
endpoints = (
conf.get("plugins", {})
.get("updater", {})
.get("endpoints", [])
)
for url in endpoints:
m = _GITHUB_REPO_RE.search(url)
if m:
owner, repo = m.group(1), m.group(2)
return f"https://github.com/{owner}/{repo}"
except Exception:
logger.debug("links: tauri.conf.json updater shape unexpected", exc_info=True)
return None
_PYPROJECT = _find_repo_root() / "pyproject.toml"
def _from_pyproject() -> Optional[str]:
"""Read `[project.urls].Repository` from pyproject.toml via tomllib."""
"""Read `[project.urls].Repository` from pyproject.toml."""
try:
# tomllib is stdlib on 3.11+
if sys.version_info >= (3, 11):
import tomllib
else: # pragma: no cover — repo pins 3.11+
else: # pragma: no cover - repository pins Python 3.11+
import tomli as tomllib # type: ignore[no-redef]
with _PYPROJECT.open("rb") as f:
data = tomllib.load(f)
with _PYPROJECT.open("rb") as file:
data = tomllib.load(file)
repo = data.get("project", {}).get("urls", {}).get("Repository")
if isinstance(repo, str) and repo.startswith("https://github.com/"):
# Strip trailing `.git` / slash if present.
return repo.rstrip("/").removesuffix(".git")
except Exception:
logger.debug("links: pyproject.toml read failed", exc_info=True)
@@ -90,12 +38,7 @@ def _from_pyproject() -> Optional[str]:
def _resolve() -> str:
"""Pick the Tauri config URL first, then fall back to pyproject."""
return (
_from_tauri()
or _from_pyproject()
or "https://github.com/debpalash/VoiceStudio"
)
return _from_pyproject() or "https://github.com/debpalash/VoiceStudio"
PROJECT_REPO_URL: str = _resolve()
+1 -1
View File
@@ -23,7 +23,7 @@ from pathlib import Path
# Last-resort literal. Guarded by
# tests/test_app_version.py::test_all_version_files_in_lockstep and bumped by
# the owner-approved release bump, so it stays equal to
# pyproject.toml and frontend/package.json. Archived Tauri versions are frozen.
# pyproject.toml and the root package.json.
_FALLBACK_VERSION = "0.5.6"
+3 -3
View File
@@ -1841,7 +1841,7 @@ _mimetypes.add_type("audio/wav", ".wav")
_mimetypes.add_type("audio/flac", ".flac")
# ── Health check ────────────────────────────────────────────────────────
# Used by Docker health checks, load balancers, and the Tauri desktop shell.
# Used by Docker health checks, load balancers, and the Electron desktop shell.
# Answers from the moment the socket binds: 503 with the current startup step
# while the deferred phases run (curl -f / the shell's probes treat that as
# not-ready, exactly like the connection-refused it replaces), the full body
@@ -1982,11 +1982,11 @@ if __name__ == "__main__":
# Distinct exit code for "the port was already taken" (#1223), so the
# desktop shell can tell that apart from a crash without parsing an
# OS-translated error string. Kept out of the 0-2 range the interpreter
# itself uses, and mirrored in frontend/src-tauri/src/backend.rs.
# itself uses, and mirrored in electron/src/main/backend.ts.
_EXIT_PORT_IN_USE = 78 # EX_CONFIG, sysexits.h
# Port 3900 picked to dodge common 8000 conflicts (Django/Rails/Jupyter).
# Rust sidecar launcher in lib.rs::BACKEND_PORT must stay in sync.
# Electron's backend supervisor must stay in sync.
#
# SECURITY: default to loopback (127.0.0.1) so the API isn't reachable
# from the LAN out of the box. VoiceStudio ships no authentication; binding
+56 -587
View File
File diff suppressed because it is too large Load Diff
+2 -3
View File
@@ -15,13 +15,12 @@ WORKDIR /app
# Monorepo — bun workspace with lockfile at repo root. Copy manifests first
# so `bun install` caches independently of source edits.
COPY package.json bun.lock ./
COPY frontend/package.json ./frontend/
COPY electron/package.json ./electron/
RUN bun install --frozen-lockfile
# Build the maintained Electron renderer as the browser UI. Its web transport
# targets FastAPI's same-origin root; native-only controls stay capability-gated.
# Build the maintained Electron renderer as the browser UI. `frontend/src`
# contains transitional shared modules only; it has no app entry point.
COPY frontend/ ./frontend/
COPY electron/ ./electron/
RUN bun run --cwd electron build:web
+1 -1
View File
@@ -25,7 +25,7 @@ must never receive an Electron installer.
## Versioning
`frontend/package.json` is the single source of truth for the maintained app
The root `package.json` is the single source of truth for the maintained app
version. Electron Builder reads it directly. Keep these active mirrors equal:
- `pyproject.toml`
+6 -7
View File
@@ -9,18 +9,17 @@ spark identifies creation. The product voice is clear, calm, and direct.
|---|---|
| Primary vector mark | `docs/logo.svg` |
| README mark | `docs/logo.png` and `docs/logo-256.png` |
| Browser icon | `frontend/public/favicon.svg` |
| Browser icon | `electron/public/favicon.svg` |
| In-app mark | `frontend/src/components/brand/VoiceStudioMark.jsx` |
| Desktop/platform icons (Tauri and Electron) | `frontend/src-tauri/icons/` |
| Electron sidebar and browser icon | `frontend/public/favicon.svg` via `electron/src/renderer/src/lib/brand.ts` |
| Shared sidebar/launchpad artwork | `frontend/src/assets/signal-field.webp` |
| Desktop/platform icons | `electron/build/icons/` |
| Electron sidebar and browser icon | `electron/public/favicon.svg` via `electron/src/renderer/src/lib/brand.ts` |
| Sidebar/launchpad artwork | `electron/public/signal-field.webp` |
Regenerate every desktop icon from the canonical vector after changing the
mark:
```bash
bun run --cwd frontend tauri icon ../docs/logo.svg
```
Export the canonical vector to the required PNG, ICO, and ICNS sizes under
`electron/build/icons/`, then run the Electron packaging contract.
Do not redraw the waveform per screen. Use `VoiceStudioMark` in React and the
canonical SVG elsewhere so the silhouette stays recognizable at 16–512 px.
+1 -1
View File
@@ -59,7 +59,7 @@ bun run dist # + electron-builder → release/
```
The app version is **not** stored here: `electron.vite.config.ts` and
`electron-builder.config.mjs` read it from `frontend/package.json`, the single
`electron-builder.config.mjs` reads it from the root `package.json`, the single
source of truth.
## Layout

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 37 KiB

Before

Width:  |  Height:  |  Size: 49 KiB

After

Width:  |  Height:  |  Size: 49 KiB

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 1.0 KiB

+17 -12
View File
@@ -1,16 +1,16 @@
// electron-builder configuration.
//
// The installer version is NOT stored in electron/package.json (its version
// field is a placeholder). It is read from frontend/package.json at build time
// field is a placeholder). It is read from the root package.json at build time
// so the Electron shell can never drift from the app version (CLAUDE.md,
// Versioning: frontend/package.json is the single source of truth).
// Versioning: package.json is the single source of truth).
import { existsSync, readFileSync, statSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { packageNativeHelper } from './native-helper-build.mjs';
const here = dirname(fileURLToPath(import.meta.url));
const { version } = JSON.parse(readFileSync(resolve(here, '../frontend/package.json'), 'utf-8'));
const { version } = JSON.parse(readFileSync(resolve(here, '../package.json'), 'utf-8'));
const updateChannel =
process.env.VOICESTUDIO_UPDATE_CHANNEL || `electron-stable-${process.platform}-${process.arch}`;
const updateUrl = updateChannel.startsWith('electron-preview-')
@@ -58,13 +58,13 @@ export default {
toolsets: { appimage: '1.0.3' },
files: ['out/**/*', 'package.json'],
// The Python backend + engine sources ride along as plain resources (same as
// the Tauri bundle): the shell bootstraps a uv venv on first run.
// the installer): the shell bootstraps a uv venv on first run.
extraResources: [
{ from: '../frontend/src-tauri/icons/icon.png', to: 'brand/icon.png' },
{ from: '../frontend/src-tauri/icons/icon.ico', to: 'brand/icon.ico' },
{ from: '../frontend/src-tauri/icons/32x32.png', to: 'brand/32x32.png' },
{ from: 'build/icons/icon.png', to: 'brand/icon.png' },
{ from: 'build/icons/icon.ico', to: 'brand/icon.ico' },
{ from: 'build/icons/32x32.png', to: 'brand/32x32.png' },
{
from: '../frontend/src-tauri/icons/tray-recording.png',
from: 'build/icons/tray-recording.png',
to: 'brand/tray-recording.png',
},
{
@@ -72,6 +72,11 @@ export default {
to: 'backend',
filter: ['**/*', '!**/__pycache__/**', '!**/*.pyc'],
},
{
from: '../frontend/dist',
to: 'frontend/dist',
filter: ['**/*'],
},
{
from: '../omnivoice',
to: 'omnivoice',
@@ -86,7 +91,7 @@ export default {
asar: true,
afterPack: packageNativeHelper,
win: {
icon: '../frontend/src-tauri/icons/icon.ico',
icon: 'build/icons/icon.ico',
target: [{ target: 'nsis', arch: ['x64'] }],
},
nsis: {
@@ -95,7 +100,7 @@ export default {
perMachine: false,
},
mac: {
icon: '../frontend/src-tauri/icons/icon.icns',
icon: 'build/icons/icon.icns',
entitlements: 'build/entitlements.mac.plist',
entitlementsInherit: 'build/entitlements.mac.plist',
// Keep unsigned artifact rehearsals at their existing signing defaults;
@@ -104,7 +109,7 @@ export default {
notarize: notarizeMac,
extendInfo: {
NSMicrophoneUsageDescription: readFileSync(
resolve(here, '../frontend/src-tauri/Info.plist'),
resolve(here, 'build/Info.plist'),
'utf8',
).match(/<key>NSMicrophoneUsageDescription<\/key>\s*<string>([^<]+)<\/string>/)[1],
},
@@ -115,7 +120,7 @@ export default {
linux: {
// Linux targets rewrite ${arch} to x86_64/amd64; feeds use Node's x64.
artifactName: 'VoiceStudio-Electron-${version}-linux-x64.${ext}',
icon: '../frontend/src-tauri/icons/icon.png',
icon: 'build/icons/icon.png',
syncDesktopName: true,
target: ['AppImage', 'deb'],
category: 'Audio',
+5 -5
View File
@@ -4,11 +4,11 @@ import { defineConfig, externalizeDepsPlugin } from 'electron-vite';
import react from '@vitejs/plugin-react';
import tailwindcss from '@tailwindcss/vite';
// The app version is single-sourced from frontend/package.json (CLAUDE.md,
// The app version is single-sourced from the root package.json (CLAUDE.md,
// Versioning). Nothing in electron/ mirrors it: the renderer reads it through
// __APP_VERSION__ and the installer through electron-builder.config.mjs.
const frontendPkg = JSON.parse(
readFileSync(resolve(__dirname, '../frontend/package.json'), 'utf-8'),
readFileSync(resolve(__dirname, '../package.json'), 'utf-8'),
) as { version: string };
const define = {
@@ -49,7 +49,7 @@ export default defineConfig({
},
renderer: {
root: resolve(__dirname, 'src/renderer'),
publicDir: resolve(__dirname, '../frontend/public'),
publicDir: resolve(__dirname, 'public'),
plugins: [
react(),
tailwindcss(),
@@ -59,14 +59,14 @@ export default defineConfig({
server.middlewares.use((req, res, next) => {
if (req.url?.split('?')[0] !== '/early-error-capture.js') return next();
res.setHeader('Content-Type', 'application/javascript');
res.end(readFileSync(resolve(__dirname, 'src/renderer/public/early-error-capture.js')));
res.end(readFileSync(resolve(__dirname, 'public/early-error-capture.js')));
});
},
generateBundle() {
this.emitFile({
type: 'asset',
fileName: 'early-error-capture.js',
source: readFileSync(resolve(__dirname, 'src/renderer/public/early-error-capture.js')),
source: readFileSync(resolve(__dirname, 'public/early-error-capture.js')),
});
},
},
+2 -2
View File
@@ -30,8 +30,8 @@
"smoke:server": "vite --config tests/renderer-smoke.vite.config.mjs",
"smoke:live-routes": "node tests/live-route-health.mjs",
"check": "vp check",
"dist": "bun run locale:check && electron-vite build && node tests/packaging-contract.mjs && electron-builder --config electron-builder.config.mjs && node --input-type=module -e \"if (process.platform === 'linux') await import('./scripts/embed-appimage-update.mjs')\" && node tests/update-package-contract.mjs",
"dist:dir": "bun run locale:check && electron-vite build && node tests/packaging-contract.mjs && electron-builder --config electron-builder.config.mjs --dir",
"dist": "bun run build:web && electron-vite build && node tests/packaging-contract.mjs && electron-builder --config electron-builder.config.mjs && node --input-type=module -e \"if (process.platform === 'linux') await import('./scripts/embed-appimage-update.mjs')\" && node tests/update-package-contract.mjs",
"dist:dir": "bun run build:web && electron-vite build && node tests/packaging-contract.mjs && electron-builder --config electron-builder.config.mjs --dir",
"ui:add": "shadcn add"
},
"dependencies": {

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Before

Width:  |  Height:  |  Size: 4.9 KiB

After

Width:  |  Height:  |  Size: 4.9 KiB

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 22 KiB

+1 -1
View File
@@ -11,7 +11,7 @@ import { inflateRawSync } from 'node:zlib';
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const releaseDir = resolve(root, process.argv[2] || 'release');
const version = JSON.parse(readFileSync(resolve(root, '../frontend/package.json'), 'utf8')).version;
const version = JSON.parse(readFileSync(resolve(root, '../package.json'), 'utf8')).version;
const filename = `VoiceStudio-Electron-${version}-linux-x64.AppImage`;
const image = resolve(releaseDir, filename);
const zsync = `${image}.zsync`;
+1 -1
View File
@@ -1,4 +1,4 @@
/** Injected by electron.vite.config.ts (main + preload) from frontend/package.json. */
/** Injected by electron.vite.config.ts (main + preload) from the root package.json. */
declare const __APP_VERSION__: string;
declare const __PRO_STORE_ID__: string;
declare const __PRO_PRODUCT_ID__: string;
+1 -1
View File
@@ -118,7 +118,7 @@ function beginOrderlyQuit(flushPersistence = true): Promise<void> {
function brandIconPath(file: string): string {
return app.isPackaged
? join(process.resourcesPath, 'brand', file)
: join(backendRoot(), 'frontend', 'src-tauri', 'icons', file);
: join(backendRoot(), 'electron', 'build', 'icons', file);
}
function createWindow(): BrowserWindow {
+1 -1
View File
@@ -41,7 +41,7 @@ export function installNativeCapture(
const trayIcon = (file: string) =>
app.isPackaged
? join(process.resourcesPath, 'brand', file)
: join(backendRoot(), 'frontend', 'src-tauri', 'icons', file);
: join(backendRoot(), 'electron', 'build', 'icons', file);
const settingsPath = join(app.getPath('userData'), 'dictation-shortcut.json');
let saved: unknown;
try {
+1 -1
View File
@@ -266,7 +266,7 @@ function isVoiceStudioCheckout(path: string): boolean {
'CLAUDE.md',
'backend/main.py',
'electron/package.json',
'frontend/package.json',
'package.json',
].every((entry) => existsSync(join(path, entry)));
if (!complete) return false;
try {
+29 -2
View File
@@ -8,6 +8,8 @@ import {
installRuntime,
promoteLegacyRuntimeCaches,
CUDNN8_COMPAT_PIN,
ROCM_TORCH_INDEX,
ROCM_TORCH_PINS,
clearCtranslate2ExecutableStack,
runtimePython,
runtimeReady,
@@ -30,6 +32,7 @@ async function fixture() {
const bundle = join(root, 'bundle');
const project = join(root, 'runtime');
await mkdir(join(bundle, 'backend'), { recursive: true });
await mkdir(join(bundle, 'frontend', 'dist'), { recursive: true });
await mkdir(join(bundle, 'omnivoice'));
for (const file of [
'pyproject.toml',
@@ -37,6 +40,7 @@ async function fixture() {
'README.md',
'LICENSE',
'backend/main.py',
'frontend/dist/index.html',
'omnivoice/__init__.py',
]) {
await writeFile(join(bundle, file), file);
@@ -182,6 +186,29 @@ describe('packaged runtime setup', () => {
);
expect(verify?.[1][1]).toContain('sentencepiece');
});
it('reinstalls the pinned ROCm stack only after an explicit opt-in', async () => {
const { bundle, project } = await fixture();
vi.stubEnv('OMNIVOICE_TORCH_VARIANT', ' ROCm ');
vi.stubEnv('OMNIVOICE_TORCH_INDEX', 'https://mirror.invalid/rocm');
const run = vi.fn(
async (_command: string, _args: string[], _cwd: string, _env?: NodeJS.ProcessEnv) => {
await interpreter(project);
},
);
await installRuntime(bundle, project, 'uv', run, new AbortController().signal);
const reinstall = run.mock.calls.find(([, args]) => args[0] === 'pip');
expect(reinstall?.[1]).toEqual([
'pip',
'install',
'--reinstall',
'--python',
runtimePython(project),
...ROCM_TORCH_PINS,
'--index-url',
'https://mirror.invalid/rocm',
]);
expect(ROCM_TORCH_INDEX).toContain('/rocm');
});
it('does not mark a runtime ready if the native tokenizer crashes during verification', async () => {
const { bundle, project } = await fixture();
const run = vi.fn(async (_command: string, args: string[]) => {
@@ -462,10 +489,10 @@ describe('packaged runtime setup', () => {
});
it('pins the release workflow installer version', async () => {
const release = await readFile(
new URL('../../../.github/workflows/release.yml', import.meta.url),
new URL('../../../.github/workflows/electron-release.yml', import.meta.url),
'utf8',
);
expect(release).toContain(`UV_VERSION: "${UV_VERSION}"`);
expect(release).toContain(`version: '${UV_VERSION}'`);
});
});
+35 -3
View File
@@ -15,12 +15,26 @@ import {
} from 'node:fs/promises';
import { join } from 'node:path';
const SOURCES = ['backend', 'omnivoice', 'pyproject.toml', 'uv.lock', 'README.md', 'LICENSE'];
export const UV_VERSION = '0.12.13'; // Kept in sync with the Tauri tools contract.
const SOURCES = [
'backend',
'frontend',
'omnivoice',
'pyproject.toml',
'uv.lock',
'README.md',
'LICENSE',
];
export const UV_VERSION = '0.12.13';
export const CUDNN8_COMPAT_PIN = 'nvidia-cudnn-cu12==8.9.7.29';
export const ROCM_TORCH_INDEX = 'https://download.pytorch.org/whl/rocm6.4';
export const ROCM_TORCH_PINS = [
'torch==2.8.0',
'torchaudio==2.8.0',
'torchvision==0.23.0',
] as const;
const RUNTIME_SCHEMA = 'electron-runtime-v2-cudnn8';
const CUDNN8_PROBE_PREFIX = 'VOICESTUDIO_CUDNN8_PROBE=';
const REQUIRED_ENV_BYTES = 9 * 1024 ** 3; // Tauri setup.rs: REQUIRED_ENV_BYTES.
const REQUIRED_ENV_BYTES = 9 * 1024 ** 3;
export type RuntimePhase = 'checking' | 'downloading_uv' | 'installing_deps' | 'verifying';
export type RuntimeRegion = 'auto' | 'global' | 'china' | 'russia' | 'restricted';
export type RuntimeRunner = (
@@ -463,6 +477,24 @@ export async function installRuntime(
}
await run(uv, ['sync', '--frozen', '--no-dev', ...pythonArgs, ...repairArgs], project, env);
signal.throwIfAborted();
if (process.env.OMNIVOICE_TORCH_VARIANT?.trim().toLowerCase() === 'rocm') {
await run(
uv,
[
'pip',
'install',
'--reinstall',
'--python',
runtimePython(project),
...ROCM_TORCH_PINS,
'--index-url',
process.env.OMNIVOICE_TORCH_INDEX || ROCM_TORCH_INDEX,
],
project,
env,
);
signal.throwIfAborted();
}
await ensureCudnn8Compat(uv, project, run, env, signal);
signal.throwIfAborted();
phase('verifying');
+1 -1
View File
@@ -320,7 +320,7 @@ export interface VoiceStudioBridge {
stop(token: string): Promise<void>;
};
app: {
/** From frontend/package.json via __APP_VERSION__ / extraMetadata. */
/** From the root package.json via __APP_VERSION__ / extraMetadata. */
version: string;
platform: NodeJS.Platform;
isDev: boolean;
+1 -1
View File
@@ -1,6 +1,6 @@
/// <reference types="vite/client" />
/** Injected by electron.vite.config.ts from frontend/package.json (single source of truth). */
/** Injected by electron.vite.config.ts from the root package.json (single source of truth). */
declare const __APP_VERSION__: string;
/** True only for the browser bundle served by the Python backend. */
@@ -7,7 +7,7 @@ import {
isGoalMet,
formatMoney,
} from '../../../../../../frontend/src/api/donation';
import snapshotUrl from '../../../../../../frontend/public/donation_progress.json?url';
import snapshotUrl from '../../../../../public/donation_progress.json?url';
export function DonationGoal() {
const { t } = useTranslation();
+3 -3
View File
@@ -1,3 +1,3 @@
// Canonical assets shared with the Tauri frontend; Vite bundles them locally.
export { default as brandIcon } from '../../../../../frontend/public/favicon.svg';
export { default as brandArtwork } from '../../../../../frontend/src/assets/signal-field.webp';
// Canonical Electron assets; Vite bundles them locally for desktop and web.
export { default as brandIcon } from '../../../../public/favicon.svg';
export { default as brandArtwork } from '../../../../public/signal-field.webp';
+4 -4
View File
@@ -85,12 +85,12 @@ if (artifactRequested && bundledUvSource) {
}
assert.equal(
config.extraMetadata.version,
JSON.parse(readFileSync(resolve(root, '../frontend/package.json'))).version,
JSON.parse(readFileSync(resolve(root, '../package.json'))).version,
);
const platformIcons = {
win: '../frontend/src-tauri/icons/icon.ico',
mac: '../frontend/src-tauri/icons/icon.icns',
linux: '../frontend/src-tauri/icons/icon.png',
win: 'build/icons/icon.ico',
mac: 'build/icons/icon.icns',
linux: 'build/icons/icon.png',
};
for (const [platform, icon] of Object.entries(platformIcons)) {
assert.equal(config[platform].icon, icon, `${platform} uses the shared VoiceStudio icon`);
@@ -7,7 +7,7 @@ import tailwindcss from '@tailwindcss/vite';
const electronRoot = resolve(import.meta.dirname, '..');
const rendererRoot = resolve(electronRoot, 'src/renderer');
const version = JSON.parse(
readFileSync(resolve(electronRoot, '../frontend/package.json'), 'utf8'),
readFileSync(resolve(electronRoot, '../package.json'), 'utf8'),
).version;
const backendPort = process.env.OMNIVOICE_PORT || '3900';
const uiPort = Number(process.env.VOICESTUDIO_SMOKE_PORT) || 3912;
@@ -23,7 +23,7 @@ export default defineConfig({
server.middlewares.use((request, response, next) => {
if (request.url?.split('?')[0] !== '/aec-worklet.js') return next();
response.setHeader('Content-Type', 'application/javascript');
response.end(readFileSync(resolve(electronRoot, '../frontend/public/aec-worklet.js')));
response.end(readFileSync(resolve(electronRoot, 'public/aec-worklet.js')));
});
},
},
+2 -2
View File
@@ -91,10 +91,10 @@ esac
printf 'git %s\\n' "$*" >> "$TEST_ROOT/commands"
if [ "$1" = clone ]; then
for arg in "$@"; do dest=$arg; done
mkdir -p "$dest/frontend" "$dest/electron/release"
mkdir -p "$dest/electron/release"
mkdir -p "$dest/electron/tests" "$dest/electron/scripts"
touch "$dest/electron/tests/packaging-contract.mjs" "$dest/electron/tests/update-package-contract.mjs" "$dest/electron/scripts/embed-appimage-update.mjs"
printf '{"version":"1.2.4"}' > "$dest/frontend/package.json"
printf '{"version":"1.2.4"}' > "$dest/package.json"
else echo fixture-commit; fi
`,
);
+2 -2
View File
@@ -64,7 +64,7 @@ assert(existsSync(metadataPath), `Update metadata exists: ${basename(metadataPat
const metadata = readFileSync(metadataPath, 'utf8');
const expectedVersion = JSON.parse(
readFileSync(resolve(electronRoot, '../frontend/package.json'), 'utf8'),
readFileSync(resolve(electronRoot, '../package.json'), 'utf8'),
).version;
const version = capture(metadata, /^version:\s*(.+)$/m, 'version');
const fileUrl = capture(metadata, /^\s+- url:\s*(.+)$/m, 'files[0].url');
@@ -74,7 +74,7 @@ const legacyPath = capture(metadata, /^path:\s*(.+)$/m, 'path');
const legacySha = capture(metadata, /^sha512:\s*(.+)$/m, 'sha512');
const releaseDate = capture(metadata, /^releaseDate:\s*(.+)$/m, 'releaseDate');
assert.equal(version, expectedVersion, 'Update version follows frontend/package.json');
assert.equal(version, expectedVersion, 'Update version follows the root package.json');
assert.equal(fileUrl, basename(fileUrl), 'Update artifact URL stays release-relative');
assert.equal(legacyPath, fileUrl, 'Legacy path agrees with files[0].url');
assert.equal(legacySha, fileSha, 'Legacy SHA-512 agrees with files[0].sha512');
+1
View File
@@ -6,6 +6,7 @@
"lib": ["ES2023"],
"module": "esnext",
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"types": ["node", "electron-vite/node"],
"skipLibCheck": true,
"strict": true,
+6 -6
View File
@@ -3,16 +3,16 @@ import { resolve } from 'node:path';
import react from '@vitejs/plugin-react';
import tailwindcss from '@tailwindcss/vite';
import { defineConfig } from 'vite';
import { rewriteDevApiProxyPath } from './src/shared/web-api-routing';
import { rewriteDevApiProxyPath } from './src/shared/web-api-routing.ts';
const root = resolve(import.meta.dirname, 'src/renderer');
const frontend = resolve(import.meta.dirname, '../frontend');
const version = JSON.parse(readFileSync(resolve(frontend, 'package.json'), 'utf8')).version;
const webDist = resolve(import.meta.dirname, '../frontend/dist');
const version = JSON.parse(readFileSync(resolve(import.meta.dirname, '../package.json'), 'utf8')).version;
const backendPort = process.env.OMNIVOICE_PORT || '3900';
export default defineConfig({
root,
publicDir: resolve(frontend, 'public'),
publicDir: resolve(import.meta.dirname, 'public'),
plugins: [
react(),
tailwindcss(),
@@ -22,7 +22,7 @@ export default defineConfig({
this.emitFile({
type: 'asset',
fileName: 'early-error-capture.js',
source: readFileSync(resolve(root, 'public/early-error-capture.js')),
source: readFileSync(resolve(import.meta.dirname, 'public/early-error-capture.js')),
});
},
},
@@ -60,7 +60,7 @@ export default defineConfig({
},
},
build: {
outDir: resolve(frontend, 'dist'),
outDir: webDist,
emptyOutDir: true,
rollupOptions: { input: resolve(root, 'index.html') },
},
-13
View File
@@ -1,13 +0,0 @@
{
"$schema": "./node_modules/oxfmt/configuration_schema.json",
"singleQuote": true,
"jsxSingleQuote": false,
"ignorePatterns": [
"**/*.css",
"**/*.json",
"**/*.toml",
"**/*.html",
"**/*.md",
"src-tauri/**"
]
}
-38
View File
@@ -1,38 +0,0 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"env": {
"browser": true,
"es2024": true
},
"globals": {
"__APP_VERSION__": "readonly",
"AudioWorklet": "readonly",
"AudioWorkletNode": "readonly",
"AudioWorkletProcessor": "readonly",
"registerProcessor": "readonly"
},
"plugins": ["react", "import", "unicorn"],
"ignorePatterns": ["dist/", "src-tauri/", "node_modules/"],
"categories": {
"correctness": "error"
},
"rules": {
"no-unused-vars": ["error", { "varsIgnorePattern": "^[A-Z_]", "argsIgnorePattern": "^_", "caughtErrors": "none" }],
"no-empty": ["warn", { "allowEmptyCatch": true }],
"no-unused-expressions": ["error", { "allowShortCircuit": true, "allowTernary": true }],
"react/exhaustive-deps": "warn",
"react/rules-of-hooks": "error",
"react/only-export-components": ["warn", { "allowConstantExport": true }],
"max-lines": ["warn", { "max": 500, "skipBlankLines": true, "skipComments": true }]
},
"overrides": [
{
"files": ["vite.config.js", "*.config.js", "eslint.config.js"],
"env": { "node": true }
},
{
"files": ["**/*.test.{js,jsx}", "**/*.spec.{js,jsx}", "src/test/**"],
"env": { "node": true, "vitest": true }
}
]
}
-16
View File
@@ -1,16 +0,0 @@
# React + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and [`typescript-eslint`](https://typescript-eslint.io) in your project.
-21
View File
@@ -1,21 +0,0 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/index.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"aliases": {
"components": "@/components",
"ui": "@/components/ui",
"utils": "@/lib/utils",
"lib": "@/lib",
"hooks": "@/hooks"
}
}
-529
View File
@@ -1,529 +0,0 @@
import { expect, test, type Page, type TestInfo } from '@playwright/test';
import { writeFile } from 'node:fs/promises';
const APP_STORE_KEY = 'omnivoice.app';
const OMNI_UI_KEY = 'omni_ui';
const TARGET_KEYS = [APP_STORE_KEY, OMNI_UI_KEY] as const;
const UPDATE_COUNT = 20;
const UPDATE_INTERVAL_MS = 25;
const TRAILING_FLUSH_SETTLE_MS = 1_250;
type TargetKey = (typeof TARGET_KEYS)[number];
interface PhysicalWrite {
phase: string;
key: TargetKey;
atMs: number;
bytes: number;
durationMs: number;
}
interface LongTaskSample {
phase: string;
atMs: number;
durationMs: number;
}
interface InputFrameSample {
phase: string;
target: 'ui-scale' | 'studio-text';
atMs: number;
durationMs: number;
}
interface BrowserMetrics {
phase: string;
writes: PhysicalWrite[];
longTasks: LongTaskSample[];
inputToNextRaf: InputFrameSample[];
}
declare global {
interface Window {
__OV_WINDOW__?: string;
__OMNIVOICE_API_BASE__?: string;
__ovResponsivenessMetrics?: BrowserMetrics;
__ovSetResponsivenessPhase?: (phase: string) => void;
}
}
function makeStoryTracks() {
return Array.from({ length: 400 }, (_, index) => ({
id: index + 1,
character: index % 2 === 0 ? 'narrator' : 'guest',
text: `Story track ${index.toString().padStart(3, '0')} ${'narration '.repeat(8)}`,
profileId: null,
emotion: index % 3 === 0 ? 'warm' : null,
speed: 1,
}));
}
function makeDubSegments() {
return Array.from({ length: 1_800 }, (_, index) => ({
id: `segment-${index.toString().padStart(4, '0')}`,
start: index * 2.5,
end: index * 2.5 + 2.25,
speaker: index % 2 === 0 ? 'SPEAKER_00' : 'SPEAKER_01',
text_original: `Original line ${index} ${'source '.repeat(7)}`,
text: `Translated line ${index} ${'target '.repeat(7)}`,
profile_id: null,
direction: '',
}));
}
function persistedFixtures() {
return {
app: {
state: {
mode: 'settings',
defineMethod: 'audio',
uiScale: 1,
uiScaleConfigured: true,
navStyle: 'rail',
locale: 'en',
localeChosen: true,
langPromptSeen: true,
storyTracks: makeStoryTracks(),
},
version: 7,
},
omniUi: {
uiScale: 1,
text: 'Seeded studio text',
mode: 'settings',
defineMethod: 'audio',
vdStates: {
Gender: 'Auto',
Age: 'Auto',
Pitch: 'Auto',
Style: 'Auto',
EnglishAccent: 'Auto',
ChineseDialect: 'Auto',
},
language: 'Auto',
isSidebarCollapsed: false,
sidebarTab: 'projects',
dubJobId: 'responsiveness-fixture',
dubFilename: 'responsiveness-fixture.mp4',
dubDuration: 4_500,
dubSegments: makeDubSegments(),
dubLang: 'English',
dubLangCode: 'en',
dubTracks: [],
dubStep: 'editing',
dubTranscript: '',
exportTracks: {},
preserveBg: true,
defaultTrack: 'dialogue',
exportHistory: [],
speed: 1,
steps: 16,
cfg: 2,
denoise: true,
showOverrides: false,
},
};
}
async function installDeterministicBrowserState(page: Page): Promise<Set<string>> {
const fixtures = persistedFixtures();
const unexpectedRequests = new Set<string>();
await page.addInitScript(
({ appKey, omniUiKey, app, omniUi }) => {
// Fix window identity and API routing before any application module runs.
window.__OV_WINDOW__ = 'main';
window.__OMNIVOICE_API_BASE__ = window.location.origin;
// Seed through the native method so fixture setup is not counted as an
// application write. Both payloads intentionally match production schema.
const nativeSetItem = Storage.prototype.setItem;
nativeSetItem.call(localStorage, appKey, JSON.stringify(app));
nativeSetItem.call(localStorage, omniUiKey, JSON.stringify(omniUi));
nativeSetItem.call(localStorage, 'omnivoice.settings.category', 'appearance');
const targetKeys = new Set([appKey, omniUiKey]);
const metrics: BrowserMetrics = {
phase: 'startup',
writes: [],
longTasks: [],
inputToNextRaf: [],
};
window.__ovResponsivenessMetrics = metrics;
window.__ovSetResponsivenessPhase = (phase) => {
metrics.phase = phase;
};
Storage.prototype.setItem = function setItem(key: string, value: string): void {
const startedAt = performance.now();
try {
nativeSetItem.call(this, key, value);
} finally {
if (targetKeys.has(key)) {
const durationMs = performance.now() - startedAt;
metrics.writes.push({
phase: metrics.phase,
key: key as TargetKey,
atMs: startedAt,
// Encode after the native call so byte accounting is excluded
// from the measured physical-storage duration.
bytes: new TextEncoder().encode(value).byteLength,
durationMs,
});
}
}
};
document.addEventListener(
'input',
(event) => {
const target = event.target;
if (!(target instanceof HTMLElement)) return;
const sampleTarget = target.matches('.appearance-panel input[type="range"]')
? 'ui-scale'
: target.matches('textarea.studio-script-input')
? 'studio-text'
: null;
if (!sampleTarget) return;
const startedAt = performance.now();
requestAnimationFrame(() => {
metrics.inputToNextRaf.push({
phase: metrics.phase,
target: sampleTarget,
atMs: startedAt,
durationMs: performance.now() - startedAt,
});
});
},
true,
);
if (
'PerformanceObserver' in window &&
PerformanceObserver.supportedEntryTypes?.includes('longtask')
) {
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
metrics.longTasks.push({
phase: metrics.phase,
atMs: entry.startTime,
durationMs: entry.duration,
});
}
});
observer.observe({ type: 'longtask', buffered: true });
}
// Keep the realtime hook deterministic and fully local while preserving
// the handler and EventTarget surfaces used by capture/realtime clients.
class DeterministicWebSocket extends EventTarget {
static readonly CONNECTING = 0;
static readonly OPEN = 1;
static readonly CLOSING = 2;
static readonly CLOSED = 3;
readonly url: string;
readyState = DeterministicWebSocket.CONNECTING;
onopen: ((event: Event) => void) | null = null;
onmessage: ((event: MessageEvent) => void) | null = null;
onerror: ((event: Event) => void) | null = null;
onclose: ((event: CloseEvent) => void) | null = null;
constructor(url: string | URL) {
super();
this.url = String(url);
queueMicrotask(() => {
if (this.readyState !== DeterministicWebSocket.CONNECTING) return;
this.readyState = DeterministicWebSocket.OPEN;
const event = new Event('open');
this.dispatchEvent(event);
this.onopen?.(event);
});
}
send(): void {}
close(): void {
if (this.readyState === DeterministicWebSocket.CLOSED) return;
this.readyState = DeterministicWebSocket.CLOSED;
const event = new CloseEvent('close', { code: 1000, wasClean: true });
this.dispatchEvent(event);
this.onclose?.(event);
}
}
Object.defineProperty(window, 'WebSocket', {
configurable: true,
writable: true,
value: DeterministicWebSocket,
});
},
{
appKey: APP_STORE_KEY,
omniUiKey: OMNI_UI_KEY,
app: fixtures.app,
omniUi: fixtures.omniUi,
},
);
// Production resolves API calls to the preview origin. Fulfil every
// fetch/XHR deterministically, while allowing HTML, chunks, fonts and CSS to
// come from the real production bundle under test.
await page.route('**/*', async (route) => {
const request = route.request();
if (!['fetch', 'xhr'].includes(request.resourceType())) {
await route.continue();
return;
}
const path = new URL(request.url()).pathname;
const responseByPath: Record<string, unknown> = {
'/health': { status: 'ok' },
'/setup/status': {
models_ready: true,
missing: [],
hf_cache_dir: '/deterministic/models',
disk_free_gb: 100,
min_free_gb: 1,
enough_disk: true,
},
'/model/status': { status: 'idle', sub_stage: null, detail: '', error: null, progress: null },
'/profiles': [],
'/personalities': [],
'/history': [],
'/dub/history': [],
'/projects': [],
'/export/history': [],
'/engines': {
tts: { active: null, backends: [] },
asr: { active: null, backends: [] },
llm: { active: null, backends: [] },
},
'/sysinfo': { cpu: 0, ram: 0, total_ram: 32, vram: 0, gpu_active: false },
'/system/info': { platform: 'benchmark', device: 'deterministic' },
'/system/notifications': { notifications: [] },
'/system/last-run-crash': { record: null, acknowledged: false },
'/system/logs': { path: '', exists: false, lines: [] },
'/system/logs/tauri': { path: '', exists: false, lines: [] },
'/system/network/state': { enabled: false },
'/dictation/prefs': {
enabled: false,
mode: 'toggle',
model_id: 'sherpa-parakeet-tdt-v3',
},
'/workers': { enabled: false, running: false, workers: [] },
'/workers/target': {
target: 'local',
active: { remote: false },
targets: [
{ id: 'local', label: 'Local', is_local: true, status: 'ready', available: true },
],
},
'/api/settings/analytics': { available: false, prompted: true, opted_in: false },
'/donation_progress.json': {
raised: 10,
goal: 200,
currency: 'USD',
sponsorCount: 1,
updated: '2026-06-17',
},
};
const responseBody = responseByPath[path];
if (responseBody === undefined) {
unexpectedRequests.add(`${request.method()} ${path}`);
await route.fulfill({
status: 501,
contentType: 'application/json',
headers: { 'x-omnivoice-backend': '1' },
body: JSON.stringify({ detail: 'Unhandled deterministic benchmark route' }),
});
return;
}
await route.fulfill({
status: 200,
contentType: 'application/json',
headers: { 'x-omnivoice-backend': '1' },
body: JSON.stringify(responseBody),
});
});
return unexpectedRequests;
}
async function setPhase(page: Page, phase: string): Promise<void> {
await page.evaluate((nextPhase) => window.__ovSetResponsivenessPhase?.(nextPhase), phase);
}
async function driveNativeInputBurst(
page: Page,
selector: string,
values: string[],
): Promise<void> {
await page.locator(selector).evaluate(
async (node, burst) => {
const element = node as HTMLInputElement | HTMLTextAreaElement;
const prototype =
element instanceof HTMLTextAreaElement
? HTMLTextAreaElement.prototype
: HTMLInputElement.prototype;
const nativeValueSetter = Object.getOwnPropertyDescriptor(prototype, 'value')?.set;
if (!nativeValueSetter) throw new Error(`No native value setter for ${element.tagName}`);
await new Promise<void>((resolve) => {
// Schedule against one common origin. Measuring UI work must not add
// another 25 ms after every handler and accidentally turn a 475 ms
// burst into a >1 s stream that rightfully crosses the max-flush gate.
burst.values.forEach((value, index) => {
setTimeout(() => {
nativeValueSetter.call(element, value);
element.dispatchEvent(new Event('input', { bubbles: true, composed: true }));
if (index === burst.values.length - 1) resolve();
}, index * burst.intervalMs);
});
});
},
{ values, intervalMs: UPDATE_INTERVAL_MS },
);
}
async function readDurableValues(page: Page) {
return page.evaluate(
({ appKey, omniUiKey }) => ({
app: JSON.parse(localStorage.getItem(appKey) || 'null'),
omniUi: JSON.parse(localStorage.getItem(omniUiKey) || 'null'),
}),
{ appKey: APP_STORE_KEY, omniUiKey: OMNI_UI_KEY },
);
}
function writesFor(metrics: BrowserMetrics, phase: string, key: TargetKey): PhysicalWrite[] {
return metrics.writes.filter((write) => write.phase === phase && write.key === key);
}
async function writeReport(testInfo: TestInfo, report: unknown): Promise<void> {
const artifactPath = testInfo.outputPath('responsiveness.json');
await writeFile(artifactPath, `${JSON.stringify(report, null, 2)}\n`, 'utf8');
await testInfo.attach('responsiveness.json', {
path: artifactPath,
contentType: 'application/json',
});
}
test('coalesces large-state persistence during rapid UI input', async ({ page }, testInfo) => {
const unexpectedRequests = await installDeterministicBrowserState(page);
await page.goto('/', { waitUntil: 'domcontentloaded' });
const listenerProbe = await page.evaluate(async () => {
const socket = new WebSocket('ws://benchmark.invalid');
let onceCalls = 0;
let removedCalls = 0;
const removedListener = () => {
removedCalls += 1;
};
socket.addEventListener(
'open',
() => {
onceCalls += 1;
},
{ once: true },
);
socket.addEventListener('open', removedListener);
socket.removeEventListener('open', removedListener);
await Promise.resolve();
socket.dispatchEvent(new Event('open'));
socket.close();
return { onceCalls, removedCalls };
});
expect(listenerProbe).toEqual({ onceCalls: 1, removedCalls: 0 });
const scaleSelector = '.appearance-panel input[type="range"]';
await expect(page.locator(scaleSelector)).toBeVisible();
// Let startup restoration and its trailing persistence window fully settle;
// subsequent records are phase-labelled and attributable to one burst.
await page.waitForTimeout(TRAILING_FLUSH_SETTLE_MS);
const scaleValues = Array.from({ length: UPDATE_COUNT }, (_, index) =>
(0.65 + index * 0.05).toFixed(2),
);
const finalScale = Number(scaleValues.at(-1));
await setPhase(page, 'ui-scale');
await driveNativeInputBurst(page, scaleSelector, scaleValues);
await page.waitForTimeout(TRAILING_FLUSH_SETTLE_MS);
const afterScale = await readDurableValues(page);
expect(afterScale.app?.state?.uiScale).toBe(finalScale);
expect(afterScale.omniUi?.uiScale).toBe(finalScale);
// Navigate through the real production UI. Waiting before phase assignment
// prevents the navigation write from being counted as a text-input write.
await setPhase(page, 'navigation');
await page.locator('.nav-rail button[aria-label="Voice"]').click();
const textSelector = 'textarea.studio-script-input';
await expect(page.locator(textSelector)).toBeVisible();
await page.waitForTimeout(TRAILING_FLUSH_SETTLE_MS);
const textValues = Array.from(
{ length: UPDATE_COUNT },
(_, index) => `responsiveness-${index.toString().padStart(2, '0')}-${'voice '.repeat(8)}`,
);
const finalText = textValues.at(-1);
await setPhase(page, 'studio-text');
await driveNativeInputBurst(page, textSelector, textValues);
await page.waitForTimeout(TRAILING_FLUSH_SETTLE_MS);
const durable = await readDurableValues(page);
const metrics = await page.evaluate(() => window.__ovResponsivenessMetrics as BrowserMetrics);
const report = {
schemaVersion: 1,
fixture: { appStoreVersion: 7, storyTracks: 400, dubSegments: 1_800 },
burst: { updates: UPDATE_COUNT, requestedIntervalMs: UPDATE_INTERVAL_MS },
durable: {
appUiScale: durable.app?.state?.uiScale,
omniUiScale: durable.omniUi?.uiScale,
omniUiText: durable.omniUi?.text,
},
phases: {
uiScale: {
writes: Object.fromEntries(
TARGET_KEYS.map((key) => [key, writesFor(metrics, 'ui-scale', key)]),
),
inputToNextRaf: metrics.inputToNextRaf.filter((sample) => sample.phase === 'ui-scale'),
longTasks: metrics.longTasks.filter((sample) => sample.phase === 'ui-scale'),
},
studioText: {
writes: Object.fromEntries(
TARGET_KEYS.map((key) => [key, writesFor(metrics, 'studio-text', key)]),
),
inputToNextRaf: metrics.inputToNextRaf.filter((sample) => sample.phase === 'studio-text'),
longTasks: metrics.longTasks.filter((sample) => sample.phase === 'studio-text'),
},
},
startup: {
writes: metrics.writes.filter((write) => write.phase === 'startup'),
longTasks: metrics.longTasks.filter((sample) => sample.phase === 'startup'),
},
network: { unexpectedRequests: [...unexpectedRequests].sort() },
};
await writeReport(testInfo, report);
expect(durable.omniUi?.text).toBe(finalText);
expect([...unexpectedRequests].sort(), 'every fetch/XHR must have an explicit fixture').toEqual(
[],
);
expect(metrics.inputToNextRaf.filter((sample) => sample.phase === 'ui-scale')).toHaveLength(
UPDATE_COUNT,
);
expect(metrics.inputToNextRaf.filter((sample) => sample.phase === 'studio-text')).toHaveLength(
UPDATE_COUNT,
);
for (const phase of ['ui-scale', 'studio-text']) {
for (const key of TARGET_KEYS) {
expect(
writesFor(metrics, phase, key).length,
`${phase} should physically write ${key} no more than once`,
).toBeLessThanOrEqual(1);
}
}
});
@@ -1,130 +0,0 @@
import { expect, test, type Page, type Request, type Route } from '@playwright/test';
const MASTER = 'root-master-never-retained';
const SESSION = `ovs_admin_session_${'S'.repeat(43)}`;
async function browserCredentialSnapshot(page: Page) {
return page.evaluate(() => ({
href: location.href,
legacyMaster: localStorage.getItem('ov_api_key'),
storedSession: sessionStorage.getItem('ov_admin_session'),
localValues: Object.values(localStorage),
sessionValues: Object.values(sessionStorage),
}));
}
test('same-origin production bootstrap exchanges once into an HttpOnly cookie', async ({
context,
page,
}) => {
const seen: Request[] = [];
page.on('request', (request) => seen.push(request));
await page.addInitScript((master) => localStorage.setItem('ov_api_key', master), MASTER);
let exchange: Request | undefined;
await page.route('**/api/auth/session', async (route) => {
exchange = route.request();
await route.fulfill({
status: 204,
headers: {
'cache-control': 'no-store',
'set-cookie': `ov_session=${SESSION}; HttpOnly; SameSite=Strict; Path=/; Max-Age=28800`,
},
});
});
await page.goto(`/#api_key=${MASTER}&tab=voices`, { waitUntil: 'domcontentloaded' });
await expect.poll(() => exchange?.headers().authorization).toBe(`Bearer ${MASTER}`);
expect(exchange?.postDataJSON()).toEqual({ transport: 'cookie' });
const snapshot = await browserCredentialSnapshot(page);
expect(snapshot.href).toMatch(/#tab=voices$/);
expect(snapshot.href).not.toContain(MASTER);
expect(snapshot.legacyMaster).toBeNull();
expect(snapshot.storedSession).toBeNull();
expect([...snapshot.localValues, ...snapshot.sessionValues].join('\n')).not.toContain(MASTER);
expect(seen.map((request) => request.url()).join('\n')).not.toContain(MASTER);
const cookies = await context.cookies();
const cookie = cookies.find(({ name }) => name === 'ov_session');
expect(cookie).toMatchObject({ value: SESSION, httpOnly: true, sameSite: 'Strict', path: '/' });
expect(cookies.some(({ name }) => name === 'ov_key')).toBe(false);
expect(cookies.map(({ value }) => value).join('\n')).not.toContain(MASTER);
});
test('cross-origin production bootstrap stores only a backend-bound tab session', async ({
page,
}) => {
const remote = 'http://gpu.test:3900';
const seen: Request[] = [];
page.on('request', (request) => seen.push(request));
await page.addInitScript(
({ backend, master }) => {
localStorage.setItem('ov_backend_url', backend);
localStorage.setItem('ov_api_key', master);
},
{ backend: remote, master: MASTER },
);
let exchange: Request | undefined;
await page.route(`${remote}/**`, async (route: Route) => {
const request = route.request();
const corsHeaders = {
'access-control-allow-credentials': 'true',
'access-control-allow-headers': 'authorization,content-type,x-voicestudio-csrf',
'access-control-allow-methods': 'GET,POST,DELETE,OPTIONS',
'access-control-allow-origin': request.headers().origin ?? 'http://localhost:4173',
'access-control-expose-headers': 'x-omnivoice-backend',
'x-omnivoice-backend': 'e2e',
};
if (request.method() === 'OPTIONS') {
await route.fulfill({ status: 204, headers: corsHeaders });
return;
}
if (new URL(request.url()).pathname === '/api/auth/session') {
exchange = request;
await route.fulfill({
status: 201,
headers: {
...corsHeaders,
'cache-control': 'no-store',
'content-type': 'application/json',
},
body: JSON.stringify({ token: SESSION, expires_at: 1, expires_in: 3600 }),
});
return;
}
if (new URL(request.url()).pathname === '/health') {
await route.fulfill({
status: 200,
headers: { ...corsHeaders, 'content-type': 'application/json' },
body: JSON.stringify({ status: 'ok', version: 'e2e', device: 'cpu' }),
});
return;
}
await route.fulfill({
status: 200,
headers: { ...corsHeaders, 'content-type': 'application/json' },
body: '{}',
});
});
await page.goto(`/#api_key=${MASTER}`, { waitUntil: 'domcontentloaded' });
await expect.poll(() => exchange?.headers().authorization).toBe(`Bearer ${MASTER}`);
expect(exchange?.postDataJSON()).toEqual({ transport: 'bearer' });
const snapshot = await browserCredentialSnapshot(page);
expect(snapshot.href).not.toContain(MASTER);
expect(snapshot.legacyMaster).toBeNull();
expect(snapshot.storedSession).not.toBeNull();
expect(JSON.parse(snapshot.storedSession ?? '{}')).toMatchObject({
token: SESSION,
apiBase: remote,
});
expect(snapshot.localValues.join('\n')).not.toContain(MASTER);
expect(snapshot.sessionValues.join('\n')).not.toContain(MASTER);
expect(seen.map((request) => request.url()).join('\n')).not.toContain(MASTER);
expect(
seen.filter((request) => request.headers().authorization === `Bearer ${MASTER}`),
).toHaveLength(1);
});
@@ -1,78 +0,0 @@
import { test, expect } from '@playwright/test';
/**
* The app must RENDER when built for production.
*
* v0.3.22 shipped a black screen: a minifier temporal-dead-zone reorder threw
* before React mounted (`Cannot access 'logs' before initialization`), so the
* window came up empty. It only ever bit the MINIFIED bundle — the dev server
* and the entire e2e suite run un-minified, so every check passed and it
* shipped (#1178).
*
* These tests run against the real `dist/` output. The core assertion is
* deliberately dumb and structural — "did anything mount?" — because that is
* the one thing a pre-render crash always breaks, whatever its cause.
*/
// Errors that are expected in a bundle served without a backend / Tauri host.
// Keep this list TIGHT: every entry is a hole in the gate.
const IGNORABLE = [
/Failed to load resource/i, // no backend on :3900 in this harness
/net::ERR_/i, // ditto — network fetches to the API
/ERR_CONNECTION_REFUSED/i,
/__TAURI__/i, // not running inside the Tauri shell
/favicon/i,
// The app opens ws://…/ws/events at startup. With no backend, the static
// preview server answers every path with the SPA's 200, so the WebSocket
// upgrade is refused ("Unexpected response code: 200"). That's the absent
// backend, not a render crash — the same class as the ERR_ entries above.
/WebSocket connection to .* failed/i,
/\/ws\//i,
];
function isIgnorable(text: string): boolean {
return IGNORABLE.some((re) => re.test(text));
}
test('production bundle mounts the app (no blank screen)', async ({ page }) => {
const fatal: string[] = [];
// A pre-render crash surfaces as a pageerror, not a console.error.
page.on('pageerror', (err) => fatal.push(`pageerror: ${err.message}`));
page.on('console', (msg) => {
if (msg.type() === 'error' && !isIgnorable(msg.text())) {
fatal.push(`console.error: ${msg.text()}`);
}
});
await page.goto('/', { waitUntil: 'domcontentloaded' });
// The actual anti-blank assertion: something must mount into #root.
const root = page.locator('#root');
await expect(root).toHaveCount(1);
await expect
.poll(() => root.evaluate((el) => el.childElementCount), {
message:
'production bundle rendered NOTHING into #root — this is the black-screen ' +
'failure mode (#1178). Check for a crash before React mounts.',
timeout: 20_000,
})
.toBeGreaterThan(0);
// And it must not have thrown on the way there. A TDZ reorder throws a
// ReferenceError, which is precisely what this catches.
expect(fatal, `production bundle raised fatal errors:\n${fatal.join('\n')}`).toEqual([]);
});
test('production bundle renders visible content, not an empty shell', async ({ page }) => {
// Guards the degenerate pass where #root gets a child that paints nothing.
await page.goto('/', { waitUntil: 'domcontentloaded' });
await expect
.poll(
() => page.locator('#root').evaluate((el) => (el as HTMLElement).innerText.trim().length),
{
message: 'production bundle mounted an element tree with no visible text',
timeout: 20_000,
},
)
.toBeGreaterThan(0);
});
-59
View File
@@ -1,59 +0,0 @@
import type { Page } from '@playwright/test';
/** Every routable view (the `mode` values in App.jsx). */
export const MODES = [
'launchpad',
'clone',
'design',
'gallery',
'dub',
'stories',
'projects',
'queue',
'tools',
'transcriptions',
'settings',
'donate',
] as const;
/**
* Fatal client errors that mean a view failed to LOAD — code-split chunk /
* dynamic-import failures (the "Use design → Importing a module script failed"
* regression) and uncaught exceptions. Deliberately NOT matching network/API
* noise (5xx, fetch failures) — backend health is covered elsewhere, and a
* flaky API shouldn't fail a UI-mount test.
*/
const FATAL = [
/Importing a module script failed/i,
/Failed to fetch dynamically imported module/i,
/error loading dynamically imported module/i,
/ChunkLoadError/i,
];
export type ErrorSink = { fatal: string[]; all: string[] };
/** Attach console/pageerror listeners; returns a sink you assert on later. */
export function collectErrors(page: Page): ErrorSink {
const sink: ErrorSink = { fatal: [], all: [] };
const record = (text: string) => {
sink.all.push(text);
if (FATAL.some((re) => re.test(text))) sink.fatal.push(text);
};
page.on('pageerror', (err) => record(`pageerror: ${err.message}`));
page.on('console', (msg) => {
if (msg.type() === 'error') record(`console.error: ${msg.text()}`);
});
return sink;
}
/**
* Land directly on a view by seeding the zustand-persist store (key
* `omnivoice.app`) before the app boots. A shallow merge over slice defaults,
* so only `mode` is forced.
*/
export async function gotoMode(page: Page, mode: string): Promise<void> {
await page.addInitScript((m) => {
localStorage.setItem('omnivoice.app', JSON.stringify({ state: { mode: m }, version: 4 }));
}, mode);
await page.goto('/');
}
-63
View File
@@ -1,63 +0,0 @@
import { test, expect } from '@playwright/test';
import { gotoMode } from './_helpers';
/**
* Footer-clipping guard — "buttons hidden under the footer on small windows"
* (owner report 2026-07-02; same class as #476/#504).
*
* The LogsFooter is a grid row of .app-container (see index.css), so page
* content must physically end at the footer's top edge — no card, button, or
* action bar may render underneath it. Verified at the app's minimum window
* size (tauri.conf.json minWidth 900 × minHeight 600), where the old fixed
* overlay + padding reservation clipped the bottom card row.
*/
const MIN_WINDOW = { width: 900, height: 600 };
async function footerTop(page): Promise<number> {
const footer = page.locator('.app-container .logs-footer');
await expect(footer).toBeVisible();
const box = await footer.boundingBox();
expect(box).not.toBeNull();
return box!.y;
}
test.describe('LogsFooter never covers page content @ 900x600', () => {
test.use({ viewport: MIN_WINDOW });
test('gallery: bottom-most voice card stays above the collapsed footer', async ({ page }) => {
await gotoMode(page, 'gallery');
const cards = page.getByTestId('gallery-persona-card');
await expect(cards.first()).toBeVisible({ timeout: 20_000 });
const top = await footerTop(page);
// Scroll the last card into view — with the footer in the grid flow the
// scroll container ends at the footer's top, so the card must fit fully
// above it once scrolled.
const last = cards.last();
await last.scrollIntoViewIfNeeded();
const box = await last.boundingBox();
expect(box).not.toBeNull();
expect(box!.y + box!.height).toBeLessThanOrEqual(top + 1); // 1px AA tolerance
});
test('expanded footer still cannot cover content — scroll container shrinks instead', async ({
page,
}) => {
await gotoMode(page, 'gallery');
const cards = page.getByTestId('gallery-persona-card');
await expect(cards.first()).toBeVisible({ timeout: 20_000 });
// Expand the logs panel (chevron toggle in the collapsed bar).
const toggle = page.locator('.logs-footer [title], .logs-footer button').first();
await toggle.click();
await expect(page.locator('.logs-footer--open')).toBeVisible();
const top = await footerTop(page);
const last = cards.last();
await last.scrollIntoViewIfNeeded();
const box = await last.boundingBox();
expect(box).not.toBeNull();
expect(box!.y + box!.height).toBeLessThanOrEqual(top + 1);
});
});
-43
View File
@@ -1,43 +0,0 @@
import { test, expect } from '@playwright/test';
import { collectErrors, gotoMode } from './_helpers';
test.describe('VoiceStudio Gallery', () => {
test('heading is "VoiceStudio Gallery"', async ({ page }) => {
await gotoMode(page, 'gallery');
await expect(page.getByRole('heading', { name: /VoiceStudio Gallery/i })).toBeVisible();
});
test('facet dropdowns use the dark theme, not the OS-default light surface', async ({ page }) => {
await gotoMode(page, 'gallery');
// Scope by testid, not the translated 'Archetypes' label — the accessible
// name follows the app locale and breaks under non-English navigators.
const select = page.getByTestId('archetypes-zone').getByRole('combobox').first();
await expect(select).toBeVisible();
// Regression guard for the undefined-var fallback: the fixed style resolves
// --chrome-hover-bg → rgba(255,255,255,0.04), NOT an opaque UA light surface
// and NOT transparent (rgba(0,0,0,0), the broken undefined-var state).
const bg = await select.evaluate((el) => getComputedStyle(el).backgroundColor);
expect(bg).toBe('rgba(255, 255, 255, 0.04)');
});
test('opening an archetype in the Designer mounts the design view (no chunk-load failure)', async ({
page,
}) => {
const errors = collectErrors(page);
await gotoMode(page, 'gallery');
// Cards load from the backend; wait for the first one.
const designerBtn = page.getByRole('button', { name: /Open in Designer/i }).first();
await expect(designerBtn).toBeVisible({ timeout: 20_000 });
await designerBtn.click();
// The design view (CloneDesignTab — the lazy chunk that failed when Vite
// was down) must mount. Its prompt/personality UI is the tell.
await expect(page.getByText(/personality|prompt|steps/i).first()).toBeVisible({
timeout: 15_000,
});
await expect(page.getByText(/this tab hit a snag/i)).toHaveCount(0);
expect(errors.fatal, errors.fatal.join('\n')).toEqual([]);
});
});
-32
View File
@@ -1,32 +0,0 @@
import { test, expect } from '@playwright/test';
import { gotoMode } from './_helpers';
const MIN_WINDOW = { width: 900, height: 600 };
test.describe('Support page stays on one screen @ 900x600', () => {
test.use({ viewport: MIN_WINDOW });
test('support, commercial licence and contact panels do not overflow', async ({ page }) => {
await gotoMode(page, 'donate');
await expect(page.getByRole('heading', { name: 'Support VoiceStudio' })).toBeVisible({
timeout: 20_000,
});
for (const tabName of ['Support', 'Commercial License', 'Contact']) {
const tab = page.getByRole('tab', { name: tabName });
if ((await tab.getAttribute('aria-selected')) !== 'true') await tab.click({ force: true });
await expect(tab).toHaveAttribute('aria-selected', 'true');
const panel = page.getByRole('tabpanel');
await expect(panel).toBeVisible();
const { clientHeight, scrollHeight } = await panel.evaluate((element) => ({
clientHeight: element.clientHeight,
scrollHeight: element.scrollHeight,
}));
expect(
scrollHeight,
`${tabName} panel should not need vertical scrolling`,
).toBeLessThanOrEqual(clientHeight + 1);
}
});
});
-24
View File
@@ -1,24 +0,0 @@
import { test, expect } from '@playwright/test';
import { MODES, collectErrors, gotoMode } from './_helpers';
// Every view must mount without a code-split/import failure or an uncaught
// exception, and without tripping the ErrorBoundary fallback. This is the
// regression guard for "Use design → Importing a module script failed" (a dead
// Vite/module server) and any lazy() page that fails to load.
for (const mode of MODES) {
test(`view "${mode}" mounts without fatal client errors`, async ({ page }) => {
const errors = collectErrors(page);
await gotoMode(page, mode);
// Give the lazy chunk time to fetch + the Suspense boundary to resolve.
// (No networkidle wait — views with a live WS/SSE log stream, e.g. Settings,
// never reach it.)
await page.waitForTimeout(2000);
// The ErrorBoundary fallback copy ("This tab hit a snag.") must not show.
const snag = page.getByText(/this tab hit a snag/i);
await expect(snag).toHaveCount(0);
expect(errors.fatal, `fatal errors in "${mode}":\n${errors.fatal.join('\n')}`).toEqual([]);
});
}
-33
View File
@@ -1,33 +0,0 @@
// Advisory-only lint for the React Compiler rule family that oxlint does not yet
// implement natively (e.g. react-hooks/set-state-in-effect, immutability,
// preserve-manual-memoization). Run locally via `bun run lint:hooks`.
//
// This is NOT a CI gate. oxlint (`bun run lint`, see .oxlintrc.json) is the gate.
// Everything oxlint already covers (no-unused-vars, exhaustive-deps,
// rules-of-hooks, no-undef, max-lines, react-refresh) is turned OFF here so the
// two tools don't double-report. Drop this file once oxlint's JS-plugin support
// graduates from alpha and can run eslint-plugin-react-hooks directly.
import reactHooks from 'eslint-plugin-react-hooks';
import globals from 'globals';
import { defineConfig, globalIgnores } from 'eslint/config';
export default defineConfig([
globalIgnores(['dist', 'src-tauri']),
{
files: ['**/*.{js,jsx}'],
extends: [reactHooks.configs.flat.recommended],
languageOptions: {
ecmaVersion: 'latest',
globals: globals.browser,
parserOptions: {
ecmaFeatures: { jsx: true },
sourceType: 'module',
},
},
rules: {
// Owned by oxlint — disabled here to avoid duplicate diagnostics.
'react-hooks/exhaustive-deps': 'off',
'react-hooks/rules-of-hooks': 'off',
},
},
]);
-13
View File
@@ -1,13 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>VoiceStudio</title>
</head>
<body style="background:#1d2021">
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
-15
View File
@@ -1,15 +0,0 @@
{
"$schema": "https://unpkg.com/knip@6/schema.json",
"ignore": [
"public/aec-worklet.js"
],
"ignoreUnresolved": [
"/@react-refresh"
],
"ignoreDependencies": [
"tailwindcss",
"@tauri-apps/plugin-updater",
"@tauri-apps/plugin-window-state",
"playwright-core"
]
}
-94
View File
@@ -1,94 +0,0 @@
{
"name": "omnivoice-studio",
"version": "0.5.6",
"private": true,
"license": "AGPL-3.0-only",
"type": "module",
"scripts": {
"dev": "vite",
"desktop": "bun ../scripts/desktop-dev.mjs",
"build": "vite build",
"lint": "oxlint",
"lint:fix": "oxlint --fix",
"lint:hooks": "eslint .",
"format": "oxfmt --write",
"format:check": "oxfmt --check",
"knip": "knip",
"typecheck": "tsc --noEmit",
"typecheck:ci": "tsc --noEmit --checkJs false",
"test": "vitest run",
"test:watch": "vitest",
"test:legacy": "node --experimental-strip-types --no-warnings --test ../tests/frontend/*.test.mjs",
"preview": "vite preview",
"tauri": "tauri",
"e2e": "playwright test",
"test:prod-bundle": "playwright test --config=playwright.prod.config.ts",
"test:visual": "playwright test --config=playwright.visual.config.ts",
"test:visual:update": "playwright test --config=playwright.visual.config.ts --update-snapshots"
},
"dependencies": {
"@fontsource-variable/inter": "^5.3.0",
"@fontsource-variable/source-serif-4": "^5.3.0",
"@fontsource/ibm-plex-mono": "^5.3.0",
"@radix-ui/react-dialog": "^1.1.23",
"@radix-ui/react-dropdown-menu": "^2.1.24",
"@radix-ui/react-progress": "^1.1.16",
"@radix-ui/react-select": "^2.3.7",
"@radix-ui/react-slider": "^1.4.7",
"@radix-ui/react-slot": "^1.3.3",
"@radix-ui/react-tabs": "^1.1.21",
"@radix-ui/react-toggle": "^1.1.18",
"@radix-ui/react-toggle-group": "^1.1.19",
"@radix-ui/react-tooltip": "^1.2.16",
"@scalar/api-reference-react": "^0.9.67",
"@tailwindcss/vite": "^4.3.3",
"@tanstack/react-query": "^5.102.8",
"@tanstack/react-table": "^8.21.3",
"@tanstack/react-virtual": "^3.14.11",
"@tauri-apps/plugin-dialog": "^2.7.3",
"@tauri-apps/plugin-opener": "^2.5.5",
"@tauri-apps/plugin-process": "^2.3.1",
"@tauri-apps/plugin-updater": "^2.11.0",
"@tauri-apps/plugin-window-state": "^2.4.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"country-flag-icons": "^1.6.20",
"html-entities": "^2.6.0",
"i18next": "^26.4.2",
"i18next-browser-languagedetector": "^8.2.1",
"lucide-react": "^1.43.0",
"posthog-js": "^1.428.11",
"qrcode": "^1.5.4",
"react": "^19.3.0",
"react-dom": "^19.3.0",
"react-hot-toast": "^2.6.0",
"react-i18next": "^17.0.13",
"react-window": "^2.3.1",
"tailwind-merge": "^3.6.0",
"tailwindcss": "^4.3.3",
"tw-animate-css": "^1.4.0",
"wavesurfer.js": "^7.12.11",
"zustand": "^5.0.15"
},
"devDependencies": {
"@playwright/test": "1.63.0",
"@tauri-apps/api": "^2.11.1",
"@tauri-apps/cli": "^2.11.4",
"@testing-library/jest-dom": "^7.0.1",
"@testing-library/react": "^16.3.3",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.7",
"@vitejs/plugin-react": "^6.1.1",
"eslint": "^10.10.0",
"eslint-plugin-react-hooks": "^7.1.1",
"globals": "^17.12.0",
"jsdom": "^30.0.1",
"knip": "^6.35.1",
"oxfmt": "^0.57.0",
"oxlint": "1.71.0",
"playwright-core": "1.63.0",
"typescript": "^6.0.3",
"vite": "^8.2.2",
"vitest": "4.1.11"
}
}
-18
View File
@@ -1,18 +0,0 @@
import re
with open("src/App.jsx", "r") as f:
text = f.read()
# simple stack to find unmatched <div> / </div>
lines = text.split("\n")
stack = []
for i, line in enumerate(lines):
# ignore comments for simplicity
if "/*" in line or "//" in line: continue
opens = re.findall(r'<div\b[^>]*>', line)
closes = re.findall(r'</div>', line)
for _ in opens: stack.append(i+1)
for _ in closes:
if stack: stack.pop()
else: print(f"Unmatched </div> at line {i+1}")
print("Unclosed <div> starts at lines:", stack)
-31
View File
@@ -1,31 +0,0 @@
import { defineConfig, devices } from '@playwright/test';
// E2E runs against the Vite dev server (UI on :3901, backend on :3900). It
// drives the SYSTEM chromium (no `playwright install` browser download) — set
// PLAYWRIGHT_CHROMIUM to override the path. reuseExistingServer keeps a dev
// session you already have running; CI starts its own `bun run dev`.
const PORT = Number(process.env.E2E_PORT || 3901);
export default defineConfig({
testDir: './e2e',
timeout: 45_000,
expect: { timeout: 10_000 },
fullyParallel: false,
retries: process.env.CI ? 1 : 0,
reporter: [['list']],
use: {
baseURL: `http://localhost:${PORT}`,
headless: true,
trace: 'retain-on-failure',
launchOptions: {
executablePath: process.env.PLAYWRIGHT_CHROMIUM || '/usr/bin/chromium',
},
},
projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }],
webServer: {
command: 'bun run dev',
url: `http://localhost:${PORT}`,
reuseExistingServer: true,
timeout: 60_000,
},
});
-47
View File
@@ -1,47 +0,0 @@
import { defineConfig, devices } from '@playwright/test';
import { existsSync } from 'node:fs';
// Opt-in production-bundle responsiveness benchmark. Keep it separate from
// playwright.prod.config.ts: the smoke suite is a CI correctness gate, while
// this harness records machine-dependent timing diagnostics for local review.
const PORT = Number(process.env.E2E_PERF_PORT || 4174);
// An explicit browser wins; Linux CI/dev containers commonly provide a system
// Chromium; contributors on Windows/macOS fall back to Playwright's bundle.
const SYSTEM_CHROMIUM = '/usr/bin/chromium';
const browserPath =
process.env.PLAYWRIGHT_CHROMIUM || (existsSync(SYSTEM_CHROMIUM) ? SYSTEM_CHROMIUM : undefined);
export default defineConfig({
testDir: './e2e-perf',
testMatch: 'responsiveness.spec.ts',
timeout: 120_000,
expect: { timeout: 15_000 },
fullyParallel: false,
// `--repeat-each=5` is a variance sample, not five independent load tests.
// Keep repeats serial so they do not contend with each other or distort the
// input/long-task evidence on high-core development machines.
workers: 1,
retries: 0,
reporter: [['list']],
outputDir: 'test-results/responsiveness',
use: {
baseURL: `http://localhost:${PORT}`,
headless: true,
trace: 'retain-on-failure',
...(browserPath ? { launchOptions: { executablePath: browserPath } } : {}),
},
projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }],
webServer: {
// Playwright launches through the platform shell. Invoke the repo-pinned
// Vite binary directly so Windows does not depend on whichever global Bun
// shim happens to precede the checked-in toolchain on PATH.
command: `node ./node_modules/vite/bin/vite.js build && node ./node_modules/vite/bin/vite.js preview --port ${PORT} --strictPort`,
url: `http://localhost:${PORT}`,
// Always own the production preview used for a measurement. Reusing an
// arbitrary listener can benchmark stale dist bytes and leaves teardown
// ownership ambiguous; a stale 4174 listener should fail loudly instead.
reuseExistingServer: false,
timeout: 180_000,
},
});
-52
View File
@@ -1,52 +0,0 @@
import { defineConfig, devices } from '@playwright/test';
import { existsSync } from 'node:fs';
// PRODUCTION-BUNDLE smoke gate. The regular e2e config (playwright.config.ts)
// drives the Vite DEV server, which is un-minified — so a bug that exists ONLY
// in the minified release bundle passes every check and ships. That is exactly
// how v0.3.22 went out with a black screen: a minifier temporal-dead-zone
// reorder threw before React mounted, leaving an empty #root. Dev runs and the
// whole e2e suite never saw it (#1178).
//
// This config closes that hole: it BUILDS the app and serves the real `dist/`
// through `vite preview`, so the bytes under test are the bytes we ship.
// Separate port from the dev server so both can run without colliding.
const PORT = Number(process.env.E2E_PROD_PORT || 4173);
// PLAYWRIGHT_CHROMIUM wins; else a system chromium if it's actually there
// (CI); else undefined, which lets Playwright use its bundled browser.
const SYSTEM_CHROMIUM = '/usr/bin/chromium';
const browserPath =
process.env.PLAYWRIGHT_CHROMIUM || (existsSync(SYSTEM_CHROMIUM) ? SYSTEM_CHROMIUM : undefined);
export default defineConfig({
testDir: './e2e-prod',
timeout: 120_000, // includes the production build
expect: { timeout: 15_000 },
fullyParallel: false,
retries: 0, // a blank screen must never be "flaky-passed" away
reporter: [['list']],
use: {
baseURL: `http://localhost:${PORT}`,
headless: true,
trace: 'retain-on-failure',
// Use an explicit browser when one is named (CI installs a system
// chromium), otherwise fall back to Playwright's own download. The dev
// e2e config hardcodes /usr/bin/chromium, which simply doesn't exist on
// Windows/macOS — this gate has to be runnable on a contributor's laptop
// too, or it won't get run before release.
...(browserPath ? { launchOptions: { executablePath: browserPath } } : {}),
},
projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }],
webServer: {
// Build then serve the real production output. `--strictPort` so a port
// clash fails loudly instead of silently testing some other server.
command: `bun run build && bun x vite preview --port ${PORT} --strictPort`,
url: `http://localhost:${PORT}`,
// CI must always build fresh — the whole point is testing the bytes we
// ship. Locally, reuse a `vite preview` you already have up so iterating
// doesn't pay a full production build every run.
reuseExistingServer: !process.env.CI,
timeout: 180_000,
},
});
-51
View File
@@ -1,51 +0,0 @@
import { defineConfig, devices } from '@playwright/test';
// Visual-regression config — SEPARATE from playwright.config.ts (e2e).
//
// The e2e suite drives the full app against a Python backend; this one renders
// individual presentational components in isolation via the Vite harness
// (src/test/visual/harness.html), so NO backend is required. It runs its own
// Vite dev server on a dedicated port and snapshots leaf components across
// themes. Local/manual only (`bun run test:visual`) — see
// src/test/visual/README.md for why it is not yet a CI gate.
const PORT = Number(process.env.VISUAL_PORT || 3902);
export default defineConfig({
testDir: './src/test/visual',
testMatch: /.*\.visual\.spec\.ts$/,
timeout: 30_000,
fullyParallel: true,
retries: 0,
reporter: [['list']],
// Flat, platform-agnostic baseline filenames (Badge-midnight.png …). These
// are committed; they are correct for the machine that generated them.
snapshotPathTemplate: '{testDir}/__screenshots__/{arg}{ext}',
expect: {
timeout: 10_000,
toHaveScreenshot: {
animations: 'disabled',
caret: 'hide',
// Small tolerance absorbs sub-pixel font anti-aliasing jitter on the
// same OS without masking real layout/color regressions.
maxDiffPixelRatio: 0.01,
},
},
use: {
baseURL: `http://localhost:${PORT}`,
headless: true,
deviceScaleFactor: 1,
// Default to Playwright's managed chromium; set PLAYWRIGHT_CHROMIUM to
// pin a specific binary (e.g. the system chromium used by e2e in CI).
...(process.env.PLAYWRIGHT_CHROMIUM
? { launchOptions: { executablePath: process.env.PLAYWRIGHT_CHROMIUM } }
: {}),
},
projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'], deviceScaleFactor: 1 } }],
webServer: {
command: 'bun run dev',
url: `http://localhost:${PORT}`,
reuseExistingServer: !process.env.CI,
timeout: 60_000,
env: { OMNIVOICE_UI_PORT: String(PORT) },
},
});
-9
View File
@@ -1,9 +0,0 @@
import { existsSync } from 'node:fs';
import path from 'node:path';
export function resolveDialogEsm(frontendRoot) {
return [
path.resolve(frontendRoot, 'node_modules/@tauri-apps/plugin-dialog/dist-js/index.js'),
path.resolve(frontendRoot, '../node_modules/@tauri-apps/plugin-dialog/dist-js/index.js'),
].find(existsSync);
}
-4
View File
@@ -1,4 +0,0 @@
# Generated by Cargo
# will have compiled files and executables
/target/
/gen/schemas
-6901
View File
File diff suppressed because it is too large Load Diff
-124
View File
@@ -1,124 +0,0 @@
[package]
# NOT renamed with the product (VoiceStudio). This is the BINARY name, and
# scripts/desktop-common.mjs keys its dev-vs-release distinction on it: the dev
# launcher's pkill matches `omnivoice-studio` and must never match a user's
# installed app. Renaming it would collapse that distinction.
name = "omnivoice-studio"
version = "0.5.4"
description = "VoiceStudio – AI voice cloning & dubbing desktop app"
authors = ["Debpalash"]
license = "AGPL-3.0-only"
repository = ""
edition = "2021"
rust-version = "1.77.2"
[lib]
name = "app_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
tauri-build = { version = "2.6.0", features = [] }
[dependencies]
serde_json = "1.0"
serde = { version = "1.0", features = ["derive"] }
getrandom = "0.3"
# Capability-scoped fs access for the batch watch folder: scans/reads resolve
# against a directory HANDLE captured at pick time. The selected pathname is
# re-resolved only for liveness/identity checks (#1768).
cap-std = "3"
log = "0.4"
tauri = { version = "2.11.0", features = ["macos-private-api", "protocol-asset", "tray-icon", "image-png"] }
tauri-plugin-log = "2"
tauri-plugin-dialog = "2"
tauri-plugin-window-state = "2.0.0"
tauri-plugin-updater = "2"
tauri-plugin-process = "2"
tauri-plugin-opener = "2"
tauri-plugin-global-shortcut = "2"
tauri-plugin-single-instance = "2"
# Bottom-center placement of the dictation pill/widget window. Replaces the
# hand-rolled primary-monitor geometry + LogicalPosition math. tray-icon
# feature enabled because the app ships a system tray (TrayIconBuilder).
tauri-plugin-positioner = { version = "2", features = ["tray-icon"] }
# Cross-platform keyboard simulation for auto-paste after dictation
enigo = { version = "0.3", features = ["serde"] }
# First-run bootstrap: on first launch the Rust setup hook installs `uv`
# via the official Astral installer script (curl|sh on Unix, irm|iex on
# Windows), creates a Python 3.11 venv, and runs `uv sync` against the
# bundled pyproject.toml — which installs torch, whisperx, etc.
# ureq is used for HTTP health checks and ffmpeg downloads.
ureq = "2"
# reqwest is used for GitHub Releases API calls and bounded-memory native
# watch-folder uploads to the loopback backend.
reqwest = { version = "0.13", features = ["json", "blocking", "multipart"] }
# semver is used by the channel-aware updater (updater_channel.rs) to rank
# builds across the stable/preview channels (#326); same major as the
# `Version` type tauri-plugin-updater re-exports (already in the lockfile).
semver = "1"
# ── Rust IPC commands (cross-platform) ──
# get_sysinfo: CPU + RAM metrics without HTTP round-trip
sysinfo = { version = "0.33", default-features = false, features = ["system"] }
# hf_cache_scan: walk HF cache directory 3-5× faster than Python
walkdir = "2"
# First-run setup screen: per-path free-disk-space probe (statvfs /
# GetDiskFreeSpaceExW) for the minimum-storage install gate
fs4 = "0.13"
# Cross-platform home/config/data directories (config.rs, setup.rs)
dirs-next = "2"
# Native (OS-side) clipboard write for dictation auto-paste: the widget window
# is unfocused on macOS so the simulated ⌘V reaches the target app, which makes
# the WebView clipboard APIs fail silently there (#287)
arboard = { version = "3", features = ["wayland-data-control"] }
[target.'cfg(target_os = "macos")'.dependencies]
dispatch2 = { version = "0.3", default-features = false, features = ["std"] }
# Capture the frontmost application at shortcut-down and reactivate that exact
# process before transcript delivery.
objc2 = "0.6"
objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "libc", "NSRunningApplication", "NSWorkspace"] }
[target.'cfg(windows)'.dependencies]
zip = { version = "2", default-features = false, features = ["deflate"] }
# WebView2 PermissionRequested handler: auto-grant media-capture for the
# app's own origin so getUserMedia() works in the dictation widget (#323).
# Versions match what wry already locks — no new native code is pulled in.
webview2-com = "0.38"
windows-core = "0.61"
# Raw HWND access (GetWindowLongPtrW/SetWindowLongPtrW/ShowWindow) to mark the
# dictation pill WS_EX_NOACTIVATE so showing it doesn't steal Win32 foreground
# activation (#982 — Windows counterpart of #287's macOS focus-steal fix).
# Version pinned to match what `tauri` itself already resolves to (0.61.x) so
# `WebviewWindow::hwnd()`'s return type and our syscalls share the exact same
# `HWND` type — no second copy of the crate enters the dependency graph.
# Win32_System_Registry: check_microphone reads the CapabilityAccessManager
# ConsentStore mic toggle (RegGetValueW) for the permissions UX.
windows = { version = "0.61", features = ["Win32_Foundation", "Win32_Storage_FileSystem", "Win32_UI_WindowsAndMessaging", "Win32_System_Registry", "Win32_System_Threading", "Win32_System_JobObjects", "Win32_System_Diagnostics_ToolHelp", "Win32_Security"] }
[target.'cfg(unix)'.dependencies]
libc = "0.2"
[target.'cfg(target_os = "linux")'.dependencies]
webkit2gtk = "2.0"
gtk = "0.18"
x11rb = "0.13"
# Wayland compositors do not expose global keys through X11 grabs. Use the
# standard xdg-desktop-portal GlobalShortcuts interface there; zbus is already
# present transitively through Tauri's opener/single-instance plugins.
zbus = "5.16"
[dev-dependencies]
# Scoped-reset tests build real directory trees to prove the delete guard only
# ever removes paths inside a validated OmniVoice root.
tempfile = "3"
# MockRuntime app for the backend-lifecycle fault-injection harness
# (tests/backend_lifecycle.rs) — feature-unifies onto the main dep.
tauri = { version = "2.11.0", features = ["test"] }
[[test]]
name = "keyboard_dispatch"
path = "tests/keyboard_dispatch.rs"
harness = false
-323
View File
@@ -1,323 +0,0 @@
#!/usr/bin/env bash
# VoiceStudio — AppImage launcher
#
# Issue #56: AppImage shows a white screen on Fedora 44 / Ubuntu 24.04.
# Root cause: WebKitGTK 2.44.x / 2.46.x has a compositing-path regression on
# Wayland that blanks the surface on first paint. Setting
# WEBKIT_DISABLE_COMPOSITING_MODE=1 forces WebKit to use a software fallback
# that works correctly.
#
# We detect the WebKit version via pkg-config and ONLY set the env var on the
# known-broken ranges. Setting it unconditionally regresses healthy WebKit
# versions (2.48+) where the compositing path works fine.
#
# This file is installed into Tauri's local tool cache by
# scripts/inject-apprun.sh (wired into Tauri's beforeBundleCommand). See
# .planning/decisions/apprun-strategy.md for the decision rationale.
set -euo pipefail
HERE="$(dirname -- "$(readlink -f -- "$0")")"
# Sourced by AppRun.test.sh — keep this function pure so unit tests can stub
# `pkg-config`, source the file, call _detect_webkit_workaround, and inspect
# the resulting environment without exec'ing the binary.
#
# Version source (#961 follow-up): the WebKitGTK that actually RUNS is the
# BUNDLED copy (LD_LIBRARY_PATH below puts $HERE/usr/lib first) — NOT the
# host's. Asking the host's pkg-config therefore reads the wrong number
# whenever host and bundle diverge (e.g. a user who builds from source has
# dev packages installed, so pkg-config answers with their system's healthy
# 2.48 while the bundle runs an older lib — skipping a workaround the running
# library needs). inject-apprun.sh stamps the bundled version into
# usr/lib/.bundled-webkitgtk-version at build time, where it is knowable by
# construction; the host pkg-config path survives only as a fallback for
# bundles predating the stamp. OMNIVOICE_APPRUN_WK_MARKER exists for the
# unit tests to point at a fixture marker.
_detect_webkit_workaround() {
local wk_version="0.0"
local marker="${OMNIVOICE_APPRUN_WK_MARKER:-$HERE/usr/lib/.bundled-webkitgtk-version}"
if [ -r "$marker" ]; then
# Empty/unreadable marker content → "0.0" (unknown) → fail-safe workaround,
# same philosophy as the missing-pkg-config branch below.
wk_version="$(cat "$marker" 2>/dev/null | tr -d '[:space:]')"
[ -n "$wk_version" ] || wk_version="0.0"
elif command -v pkg-config >/dev/null 2>&1; then
wk_version="$(pkg-config --modversion webkit2gtk-4.1 2>/dev/null \
|| pkg-config --modversion webkit2gtk-4.0 2>/dev/null \
|| echo "0.0")"
fi
case "$wk_version" in
2.44.*|2.46.*|0.0)
export WEBKIT_DISABLE_COMPOSITING_MODE=1
;;
esac
}
_detect_webkit_workaround
# ── Bundled-vs-system WebKit priority (#1258, #1244) ───────────────────────
#
# The bundled WebKitGTK links against the HOST's Mesa — the AppImage ships no
# libEGL of its own. That pairing is only tested for the Mesa of the build
# runner, and it breaks outright as hosts move ahead: on Mesa >= 26.1 the
# Ubuntu-built libwebkit2gtk calls eglGetPlatformDisplay() with parameters the
# newer driver rejects, and the app dies before it renders anything:
#
# Could not create default EGL display: EGL_BAD_PARAMETER. Aborting...
# blank window detected (#root children = -2); reload 1/3
#
# No env var helps, because the failure is in EGL display creation — it happens
# 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.
#
# What DOES work on those machines is the host's own WebKitGTK, because the
# distro compiled it against the very Mesa it ships — which is why building
# from source works on the exact hardware where the AppImage does not.
#
# Chasing the build runner's WebKit version (#961 bumped 22.04 → 24.04) cannot
# fix this class: whatever we bundle is frozen, and host Mesa keeps moving. So
# when the host has a WebKitGTK at least as new as ours, let its copy win.
#
# Note it is NOT enough to merely stop prepending the bundle: 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
# would still be the only explicit directory and still win. The host's WebKit
# libdir has to be named explicitly, ahead of ours (#1258 review).
#: Directory holding the host's libwebkit2gtk-4.1.so.0, or empty.
_system_webkit_libdir() {
local dir
# pkg-config is exact, but only present with the -dev package installed.
dir="$(pkg-config --variable=libdir webkit2gtk-4.1 2>/dev/null || echo "")"
if [ -n "$dir" ] && [ -e "$dir/libwebkit2gtk-4.1.so.0" ]; then
printf '%s' "$dir"
return 0
fi
# Runtime-only hosts (an end user who never installed -dev) have the library
# but no .pc file. ldconfig knows where it is (#1258 review).
if command -v ldconfig >/dev/null 2>&1; then
dir="$(ldconfig -p 2>/dev/null \
| awk '/libwebkit2gtk-4\.1\.so\.0 /{print $NF; exit}')"
if [ -n "$dir" ] && [ -e "$dir" ]; then
printf '%s' "$(dirname -- "$dir")"
return 0
fi
fi
return 1
}
#: Host WebKitGTK version, or empty when it can't be established.
_system_webkit_version() {
pkg-config --modversion webkit2gtk-4.1 2>/dev/null || echo ""
}
_prefer_system_webkit() {
# An explicit override for the case we cannot decide automatically: a host
# with the runtime but no pkg-config metadata, where the version is unknowable
# from here. Documented in docs/install/linux.md.
if [ "${OMNIVOICE_PREFER_SYSTEM_WEBKIT:-}" = "1" ]; then
return 0
fi
[ "${OMNIVOICE_PREFER_SYSTEM_WEBKIT:-}" = "0" ] && return 1
# Only meaningful when we know what we bundled; an unstamped bundle keeps
# the old ordering rather than guessing.
local marker="${OMNIVOICE_APPRUN_WK_MARKER:-$HERE/usr/lib/.bundled-webkitgtk-version}"
[ -r "$marker" ] || return 1
local bundled
bundled="$(cat "$marker" 2>/dev/null | tr -d '[:space:]')"
[ -n "$bundled" ] && [ "$bundled" != "0.0" ] || return 1
local system
system="$(_system_webkit_version)"
# Unknown host version → keep the bundle. Preferring an unverified copy could
# hand the user an OLDER WebKit than we ship, which is the #961 regression;
# OMNIVOICE_PREFER_SYSTEM_WEBKIT=1 is the escape hatch for that host.
[ -n "$system" ] || return 1
# `sort -V` puts the older version first; the host wins only on >=.
[ "$(printf '%s\n%s\n' "$bundled" "$system" | sort -V | head -1)" = "$bundled" ]
}
_SYS_WK_LIBDIR=""
if _prefer_system_webkit; then
_SYS_WK_LIBDIR="$(_system_webkit_libdir || echo "")"
fi
if [ -n "$_SYS_WK_LIBDIR" ]; then
# The host's WebKit resolves first; ours fills only what the host lacks.
export LD_LIBRARY_PATH="${_SYS_WK_LIBDIR}:${HERE}/usr/lib:${LD_LIBRARY_PATH:-}"
# The workaround above was chosen for the BUNDLED version; re-decide it
# against the copy that will actually run.
unset WEBKIT_DISABLE_COMPOSITING_MODE
case "$(_system_webkit_version)" in
2.44.*|2.46.*|"") export WEBKIT_DISABLE_COMPOSITING_MODE=1 ;;
esac
else
# Standard AppImage env that Tauri's auto-generated AppRun would have set.
export LD_LIBRARY_PATH="${HERE}/usr/lib:${LD_LIBRARY_PATH:-}"
fi
# ── GStreamer: the bundle has a core but no plugins (#1333) ────────────────
# Microphone capture died on Debian 13 with "No microphone found", while the
# same build's raw binary recorded fine. GST_DEBUG=2 named the cause:
#
# WARN GST_REGISTRY gst_registry_binary_check_magic:
# Binary registry magic version is different : 1.23.90 != 1.3.0
# GStreamer element appsink not found. Please install it.
#
# linuxdeploy bundles libgstreamer-1.0 because WebKit links it, but NOT the
# plugins — those are dlopen'd at runtime, so nothing static can see them to
# copy. The bundled core therefore falls back to its compile-time default
# plugin directory, which is the HOST's, and the host's plugins were built
# against the host's core. Version check fails, the scan yields nothing, and
# `appsink` — the element WebKit hands a capture stream to — does not exist.
# getUserMedia() then rejects with NotFoundError and the UI says there is no
# microphone.
#
# Same class as the WebKit problem above (a frozen bundled library paired
# against a host that moved on) and the same remedy: since we ship no plugins,
# the host's core is the only one that can agree with the plugins that will
# actually load, so let it win. This is why the raw binary works — it sets no
# LD_LIBRARY_PATH, so core and plugins are both the host's.
#
# "Let it win" here means preloading that ONE library, not hoisting the
# directory it sits in — see _system_gstreamer_lib below.
#: Full path to the host's libgstreamer-1.0.so.0, or empty.
#
# The FILE, not its directory. The host's GStreamer lives in a general system
# library directory (/usr/lib/x86_64-linux-gnu on Debian), so putting that
# directory ahead of ${HERE}/usr/lib would replace every OTHER bundled library
# with the host's copy too — which is how you get loader symbol errors or a
# blank window on a distro we never built against (greptile). One library needs
# to come from the host; the mechanism has to be that narrow.
_system_gstreamer_lib() {
local dir path
dir="$(pkg-config --variable=libdir gstreamer-1.0 2>/dev/null || echo "")"
if [ -n "$dir" ] && [ -e "$dir/libgstreamer-1.0.so.0" ]; then
printf '%s' "$dir/libgstreamer-1.0.so.0"
return 0
fi
# Runtime-only host (no -dev package, so no .pc file), same fallback as
# _system_webkit_libdir.
if command -v ldconfig >/dev/null 2>&1; then
path="$(ldconfig -p 2>/dev/null \
| awk '/libgstreamer-1\.0\.so\.0 /{print $NF; exit}')"
if [ -n "$path" ] && [ -e "$path" ]; then
printf '%s' "$path"
return 0
fi
fi
return 1
}
_prefer_system_gstreamer() {
# Unlike WebKit there is no version comparison to make: we bundle no
# plugins, so a bundled core can only ever pair with host plugins it was not
# built against. Any host core is better than that. The override exists for
# a host whose GStreamer is genuinely broken, where falling back to the
# bundled core at least keeps the rest of the app running.
[ "${OMNIVOICE_PREFER_SYSTEM_GSTREAMER:-}" = "0" ] && return 1
return 0
}
#: Can this library actually load in the environment the app will run in?
#
# The host's GStreamer links GLib, and the bundle ships GLib too — resolved
# bundle-first. A host core built against newer GLib than we bundle therefore
# fails its relocations and the app does not start AT ALL, which is a worse
# outcome than the broken microphone this is fixing (greptile). Pairing host
# GStreamer with host GLib is not an option either: GLib is what WebKit is
# built against, so pulling that from the host reopens #961/#1258.
#
# So do not predict the pairing — test it. The dynamic loader processes
# LD_PRELOAD for *any* binary, so running `true` under the exact environment
# the app will get is a complete check of "does this library load here":
# a missing dependency or an unresolved version tag (`version 'GLIB_2.84' not
# found`) fails it, and nothing else runs. Cheap, and decisive where a version
# comparison would be guesswork.
#: Path to an EXTERNAL executable to run the probe against, or empty.
#
# It has to be a real binary the loader will exec. `command -v true` answers
# with the shell BUILTIN — the bare word "true", not a path — so `[ -x ... ]`
# rejected it and the probe failed on every host, silently disabling the whole
# preload and leaving #1333 exactly as it was (CodeRabbit). A builtin never
# involves the dynamic loader anyway, so it could not have tested anything.
_preload_probe_bin() {
if [ -n "${OMNIVOICE_APPRUN_PRELOAD_PROBE:-}" ]; then
printf '%s' "$OMNIVOICE_APPRUN_PRELOAD_PROBE"
return 0
fi
local candidate
# Absolute paths only. A PATH lookup is deliberately absent: `command -v true`
# is what caused the original bug, and there is nothing left for it to find
# anyway — /bin/sh is guaranteed present on any host that can run this script
# at all, so the list already terminates. (An earlier revision searched PATH
# for `coreutils`, which is not an executable name and could never resolve —
# a branch that looks like a fallback and is not, CodeRabbit.)
for candidate in /usr/bin/true /bin/true /bin/sh /usr/bin/sh; do
[ -x "$candidate" ] && { printf '%s' "$candidate"; return 0; }
done
return 1
}
# OMNIVOICE_APPRUN_PRELOAD_PROBE lets the unit tests choose the outcome, the
# same way OMNIVOICE_APPRUN_WK_MARKER points at a fixture marker — the decision
# logic is what is testable here; whether a given .so loads is the OS's answer.
#
# The argument is the FINAL LD_PRELOAD value, not just our library. An
# inherited LD_PRELOAD is restored alongside ours for the app, so probing our
# entry alone could pass while the environment the app actually gets fails
# (CodeRabbit).
_preload_loads_cleanly() {
local probe args
probe="$(_preload_probe_bin || echo "")"
[ -n "$probe" ] && [ -x "$probe" ] || return 1
case "$probe" in
*/sh) args="-c :" ;;
*) args="" ;;
esac
# shellcheck disable=SC2086 — $args is our own fixed literal, never user input
LD_PRELOAD="$1" LD_LIBRARY_PATH="${LD_LIBRARY_PATH:-}" "$probe" $args 2>/dev/null
}
# LD_PRELOAD, not LD_LIBRARY_PATH. Preloading names exactly one library and
# leaves the search path — and therefore every other bundled library — alone.
# Reordering directories cannot be that precise: the host's GStreamer shares a
# directory with most of the system, so hoisting it hoists everything.
#
# It is inherited by the Python backend we spawn, where nothing links GStreamer
# and the preload is inert. That is the accepted cost of the narrower mechanism.
if _prefer_system_gstreamer; then
_SYS_GST_LIB="$(_system_gstreamer_lib || echo "")"
_CANDIDATE_PRELOAD="${_SYS_GST_LIB}${LD_PRELOAD:+ $LD_PRELOAD}"
if [ -n "$_SYS_GST_LIB" ] && _preload_loads_cleanly "$_CANDIDATE_PRELOAD"; then
export LD_PRELOAD="$_CANDIDATE_PRELOAD"
elif [ -n "$_SYS_GST_LIB" ]; then
# Fail safe, and say so: the app still starts on the bundled core, which
# is where the microphone problem lives, so the user needs a thread to
# pull rather than a silent half-fix.
echo "VoiceStudio: your GStreamer (${_SYS_GST_LIB}) cannot load against the" >&2
echo " libraries this AppImage bundles, so it is not being used. Audio" >&2
echo " capture may not find any microphone (see issue #1333). Building" >&2
echo " from source avoids the mismatch entirely." >&2
fi
fi
# Private registry cache, unconditionally. GStreamer caches its plugin scan in
# ~/.cache/gstreamer-1.0/registry.<arch>.bin, keyed only by architecture — so
# two cores of different versions clobber each other's file. That is a second,
# independent defect: it makes the failure above intermittent (it depends on
# which application ran last), and the AppImage corrupts the cache for every
# other GStreamer app on the machine. Giving this app its own file removes the
# interaction in both directions.
export GST_REGISTRY_1_0="${XDG_CACHE_HOME:-${HOME:-/tmp}/.cache}/OmniVoice/gstreamer-registry.bin"
mkdir -p -- "$(dirname -- "$GST_REGISTRY_1_0")" 2>/dev/null || true
export XDG_DATA_DIRS="${HERE}/usr/share:${XDG_DATA_DIRS:-/usr/local/share:/usr/share}"
exec "${HERE}/usr/bin/omnivoice-studio" "$@"
-545
View File
@@ -1,545 +0,0 @@
#!/usr/bin/env bash
# Shell unit test for the AppImage AppRun launcher.
# Verifies the _detect_webkit_workaround function's conditional behavior:
# - WEBKIT_DISABLE_COMPOSITING_MODE=1 on known-broken WebKit ranges (2.44.x, 2.46.x)
# - WEBKIT_DISABLE_COMPOSITING_MODE unset on healthy versions (2.48+)
# - WEBKIT_DISABLE_COMPOSITING_MODE=1 when pkg-config is absent (fail-safe)
#
# Per W-1 checker requirement in 01-03-PLAN.md.
set -uo pipefail
THIS_DIR="$(cd "$(dirname "$0")" && pwd)"
PASS_COUNT=0
FAIL_COUNT=0
run_case() {
local label="$1" pkg_output="$2" expected="$3" pkg_present="${4:-yes}"
# Run each case in an isolated subshell.
# - Stub `pkg-config` to print the version we want.
# - Stub `exec` as a no-op so AppRun does not actually try to launch the binary.
# - Sentinel: replace `command -v` so the "missing pkg-config" case can be
# simulated reliably (PATH manipulation alone is fragile in test envs).
local actual
actual=$(
bash -c '
set +e
pkg_present="'"$pkg_present"'"
pkg_output="'"$pkg_output"'"
pkg-config() { echo "$pkg_output"; }
export -f pkg-config
command() {
if [[ "$1" == "-v" && "$2" == "pkg-config" ]]; then
if [[ "$pkg_present" == "yes" ]]; then
echo "function"
return 0
else
return 1
fi
fi
builtin command "$@"
}
export -f command
# Neutralise the exec at the end of AppRun so sourcing does not hand off
# control to a missing binary.
exec() { :; }
export -f exec
# Source the AppRun and call the detection function. AppRun begins with
# `set -euo pipefail`; that is fine for the function itself.
# shellcheck disable=SC1090
source "'"$THIS_DIR"'/AppRun" >/dev/null 2>&1 || true
echo "${WEBKIT_DISABLE_COMPOSITING_MODE:-unset}"
'
)
if [[ "$actual" == "$expected" ]]; then
echo "PASS [$label]"
PASS_COUNT=$((PASS_COUNT + 1))
else
echo "FAIL [$label]: expected '$expected' got '$actual'" >&2
FAIL_COUNT=$((FAIL_COUNT + 1))
fi
}
run_case "2.44 (broken)" "2.44.3" "1"
run_case "2.46 (broken)" "2.46.1" "1"
run_case "2.48 (healthy)" "2.48.0" "unset"
run_case "pkg-config absent" "0.0" "1" "no"
# ── Bundled-version marker cases (#961 follow-up) ───────────────────────────
# inject-apprun.sh stamps the bundle's actual WebKitGTK version into
# .bundled-webkitgtk-version at build time; AppRun must prefer that marker
# over the host's pkg-config (which reports the SYSTEM version — wrong
# whenever it diverges from the bundled copy, e.g. on a machine with newer
# dev packages installed).
run_marker_case() {
local label="$1" marker_content="$2" pkg_output="$3" expected="$4"
local marker_file
marker_file="$(mktemp)"
printf '%s\n' "$marker_content" > "$marker_file"
local actual
actual=$(
bash -c '
set +e
pkg_output="'"$pkg_output"'"
export OMNIVOICE_APPRUN_WK_MARKER="'"$marker_file"'"
pkg-config() { echo "$pkg_output"; }
export -f pkg-config
exec() { :; }
export -f exec
# shellcheck disable=SC1090
source "'"$THIS_DIR"'/AppRun" >/dev/null 2>&1 || true
echo "${WEBKIT_DISABLE_COMPOSITING_MODE:-unset}"
'
)
rm -f "$marker_file"
if [[ "$actual" == "$expected" ]]; then
echo "PASS [$label]"
PASS_COUNT=$((PASS_COUNT + 1))
else
echo "FAIL [$label]: expected '$expected' got '$actual'" >&2
FAIL_COUNT=$((FAIL_COUNT + 1))
fi
}
# Marker says broken → workaround applies, even though host pkg-config reports
# a different number. The host here is OLDER, so the bundled lib is the one
# that runs and the marker is the only correct source.
#
# (This case used to pair marker 2.46 with host 2.48 and expect the workaround.
# Since #1258 a host that is NEWER takes over the run entirely, so the honest
# expectation for that pairing is "no workaround" — asserted directly in
# run_workaround_with_system below. The intent being pinned here, "trust the
# marker over the host for the library that actually runs", is unchanged.)
run_marker_case "marker 2.46 beats host 2.44" "2.46.1" "2.44.3" "1"
# Marker says healthy → no workaround, even though host pkg-config says broken
# (the exact #961 inversion: from-source user with old system lib, new bundle).
run_marker_case "marker 2.48 beats host 2.44" "2.48.0" "2.44.3" "unset"
# Empty marker → treated as unknown → fail-safe workaround.
run_marker_case "empty marker fails safe" "" "2.48.0" "1"
# ── System-vs-bundled WebKit priority (#1258, #1244) ────────────────────────
# The bundled WebKitGTK links against the HOST's Mesa (the AppImage ships no
# libEGL), so a host that has moved ahead of the build runner hits
# EGL_BAD_PARAMETER and a permanently blank window — with no env-var escape,
# because the failure precedes every rendering-path flag. When the host has a
# WebKitGTK at least as new as ours, its own copy must win: that is exactly
# what makes a source build work on the hardware where the AppImage does not.
run_ldpath_case() {
local label="$1" marker_content="$2" system_version="$3" expected="$4"
local marker_file wklibdir
marker_file="$(mktemp)"
printf '%s\n' "$marker_content" > "$marker_file"
# A REAL file: AppRun's libdir probe uses `[ -e ]`, which is a shell builtin
# and cannot be stubbed.
wklibdir="$(mktemp -d)"
touch "$wklibdir/libwebkit2gtk-4.1.so.0"
local actual
actual=$(
bash -c '
set +e
export OMNIVOICE_APPRUN_WK_MARKER="'"$marker_file"'"
sys="'"$system_version"'"
wklibdir="'"$wklibdir"'"
pkg-config() {
[ -n "$sys" ] || return 1
case "$1" in
--variable=libdir) echo "$wklibdir" ;;
*) echo "$sys" ;;
esac
}
export -f pkg-config
exec() { :; }
export -f exec
unset LD_LIBRARY_PATH
# shellcheck disable=SC1090
source "'"$THIS_DIR"'/AppRun" >/dev/null 2>&1 || true
# The bug this pins: LD_LIBRARY_PATH is searched BEFORE the linker default
# paths regardless of ordering within it, so merely appending the bundle
# still let the bundled WebKit win on a normal (empty) launch. The host
# libdir must be named explicitly, ahead of ours.
case "$LD_LIBRARY_PATH" in
"$wklibdir":*) echo "system-first" ;;
*) echo "bundle-first" ;;
esac
'
)
rm -rf "$marker_file" "$wklibdir"
if [[ "$actual" == "$expected" ]]; then
echo "PASS [$label]"
PASS_COUNT=$((PASS_COUNT + 1))
else
echo "FAIL [$label]: expected '$expected' got '$actual'" >&2
FAIL_COUNT=$((FAIL_COUNT + 1))
fi
}
# The #1258 machine: Arch/CachyOS ships WebKit 2.52.5, we bundle 2.44 —
# the host's copy is newer AND built against the Mesa actually installed.
run_ldpath_case "newer host WebKit wins" "2.44.3" "2.52.5" "system-first"
# Equal is still a win for the host: same version, but compiled against the
# Mesa that is actually present.
run_ldpath_case "equal host WebKit wins" "2.48.0" "2.48.0" "system-first"
# An OLDER host must not be preferred — that is the #961 regression.
run_ldpath_case "older host WebKit loses" "2.48.0" "2.44.3" "bundle-first"
# A host with no WebKitGTK at all is the case the bundle exists for.
run_ldpath_case "no host WebKit → bundle" "2.48.0" "" "bundle-first"
# An unstamped bundle can't compare, so it must not gamble.
run_ldpath_case "unknown bundle → bundle" "0.0" "2.52.5" "bundle-first"
# Runtime-only hosts (an end user who never installed the -dev package) have the
# library but no .pc file, so the version is unknowable from here. Preferring an
# unverified copy could hand the user an OLDER WebKit than we ship (#961), so
# the default stays with the bundle and an explicit opt-in exists for the
# machines where the bundle simply cannot start (#1258 review).
run_optin_case() {
local label="$1" optin="$2" marker_content="$3" system_version="$4" expected="$5"
local marker_file wklibdir
marker_file="$(mktemp)"
printf '%s\n' "$marker_content" > "$marker_file"
wklibdir="$(mktemp -d)"
touch "$wklibdir/libwebkit2gtk-4.1.so.0"
local actual
actual=$(
bash -c '
set +e
export OMNIVOICE_APPRUN_WK_MARKER="'"$marker_file"'"
export OMNIVOICE_PREFER_SYSTEM_WEBKIT="'"$optin"'"
sys="'"$system_version"'"
wklibdir="'"$wklibdir"'"
pkg-config() {
[ -n "$sys" ] || return 1
case "$1" in
--variable=libdir) echo "$wklibdir" ;;
*) echo "$sys" ;;
esac
}
export -f pkg-config
# A runtime-only host: no pkg-config metadata, but ldconfig knows the lib.
ldconfig() { echo " libwebkit2gtk-4.1.so.0 (libc6,x86-64) => $wklibdir/libwebkit2gtk-4.1.so.0"; }
export -f ldconfig
exec() { :; }
export -f exec
unset LD_LIBRARY_PATH
# shellcheck disable=SC1090
source "'"$THIS_DIR"'/AppRun" >/dev/null 2>&1 || true
case "$LD_LIBRARY_PATH" in
"$wklibdir":*) echo "system-first" ;;
*) echo "bundle-first" ;;
esac'
)
rm -rf "$marker_file" "$wklibdir"
if [[ "$actual" == "$expected" ]]; then
echo "PASS [$label]"
PASS_COUNT=$((PASS_COUNT + 1))
else
echo "FAIL [$label]: expected '$expected' got '$actual'" >&2
FAIL_COUNT=$((FAIL_COUNT + 1))
fi
}
# No pkg-config metadata → version unknowable → keep the bundle by default.
run_optin_case "runtime-only host defaults to bundle" "" "2.44.3" "" "bundle-first"
# ...and the opt-in gets that user running without building from source.
run_optin_case "opt-in overrides the unknown" "1" "2.44.3" "" "system-first"
# The opt-out is honoured even when the host would otherwise win.
run_optin_case "opt-out keeps the bundle" "0" "2.44.3" "2.52.5" "bundle-first"
# When the host's copy is chosen, the compositing workaround must be decided
# against THAT version, not the bundled one it was picked for.
run_workaround_with_system() {
local label="$1" marker_content="$2" system_version="$3" expected="$4"
local marker_file wklibdir
marker_file="$(mktemp)"
printf '%s\n' "$marker_content" > "$marker_file"
# A REAL file: AppRun's libdir probe uses `[ -e ]`, which is a shell builtin
# and cannot be stubbed.
wklibdir="$(mktemp -d)"
touch "$wklibdir/libwebkit2gtk-4.1.so.0"
local actual
actual=$(
bash -c '
set +e
export OMNIVOICE_APPRUN_WK_MARKER="'"$marker_file"'"
sys="'"$system_version"'"
wklibdir="'"$wklibdir"'"
pkg-config() {
[ -n "$sys" ] || return 1
case "$1" in
--variable=libdir) echo "$wklibdir" ;;
*) echo "$sys" ;;
esac
}
export -f pkg-config
exec() { :; }
export -f exec
# shellcheck disable=SC1090
source "'"$THIS_DIR"'/AppRun" >/dev/null 2>&1 || true
echo "${WEBKIT_DISABLE_COMPOSITING_MODE:-unset}"
'
)
rm -f "$marker_file"
if [[ "$actual" == "$expected" ]]; then
echo "PASS [$label]"
PASS_COUNT=$((PASS_COUNT + 1))
else
echo "FAIL [$label]: expected '$expected' got '$actual'" >&2
FAIL_COUNT=$((FAIL_COUNT + 1))
fi
}
# Bundle 2.44 (broken → workaround armed), host 2.52 healthy and now in charge:
# the workaround must be dropped or we software-render a healthy WebKit.
run_workaround_with_system "healthy host drops the workaround" "2.44.3" "2.52.5" "unset"
# Bundle 2.44, host 2.46 — newer, so it wins, but it is ALSO in the broken
# range: the workaround must be re-armed for the version that actually runs.
run_workaround_with_system "broken host re-arms it" "2.44.3" "2.46.1" "1"
# ── GStreamer: host core must win, and the registry must be private (#1333) ──
# The bundle ships libgstreamer-1.0 (WebKit links it) but no plugins (they are
# dlopen'd, so nothing static can see them to copy). A bundled core paired with
# host plugins fails its version check, finds no `appsink`, and getUserMedia()
# rejects with NotFoundError — the "No microphone found" the user saw, on a
# machine whose audio stack was verified healthy with pactl/wpctl/gst-launch.
# `discovery` selects which lookup path finds the host library:
# pkgconfig — the -dev package is installed and answers
# ldconfig — runtime-only host (no .pc file), the #1258-review fallback
# none — no host GStreamer at all
run_gst_case() {
local label="$1" optout="$2" discovery="$3" expected="$4" loadable="${5:-yes}"
local gstlibdir cachedir probe actual
gstlibdir="$(mktemp -d)"
cachedir="$(mktemp -d)"
[ "$discovery" = "none" ] || touch "$gstlibdir/libgstreamer-1.0.so.0"
# The load probe: AppRun runs this under the candidate LD_PRELOAD, so its
# exit status stands in for the dynamic loader accepting or rejecting the
# pairing. (A fixture .so is an empty file; only the real loader could
# answer that for real, and that is the OS's job, not this suite's.)
probe="$(mktemp)"
case "$loadable" in
# The real /bin/sh, so the `*/sh) args="-c :"` branch runs for real: with
# no argument `sh` would read stdin and hang, so a broken branch here is
# not a silent pass.
sh) rm -f "$probe"; probe="$(command -v /bin/sh)" ;;
yes) printf '#!/bin/sh\nexit 0\n' > "$probe"; chmod +x "$probe" ;;
*) printf '#!/bin/sh\nexit 1\n' > "$probe"; chmod +x "$probe" ;;
esac
actual=$(
bash -c '
set +e
export OMNIVOICE_PREFER_SYSTEM_GSTREAMER="'"$optout"'"
export OMNIVOICE_APPRUN_PRELOAD_PROBE="'"$probe"'"
export XDG_CACHE_HOME="'"$cachedir"'"
gstlibdir="'"$gstlibdir"'"
discovery="'"$discovery"'"
pkg-config() {
if [ "$1" = "--variable=libdir" ] && [ "$2" = "gstreamer-1.0" ] \
&& [ "$discovery" = "pkgconfig" ]; then
echo "$gstlibdir"; return 0
fi
return 1
}
export -f pkg-config
# No host WebKit either way; this case is only about the GStreamer branch.
ldconfig() {
[ "$discovery" = "ldconfig" ] || return 1
echo " libgstreamer-1.0.so.0 (libc6,x86-64) => $gstlibdir/libgstreamer-1.0.so.0"
}
export -f ldconfig
exec() { :; }
export -f exec
unset LD_LIBRARY_PATH
unset LD_PRELOAD
# shellcheck disable=SC1090
source "'"$THIS_DIR"'/AppRun" >/dev/null 2>&1 || true
case "${LD_PRELOAD:-}" in
*"$gstlibdir/libgstreamer-1.0.so.0"*) printf "gst-preloaded" ;;
*) printf "no-gst" ;;
esac
# The whole point of preloading is that the SEARCH PATH is untouched:
# the host GStreamer sits in a general system libdir, so hoisting that
# directory would supersede every other bundled library too (greptile).
case "$LD_LIBRARY_PATH" in
"$gstlibdir":*) printf "+libdir-hoisted" ;;
*) printf "+libdir-intact" ;;
esac
case "${GST_REGISTRY_1_0:-}" in
"'"$cachedir"'"/OmniVoice/*) printf "+private-registry" ;;
*) printf "+shared-registry" ;;
esac'
)
rm -rf "$gstlibdir" "$cachedir"
[ "$loadable" = "sh" ] || rm -f "$probe"
if [[ "$actual" == "$expected" ]]; then
echo "PASS [$label]"
PASS_COUNT=$((PASS_COUNT + 1))
else
echo "FAIL [$label]: expected '$expected' got '$actual'" >&2
FAIL_COUNT=$((FAIL_COUNT + 1))
fi
}
# The reported machine: a healthy host GStreamer exists, so it must resolve
# ahead of the bundled core.
run_gst_case "host GStreamer wins (pkg-config)" "" "pkgconfig" "gst-preloaded+libdir-intact+private-registry"
# Runtime-only host: the library is installed but there is no .pc file, so only
# ldconfig can find it. Unlike WebKit there is no version to compare, so this
# path must still win rather than fall back (CodeRabbit: the fallback branch was
# untested because every case forced ldconfig to fail).
run_gst_case "host GStreamer wins (ldconfig)" "" "ldconfig" "gst-preloaded+libdir-intact+private-registry"
# A host with no GStreamer at all: nothing to prefer, and the bundled core is
# all there is. Must not break, and must still get a private registry.
run_gst_case "no host GStreamer is harmless" "" "none" "no-gst+libdir-intact+private-registry"
# Escape hatch for a host whose own GStreamer is broken.
run_gst_case "opt-out keeps the bundled core" "0" "pkgconfig" "no-gst+libdir-intact+private-registry"
# A host core that needs newer GLib than the bundle ships fails its relocations
# and the app would not start AT ALL — worse than the broken microphone this
# fixes (greptile). The load probe catches that, so the preload is skipped and
# the app still launches on the bundled core.
run_gst_case "unloadable host core is skipped" "" "pkgconfig" "no-gst+libdir-intact+private-registry" "no"
# ── The load probe must be a REAL binary, and must test the REAL value ──────
# Both of these silently disabled the feature rather than breaking loudly, which
# is why they get their own cases (CodeRabbit).
#
# 1. `command -v true` answers with the shell BUILTIN — the bare word "true" —
# so `[ -x "true" ]` was false on every host and the preload never happened.
# A builtin never involves the dynamic loader, so it could not have tested
# anything even if it had run.
# 2. An inherited LD_PRELOAD is restored alongside ours for the app, so probing
# our library alone can pass while the environment the app gets fails.
run_probe_default_case() {
local label="$1" expected="$2"
local gstlibdir actual
gstlibdir="$(mktemp -d)"
touch "$gstlibdir/libgstreamer-1.0.so.0"
actual=$(
bash -c '
set +e
gstlibdir="'"$gstlibdir"'"
pkg-config() {
[ "$1" = "--variable=libdir" ] && [ "$2" = "gstreamer-1.0" ] \
&& { echo "$gstlibdir"; return 0; }
return 1
}
export -f pkg-config
ldconfig() { return 1; }
export -f ldconfig
exec() { :; }
export -f exec
unset OMNIVOICE_APPRUN_PRELOAD_PROBE
unset LD_LIBRARY_PATH LD_PRELOAD
# shellcheck disable=SC1090
source "'"$THIS_DIR"'/AppRun" >/dev/null 2>&1 || true
probe="$(_preload_probe_bin || echo "")"
case "$probe" in
/*) [ -x "$probe" ] && echo "external-binary" || echo "not-executable" ;;
"") echo "none" ;;
*) echo "builtin-name" ;;
esac'
)
rm -rf "$gstlibdir"
if [[ "$actual" == "$expected" ]]; then
echo "PASS [$label]"
PASS_COUNT=$((PASS_COUNT + 1))
else
echo "FAIL [$label]: expected '$expected' got '$actual'" >&2
FAIL_COUNT=$((FAIL_COUNT + 1))
fi
}
run_probe_default_case "default probe is an external binary" "external-binary"
# The probed value must be what the app will actually get, inherited entries
# included.
run_probe_arg_case() {
local label="$1" inherited="$2" expected="$3"
local gstlibdir probe seen actual
gstlibdir="$(mktemp -d)"
touch "$gstlibdir/libgstreamer-1.0.so.0"
seen="$(mktemp)"
probe="$(mktemp)"
# Record what LD_PRELOAD the probe was invoked with, then succeed.
printf '#!/bin/sh\nprintf %%s "$LD_PRELOAD" > %s\nexit 0\n' "$seen" > "$probe"
chmod +x "$probe"
bash -c '
set +e
export OMNIVOICE_APPRUN_PRELOAD_PROBE="'"$probe"'"
export LD_PRELOAD="'"$inherited"'"
gstlibdir="'"$gstlibdir"'"
pkg-config() {
[ "$1" = "--variable=libdir" ] && [ "$2" = "gstreamer-1.0" ] \
&& { echo "$gstlibdir"; return 0; }
return 1
}
export -f pkg-config
ldconfig() { return 1; }
export -f ldconfig
exec() { :; }
export -f exec
unset LD_LIBRARY_PATH
# shellcheck disable=SC1090
source "'"$THIS_DIR"'/AppRun" >/dev/null 2>&1 || true' >/dev/null 2>&1
actual="$(cat "$seen" 2>/dev/null || echo "")"
actual="${actual//$gstlibdir\/libgstreamer-1.0.so.0/GST}"
rm -rf "$gstlibdir" "$probe" "$seen"
if [[ "$actual" == "$expected" ]]; then
echo "PASS [$label]"
PASS_COUNT=$((PASS_COUNT + 1))
else
echo "FAIL [$label]: expected '$expected' got '$actual'" >&2
FAIL_COUNT=$((FAIL_COUNT + 1))
fi
}
run_probe_arg_case "probe sees our library" "" "GST"
run_probe_arg_case "probe sees inherited entries too" "/opt/x.so" "GST /opt/x.so"
# The /bin/sh last resort is a real branch — `sh` needs `-c :` where `true`
# needs no argument, and until now nothing ran it, so a host without
# /usr/bin/true or /bin/true would have been the first to find out (CodeRabbit
# flagged the sibling dead branch; this is the coverage that was missing with
# it). Pointing the probe override at /bin/sh exercises exactly that path.
run_gst_case "sh last-resort probe works" "" "pkgconfig" "gst-preloaded+libdir-intact+private-registry" "sh"
echo
echo "─── AppRun test summary: $PASS_COUNT pass / $FAIL_COUNT fail ───"
if [[ $FAIL_COUNT -ne 0 ]]; then
exit 1
fi
exit 0
-6
View File
@@ -1,6 +0,0 @@
# Binaries dropped here are either build-time placeholders (created by
# build.rs) or real per-target binaries fetched in CI before the
# tauri-action bundle step. None of them belong in version control.
uv-*
ffmpeg-*
ffprobe-*
-64
View File
@@ -1,64 +0,0 @@
use std::path::PathBuf;
// Create a placeholder `binaries/<name>-<target-triple>` file if one doesn't
// already exist for the current target. Tauri's `bundle.externalBin`
// config is validated at every build (including `cargo check`), and it
// hard-errors when the source binary is missing — which it is in dev,
// because the real binaries are only fetched during release builds in CI.
// The placeholder is empty (zero bytes) and cannot actually be run;
// `find_bundled_*()` at runtime falls back to PATH or pip-bundled binaries
// when the bundled file isn't a real executable. CI overwrites these files
// with the real binaries before the tauri-action bundle step.
fn ensure_sidecar_placeholder(name: &str) {
let triple = std::env::var("TARGET").unwrap_or_default();
if triple.is_empty() {
return;
}
let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").unwrap_or_else(|_| ".".into());
let binaries_dir = PathBuf::from(&manifest_dir).join("binaries");
let _ = std::fs::create_dir_all(&binaries_dir);
let suffix = if triple.contains("windows") { ".exe" } else { "" };
let target_path = binaries_dir.join(format!("{}-{}{}", name, triple, suffix));
if !target_path.exists() {
let _ = std::fs::write(&target_path, b"");
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
if let Ok(meta) = std::fs::metadata(&target_path) {
let mut perms = meta.permissions();
perms.set_mode(0o755);
let _ = std::fs::set_permissions(&target_path, perms);
}
}
}
}
fn main() {
// backend.rs bakes the analytics destination in with option_env!, which cargo
// resolves at COMPILE time — so without these, a cached build would keep the
// token it was first compiled with (in practice: none), and the secret would
// appear to be ignored. Tell cargo the build depends on them.
println!("cargo:rerun-if-env-changed=VITE_POSTHOG_KEY");
println!("cargo:rerun-if-env-changed=VITE_POSTHOG_HOST");
ensure_sidecar_placeholder("uv");
ensure_sidecar_placeholder("ffmpeg");
ensure_sidecar_placeholder("ffprobe");
// Windows test binaries need the Common-Controls v6 manifest that
// tauri-build embeds into the app binary but cargo gives tests none of:
// without it the loader resolves comctl32 v5 (no TaskDialogIndirect —
// imported by tauri's dialog/tray stack) and every integration-test
// binary dies at load with STATUS_ENTRYPOINT_NOT_FOUND (0xc0000139).
// See tests/windows-test.manifest.
if std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("windows") {
let manifest = PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").unwrap_or_else(|_| ".".into()))
.join("tests")
.join("windows-test.manifest");
println!("cargo:rerun-if-changed={}", manifest.display());
println!("cargo:rustc-link-arg-tests=/MANIFEST:EMBED");
println!("cargo:rustc-link-arg-tests=/MANIFESTINPUT:{}", manifest.display());
}
tauri_build::build();
}
@@ -1,7 +0,0 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "capture-widget",
"description": "Allows the capture widget to hide when recording ends or an idle window is reconciled",
"windows": ["widget"],
"permissions": ["core:window:allow-hide"]
}
@@ -1,30 +0,0 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "enables the default permissions",
"windows": [
"main",
"widget"
],
"permissions": [
"core:default",
"core:window:allow-start-dragging",
"core:window:allow-toggle-maximize",
"core:window:allow-set-fullscreen",
"core:window:allow-minimize",
"core:window:allow-close",
"core:webview:allow-set-webview-zoom",
"dialog:allow-save",
"dialog:allow-open",
"dialog:allow-message",
"dialog:allow-ask",
"updater:default",
"process:default",
"process:allow-restart",
"opener:default",
"global-shortcut:allow-register",
"global-shortcut:allow-unregister",
"global-shortcut:allow-is-registered",
"global-shortcut:allow-unregister-all"
]
}
-39
View File
@@ -1,39 +0,0 @@
#!/bin/sh
# VoiceStudio — .deb post-install hook.
#
# Issue #76: prior versions placed bundled ffprobe at /usr/bin/ffprobe via
# Tauri's externalBin, overwriting the system ffprobe on Ubuntu 26.04 + others.
# We now ship our copy at /usr/lib/omnivoice-studio/bin/ffprobe via deb.files
# (see frontend/src-tauri/tauri.linux.conf.json). This script:
# 1. Removes the legacy /usr/bin/ffprobe ONLY IF dpkg reports it as owned by
# our package (so we never blow away a user's distro-shipped ffprobe).
# 2. Ensures the new binary is executable.
set -e
LEGACY_PATH="/usr/bin/ffprobe"
NEW_PATH="/usr/lib/omnivoice-studio/bin/ffprobe"
case "$1" in
configure)
# Step 1 — defensive cleanup of a legacy package-owned /usr/bin/ffprobe.
# Only remove if dpkg confirms our package owns the file. This protects
# users who have a system ffprobe installed separately (a real risk on
# Ubuntu 26.04 — see #76).
if [ -e "$LEGACY_PATH" ] || [ -L "$LEGACY_PATH" ]; then
OWNER="$(dpkg -S "$LEGACY_PATH" 2>/dev/null | cut -d: -f1 || echo "")"
case "$OWNER" in
omnivoice-studio|omnivoice|OmniVoice*)
rm -f "$LEGACY_PATH" || true
;;
esac
fi
# Step 2 — make sure the new path is executable.
if [ -e "$NEW_PATH" ]; then
chmod 755 "$NEW_PATH" || true
fi
;;
esac
exit 0
-17
View File
@@ -1,17 +0,0 @@
#!/bin/sh
# VoiceStudio — .deb post-remove hook.
#
# Cleans up the relocated ffprobe directory tree on purge/remove. We only
# remove our own files — never the parent /usr/lib if other packages share it.
set -e
case "$1" in
purge|remove)
rm -f /usr/lib/omnivoice-studio/bin/ffprobe || true
rmdir /usr/lib/omnivoice-studio/bin 2>/dev/null || true
rmdir /usr/lib/omnivoice-studio 2>/dev/null || true
;;
esac
exit 0
-13
View File
@@ -1,13 +0,0 @@
#!/bin/sh
# VoiceStudio — .deb pre-install hook.
#
# Ensure the target directory exists before dpkg writes the bundled ffprobe to
# /usr/lib/omnivoice-studio/bin/. dpkg creates parent dirs for files in the
# data archive, but having this here makes upgrade flows from an older install
# (where the dir didn't exist yet) robust.
set -e
mkdir -p /usr/lib/omnivoice-studio/bin
exit 0
-28
View File
@@ -1,28 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!--
Tauri's macOS bundle defaults `hardenedRuntime` to true. Hardened
Runtime blocks camera/microphone hardware access unless the matching
entitlement is present here — regardless of Info.plist's
NSMicrophoneUsageDescription and regardless of wry's own WKUIDelegate
already granting the request at the WebKit/JS layer
(WryWebViewUIDelegate::request_media_capture_permission unconditionally
calls WKPermissionDecision::Grant). Without this entitlement, TCC
never even registers a request for the app — nothing shows up in
System Settings → Privacy & Security → Microphone to enable, because
the OS never saw a legitimately-entitled process ask.
-->
<key>com.apple.security.device.audio-input</key>
<true/>
<!--
Matches Info.plist's forward-looking NSCameraUsageDescription — no
current feature uses the camera, but ship the entitlement now so a
future getUserMedia({video: true}) call doesn't hit this same bug.
-->
<key>com.apple.security.device.camera</key>
<true/>
</dict>
</plist>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

@@ -1,5 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
<background android:drawable="@color/ic_launcher_background"/>
</adaptive-icon>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 KiB

Some files were not shown because too many files have changed in this diff Show More