OmniVoice-Studio / backend /core /gatekeeper_detect.py
LΓͺ Phi Nam
Deploy to HF Space
94004a2
Raw
History Blame Contribute Delete
4.6 kB
"""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,
}