Spaces:
Sleeping
Sleeping
| """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, | |
| } | |