* fix(appimage): conditional WEBKIT_DISABLE_COMPOSITING_MODE launcher (#56) WebKitGTK 2.44.x and 2.46.x have a compositing-path regression on Wayland that blanks the AppImage's first paint on Fedora 44 / Ubuntu 24.04. Setting WEBKIT_DISABLE_COMPOSITING_MODE=1 forces the software fallback that works, but blindly setting it on healthy WebKit versions (2.48+) regresses those. This wave adds a conditional AppRun launcher that detects the WebKit version via pkg-config and only sets the env var on the broken ranges (plus a fail-safe when pkg-config is absent or the version is unknown). The launcher is injected into Tauri's AppImage staging dir via a beforeBundleCommand hook — see .planning/decisions/apprun-strategy.md for the spike outcome and rationale (Strategy B chosen). Phase 1 Wave 3 — Plan 01-03 Task 1. Closes #56 frontend half. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(deb): relocate bundled ffprobe out of /usr/bin to avoid conflicts (#76) Prior versions placed the bundled ffprobe at /usr/bin/ffprobe via Tauri's externalBin, which overwrites the system ffprobe on Ubuntu 26.04 and collides with apt-installed media-package ffprobe. Relocate the .deb-bundled ffprobe to /usr/lib/omnivoice-studio/bin/ffprobe via bundle.linux.deb.files, plus defensive maintainer scripts: - preinst: ensure target dir exists for upgrade flows - postinst: remove legacy /usr/bin/ffprobe ONLY when dpkg confirms our package owns it (never touches a user's distro ffprobe) - postrm: clean up the relocated path tree on purge/remove Rust side (tools.rs::resolve_ffprobe) now probes the new path on Linux, and backend spawn (backend.rs) carries both FFPROBE_PATH (legacy alias) and OMNIVOICE_FFPROBE_PATH (canonical) into the backend env. Python side (ffmpeg_utils.resolve_ffprobe) reads OMNIVOICE_FFPROBE_PATH first, falls back to FFPROBE_PATH, then to shutil.which("ffprobe"). 6 new unit tests cover the env-cascade resolution. Phase 1 Wave 3 — Plan 01-03 Task 2. Closes #76. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(frontend): centralised apiBase resolver for Docker LAN access (#80) Docker / LAN browser users hit the preview API at the LAN host's IP, not their local machine — the prior frontend/src/utils/media.js:20 hardcoded http://localhost:3900, which from a LAN client resolved to the client machine itself. Centralise via frontend/src/utils/apiBase.ts: 1. VITE_OMNIVOICE_API override (Docker compose / dev) always wins. 2. Tauri webview → http://localhost:3900 (unchanged behaviour). 3. Plain browser → ${window.location.protocol}//${window.location.hostname}:3900 (follows the page's origin — closes #80). 4. SSR / no-window → http://localhost:3900 (safe fallback). Grep-sweep confirmed media.js:20 was the only hardcode site (Assumption A4 in 01-RESEARCH.md verified). 6 new vitest cases cover the resolver. Phase 1 Wave 3 — Plan 01-03 Task 3. Closes #80 frontend half. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(backend): macOS Gatekeeper quarantine probe + INST-01 guard (#54) Adds backend/core/gatekeeper_detect.py which walks up from sys.executable to find the .app bundle and runs `xattr -l` to check for the quarantine extended attribute (com.apple.quarantine). On detection, the lifespan startup probe logs a structured warning and emits a system_error event through the existing event bus with error_class="GATEKEEPER_QUARANTINE", which Wave 2's React ErrorBoundary turns into a docs deeplink. Detection is informational only — we never auto-run `xattr -cr` (the app itself is quarantined and cannot fix its own state per Anti-Pattern in 01-RESEARCH.md). Users get a clear pointer to the workaround docs. GET /system/quarantine-status exposes the structured payload so the frontend can poll on first load. INST-01 (setuptools>=75.0 pin from PR #62) gains a PR-time guard in tests/backend/test_pyproject.py + a user-observable smoke check in scripts/smoke-test.sh (pkg_resources + whisperx import). 7 gatekeeper tests + 1 pyproject test added — all pass. Phase 1 Wave 3 — Plan 01-03 Task 4. Closes #54 backend half (Wave 2 owns the docs page + ErrorBoundary deeplink wiring). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
165 lines
5.6 KiB
Python
165 lines
5.6 KiB
Python
import asyncio
|
|
import errno
|
|
import logging
|
|
import os
|
|
import shutil
|
|
|
|
logger = logging.getLogger("omnivoice.api")
|
|
|
|
# Cap concurrent ffmpeg jobs so macOS posix_spawn can't hit EAGAIN under load.
|
|
_FFMPEG_SEMAPHORE: "asyncio.Semaphore | None" = None
|
|
_FFMPEG_CONCURRENCY = 2
|
|
|
|
|
|
def _get_semaphore() -> asyncio.Semaphore:
|
|
global _FFMPEG_SEMAPHORE
|
|
if _FFMPEG_SEMAPHORE is None:
|
|
_FFMPEG_SEMAPHORE = asyncio.Semaphore(_FFMPEG_CONCURRENCY)
|
|
return _FFMPEG_SEMAPHORE
|
|
|
|
|
|
def find_ffmpeg():
|
|
"""Locate an ffmpeg binary.
|
|
|
|
Resolution order:
|
|
1. ``FFMPEG_PATH`` env var (set by Tauri when a sidecar is bundled).
|
|
2. ``imageio-ffmpeg`` pip package (ships a static binary per platform).
|
|
3. Common system paths / ``PATH``.
|
|
|
|
Returns the path string, or ``None`` if nothing found.
|
|
"""
|
|
# 1. Env var injected by Tauri host
|
|
env_path = os.environ.get("FFMPEG_PATH")
|
|
if env_path:
|
|
resolved = shutil.which(env_path)
|
|
if resolved:
|
|
return resolved
|
|
# 2. imageio-ffmpeg bundled static binary
|
|
try:
|
|
import imageio_ffmpeg
|
|
candidate = imageio_ffmpeg.get_ffmpeg_exe()
|
|
if candidate and os.path.isfile(candidate):
|
|
return candidate
|
|
logger.debug("imageio_ffmpeg binary not found at %s", candidate)
|
|
except Exception as e:
|
|
logger.debug("imageio_ffmpeg unavailable: %s", e)
|
|
# 3. Well-known system paths + PATH lookup
|
|
for path in ["/opt/homebrew/bin/ffmpeg", "/usr/local/bin/ffmpeg", "ffmpeg"]:
|
|
if shutil.which(path):
|
|
return path
|
|
logger.warning("ffmpeg not found in env, imageio, or system PATH")
|
|
return None
|
|
|
|
|
|
def resolve_ffprobe() -> str | None:
|
|
"""Resolve an ffprobe binary path.
|
|
|
|
Resolution order (per issue #76 and 01-03-PLAN.md must_haves):
|
|
1. ``OMNIVOICE_FFPROBE_PATH`` env var — the canonical, namespaced path
|
|
injected by Tauri pointing at the bundled sidecar (e.g.
|
|
``/usr/lib/omnivoice-studio/bin/ffprobe`` on .deb installs).
|
|
2. ``FFPROBE_PATH`` env var — legacy alias kept for backward
|
|
compatibility with older Tauri shells / dev environments.
|
|
3. ``shutil.which("ffprobe")`` — system ``PATH`` fallback.
|
|
|
|
Returns the resolved path string, or ``None`` if nothing found. Callers
|
|
that need a hard failure should use :func:`find_ffprobe` instead.
|
|
"""
|
|
for env_key in ("OMNIVOICE_FFPROBE_PATH", "FFPROBE_PATH"):
|
|
path = os.environ.get(env_key)
|
|
if not path:
|
|
continue
|
|
# The env var may carry either an absolute path to a file OR a bare
|
|
# command name (legacy). Accept both shapes — file first.
|
|
if os.path.isfile(path):
|
|
return path
|
|
resolved = shutil.which(path)
|
|
if resolved:
|
|
return resolved
|
|
|
|
system_probe = shutil.which("ffprobe")
|
|
if system_probe:
|
|
return system_probe
|
|
return None
|
|
|
|
|
|
def find_ffprobe():
|
|
"""Locate an ffprobe binary (legacy wrapper around :func:`resolve_ffprobe`).
|
|
|
|
Falls back to deriving the path from ``find_ffmpeg()`` so the
|
|
co-located ffprobe in an ffmpeg-bundle download (e.g. BtbN, evermeet.cx)
|
|
is still picked up when only ffmpeg has been resolved.
|
|
"""
|
|
resolved = resolve_ffprobe()
|
|
if resolved:
|
|
return resolved
|
|
try:
|
|
ffmpeg_path = find_ffmpeg()
|
|
if ffmpeg_path:
|
|
candidate = ffmpeg_path.replace("ffmpeg", "ffprobe")
|
|
if os.path.isfile(candidate):
|
|
return candidate
|
|
except Exception:
|
|
pass
|
|
return None
|
|
|
|
|
|
async def _spawn_with_retry(cmd, **kwargs):
|
|
"""Spawn a subprocess, retrying briefly on EAGAIN (posix_spawn resource pressure)."""
|
|
delay = 0.1
|
|
last_err = None
|
|
for _ in range(5):
|
|
try:
|
|
return await asyncio.create_subprocess_exec(*cmd, **kwargs)
|
|
except BlockingIOError as e:
|
|
last_err = e
|
|
if e.errno != errno.EAGAIN:
|
|
raise
|
|
await asyncio.sleep(delay)
|
|
delay *= 2
|
|
except OSError as e:
|
|
if e.errno == errno.EAGAIN:
|
|
last_err = e
|
|
await asyncio.sleep(delay)
|
|
delay *= 2
|
|
continue
|
|
raise
|
|
raise last_err if last_err else RuntimeError("spawn failed")
|
|
|
|
|
|
async def run_ffmpeg(cmd, timeout: float = 1800.0, capture: bool = True):
|
|
"""Run an ffmpeg subprocess with concurrency cap, timeout, and proper cleanup.
|
|
|
|
Returns (returncode, stdout_bytes, stderr_bytes). Raises asyncio.TimeoutError
|
|
on hard timeout (after killing + reaping the process).
|
|
"""
|
|
stdout = asyncio.subprocess.PIPE if capture else asyncio.subprocess.DEVNULL
|
|
stderr = asyncio.subprocess.PIPE
|
|
async with _get_semaphore():
|
|
proc = await _spawn_with_retry(cmd, stdout=stdout, stderr=stderr)
|
|
try:
|
|
try:
|
|
out, err = await asyncio.wait_for(proc.communicate(), timeout=timeout)
|
|
except asyncio.TimeoutError:
|
|
try:
|
|
proc.kill()
|
|
except ProcessLookupError:
|
|
pass
|
|
try:
|
|
await asyncio.wait_for(proc.wait(), timeout=5.0)
|
|
except asyncio.TimeoutError:
|
|
pass
|
|
raise
|
|
return proc.returncode, out, err
|
|
finally:
|
|
# Guarantee reaping — prevents zombie pileup under timeouts or errors.
|
|
if proc.returncode is None:
|
|
try:
|
|
proc.kill()
|
|
except ProcessLookupError:
|
|
pass
|
|
try:
|
|
await asyncio.wait_for(proc.wait(), timeout=5.0)
|
|
except asyncio.TimeoutError:
|
|
pass
|