"""Desktop (Electron) app: build/stamp, stage-and-swap pack, exe integrity gate, macOS signing/TCC, Linux sandbox, launch (hermes gui/desktop). Split out of ``hermes_cli/main.py``; every moved name is re-imported there, so ``hermes_cli.main.`` keeps resolving (and monkeypatching) as before. Names that stay in main are imported lazily inside the functions that use them (call-time resolution keeps ``hermes_cli.main.`` patches effective and avoids an import cycle). """ import logging import argparse import hashlib import json import os import re import shlex import shutil import stat import subprocess import sys import tempfile import time as _time_mod from pathlib import Path from typing import Optional from hermes_cli.main_tui_launch import _npm_lifecycle_env from hermes_cli.main_web_build import _nixos_build_env # Log-record parity with the origin module. logger = logging.getLogger("hermes_cli.main") def _desktop_dist_exists(desktop_dir: Path) -> bool: """Return True when a local desktop renderer build is present.""" return (desktop_dir / "dist" / "index.html").exists() def _compute_desktop_content_hash(project_root: Path) -> str: """Return a SHA-256 hex digest of all source files that feed the desktop build. Covers ``apps/desktop/`` (excluding anything matched by .gitignore) plus the root ``package.json`` / ``package-lock.json`` (workspace config that determines dependency resolution for the desktop workspace). Parses the repo-root ``.gitignore`` via *pathspec* so we automatically skip ``node_modules/``, ``dist/``, ``*.pyc``, etc. without maintaining a hardcoded skip-list. """ h = hashlib.sha256() def _hash_file(path: Path) -> None: rel = str(path.relative_to(project_root)) h.update(rel.encode()) h.update(b"\0") try: with open(path, "rb") as f: for chunk in iter(lambda: f.read(65536), b""): h.update(chunk) except (OSError, IOError): pass h.update(b"\0") from pathspec import PathSpec gitignore = project_root / ".gitignore" lines: list[str] = [] if gitignore.is_file(): lines = gitignore.read_text(encoding="utf-8").splitlines() spec = PathSpec.from_lines("gitignore", lines) # Root workspace config for name in ("package.json", "package-lock.json"): p = project_root / name if p.is_file(): rel = str(p.relative_to(project_root)) if not spec.match_file(rel): _hash_file(p) # Walk apps/desktop/ — prune ignored directories in-place desktop_dir = project_root / "apps" / "desktop" for dirpath, dirnames, filenames in os.walk(desktop_dir, topdown=True): # Prune ignored directories so we never descend into them dirnames[:] = [ d for d in dirnames if not spec.match_file(str((Path(dirpath) / d).relative_to(project_root))) ] for fn in sorted(filenames): fp = Path(dirpath) / fn rel = str(fp.relative_to(project_root)) if not spec.match_file(rel): _hash_file(fp) return h.hexdigest() def _desktop_stamp_path() -> Path: """Return the path to the desktop build stamp file under $HERMES_HOME.""" from hermes_constants import get_hermes_home return get_hermes_home() / "desktop-build-stamp.json" def _renderer_bundle_dir(desktop_dir: Path, *, source_mode: bool) -> Optional[Path]: """The renderer ``dist`` directory a launch loads, when it is inspectable. Source mode builds to ``apps/desktop/dist``. A packaged app ships the same bundle twice — inside ``app.asar`` and, because ``asarUnpack`` lists ``dist/**``, beside it in ``app.asar.unpacked``. Only the unpacked copy is a real directory; that is also the one an interrupted replace tears, so checking it catches the failure we care about. """ from hermes_cli.main import _desktop_packaged_executable if source_mode: return desktop_dir / "dist" executable = _desktop_packaged_executable(desktop_dir) if executable is None: return None # macOS: …/Hermes.app/Contents/MacOS/Hermes → …/Contents/Resources resources = ( executable.parent.parent / "Resources" if sys.platform == "darwin" else executable.parent / "resources" ) return resources / "app.asar.unpacked" / "dist" # The module files the renderer fetches before any app code runs: Vite emits # them as `