chore: retire legacy Tauri app and UI
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: |
|
||||
|
||||
@@ -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).
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 |
@@ -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',
|
||||
|
||||
@@ -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')),
|
||||
});
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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 |
@@ -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,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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}'`);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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,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();
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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')));
|
||||
});
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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
|
||||
`,
|
||||
);
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"lib": ["ES2023"],
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"types": ["node", "electron-vite/node"],
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
|
||||
@@ -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') },
|
||||
},
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
{
|
||||
"$schema": "./node_modules/oxfmt/configuration_schema.json",
|
||||
"singleQuote": true,
|
||||
"jsxSingleQuote": false,
|
||||
"ignorePatterns": [
|
||||
"**/*.css",
|
||||
"**/*.json",
|
||||
"**/*.toml",
|
||||
"**/*.html",
|
||||
"**/*.md",
|
||||
"src-tauri/**"
|
||||
]
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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('/');
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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([]);
|
||||
});
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
]);
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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) },
|
||||
},
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
# Generated by Cargo
|
||||
# will have compiled files and executables
|
||||
/target/
|
||||
/gen/schemas
|
||||
@@ -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
|
||||
@@ -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" "$@"
|
||||
@@ -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
|
||||
@@ -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-*
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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>
|
||||
|
Before Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 4.6 KiB |
|
Before Width: | Height: | Size: 8.7 KiB |
|
Before Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 2.9 KiB |
|
Before Width: | Height: | Size: 5.2 KiB |
|
Before Width: | Height: | Size: 6.8 KiB |
|
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>
|
||||
|
Before Width: | Height: | Size: 3.7 KiB |