Files
VoiceStudio/backend/core/gatekeeper_detect.py
T
Palash DebnathandClaude Opus 4.7 c32041289d Phase 1 Wave 3: AppImage launcher + .deb ffprobe + Docker LAN + Gatekeeper probe (closes #54, #56, #76, #80) (#93)
* 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>
2026-05-20 05:53:15 +05:30

127 lines
4.5 KiB
Python

"""macOS Gatekeeper quarantine detection.
Issue #54 — When a user downloads the .dmg/.zip outside the Mac App Store,
macOS Gatekeeper attaches an `com.apple.quarantine` extended attribute to the
.app bundle. On first launch, that attribute propagates to every binary inside
the bundle, and Gatekeeper refuses to exec any of them. The user sees
"OmniVoice Studio can't be opened" or the app crashes silently — both bad UX.
The fix is documented in Phase 1 Wave 2's `docs/install/macos-gatekeeper.md`
(shipped by Plan 01-02 — Wave 2 scope) and reachable via the React
ErrorBoundary's deeplink button when this module flags the bundle as
quarantined.
This module is **detection only**. It never runs `xattr -cr` to clear the
quarantine — the app cannot fix its own quarantine state (the very process
calling `xattr -cr` would itself be quarantined and refused exec). Surface
the workaround to the user; they run `xattr -cr /Applications/OmniVoice\\ Studio.app`
from Terminal once, and the next launch succeeds.
Plan: 01-03-PLAN.md (Phase 1 Wave 3)
"""
from __future__ import annotations
import logging
import os
import subprocess
import sys
from typing import Optional
logger = logging.getLogger("omnivoice.gatekeeper")
#: Structured error class emitted on quarantine detection. The React
#: ErrorBoundary (wired in Plan 01-02) matches on this exact string to choose
#: the right docs deeplink.
ERROR_CLASS = "GATEKEEPER_QUARANTINE"
def _resolve_app_bundle_path() -> Optional[str]:
"""Walk up from ``sys.executable`` until we find a ``.app`` directory.
When OmniVoice is launched from an installed .app bundle, Python runs from
`OmniVoice Studio.app/Contents/Resources/.venv/bin/python` (or similar),
so ``sys.executable`` is several levels below the bundle root.
Returns the .app path (e.g. ``/Applications/OmniVoice Studio.app``) or
``None`` for dev runs where we are not inside a bundle.
"""
if sys.platform != "darwin":
return None
path = os.path.realpath(sys.executable)
# Bound the walk so a pathological symlink loop cannot hang us.
for _ in range(20):
if path.endswith(".app"):
return path
parent = os.path.dirname(path)
if parent == path:
return None
path = parent
return None
def is_app_quarantined(bundle_path: Optional[str] = None) -> bool:
"""Return True if the running .app bundle has the quarantine xattr.
Parameters
----------
bundle_path
Override the detected bundle path. Mainly for testing — production
callers should pass nothing and let :func:`_resolve_app_bundle_path`
find the bundle via ``sys.executable``.
Returns
-------
bool
- False on non-macOS platforms (no Gatekeeper exists).
- False on dev runs where we are not inside a .app bundle.
- False when ``xattr`` is missing or errors (safe default — we'd
rather miss a quarantine warning than crash a startup probe).
- True when ``xattr -l`` lists ``com.apple.quarantine`` on the bundle.
"""
if sys.platform != "darwin":
return False
bundle = bundle_path if bundle_path is not None else _resolve_app_bundle_path()
if not bundle:
return False
try:
result = subprocess.run(
["xattr", "-l", bundle],
capture_output=True,
text=True,
timeout=5,
check=False,
)
except FileNotFoundError:
# No xattr binary on PATH — extremely unlikely on macOS, but be safe.
logger.debug("xattr binary not found; cannot probe quarantine state")
return False
except subprocess.TimeoutExpired:
logger.warning("xattr probe timed out after 5s")
return False
except OSError as e:
logger.warning("xattr probe failed: %s", e)
return False
return "com.apple.quarantine" in (result.stdout or "")
def quarantine_status() -> dict:
"""Return a dict describing the bundle's quarantine state.
Shape:
{"quarantined": bool, "bundle_path": str | None, "error_class": str | None}
``error_class`` is :data:`ERROR_CLASS` when ``quarantined`` is True, else
None. The React frontend polls ``/system/quarantine-status`` and renders
the docs deeplink when ``error_class`` is set.
"""
bundle = _resolve_app_bundle_path()
quarantined = is_app_quarantined(bundle)
return {
"quarantined": quarantined,
"bundle_path": bundle,
"error_class": ERROR_CLASS if quarantined else None,
}