* 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>
127 lines
4.5 KiB
Python
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,
|
|
}
|