2070 lines
90 KiB
Python
2070 lines
90 KiB
Python
"""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.<name>`` 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.<name>`` 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 `<script type="module" src>` plus `<link rel="modulepreload" href>`.
|
|
_HTML_TAG_WITH_URL = re.compile(r"""<(?:script|link)\b[^>]*\b(?:src|href)=["']([^"']+)["'][^>]*>""", re.IGNORECASE)
|
|
|
|
|
|
_MODULE_TAG = re.compile(r"""\btype=["']module["']|\brel=["']modulepreload["']""", re.IGNORECASE)
|
|
|
|
|
|
def _renderer_bundle_torn(dist_dir: Path) -> bool:
|
|
"""True when ``index.html`` names hashed module files that aren't there.
|
|
|
|
``index.html`` and the hashed chunks under ``assets/`` are ONE generation.
|
|
An update that replaces the app while its files are locked (antivirus, a
|
|
still-running instance, an interrupted Windows replace) can leave the two
|
|
behind from different generations. The app then launches and dies on the
|
|
first lazy import with ``Failed to fetch dynamically imported module:
|
|
…/assets/<chunk>-<hash>.js`` — and because the content stamp still matches
|
|
the intact SOURCE tree, ``hermes desktop`` skips the rebuild that would fix
|
|
it, so every relaunch reproduces the crash and reinstalling looks like the
|
|
only way out. Detecting the tear turns it into a normal rebuild.
|
|
|
|
Conservative: an unreadable index, or one naming nothing checkable, is NOT
|
|
reported as torn — the missing-bundle guards own those cases.
|
|
"""
|
|
try:
|
|
html = (dist_dir / "index.html").read_text(encoding="utf-8", errors="replace")
|
|
except OSError:
|
|
return False
|
|
|
|
for match in _HTML_TAG_WITH_URL.finditer(html):
|
|
href = match.group(1)
|
|
# Absolute/CDN URLs aren't part of this bundle's generation.
|
|
if not _MODULE_TAG.search(match.group(0)) or re.match(r"^[a-z]+:|^//", href, re.IGNORECASE):
|
|
continue
|
|
rel = href.split("?", 1)[0].split("#", 1)[0].lstrip("./")
|
|
if rel and not (dist_dir / rel).exists():
|
|
return True
|
|
|
|
return False
|
|
|
|
|
|
def _desktop_build_needed(desktop_dir: Path, project_root: Path, *, source_mode: bool) -> bool:
|
|
"""Return True when the desktop build output is stale, missing, or torn.
|
|
|
|
Compares the current content hash against the saved stamp. Also returns
|
|
True if the expected build artifact doesn't exist (e.g. first run after
|
|
``hermes update`` that pulled new source but hasn't built yet).
|
|
"""
|
|
from hermes_cli.main import _desktop_dist_exists, _desktop_packaged_executable, _desktop_stamp_path
|
|
# If there's no build output at all, we definitely need to build
|
|
if source_mode:
|
|
if not _desktop_dist_exists(desktop_dir):
|
|
return True
|
|
else:
|
|
if _desktop_packaged_executable(desktop_dir) is None:
|
|
return True
|
|
|
|
# A torn renderer bundle is stale no matter what the stamp says: the hash
|
|
# describes the SOURCE tree, which is intact, while the built output is the
|
|
# half-replaced one that crashes on its first lazy import.
|
|
dist_dir = _renderer_bundle_dir(desktop_dir, source_mode=source_mode)
|
|
if dist_dir is not None and _renderer_bundle_torn(dist_dir):
|
|
print(f" ⚠ A previous update left the desktop bundle incomplete ({dist_dir}); rebuilding it")
|
|
return True
|
|
|
|
stamp_file = _desktop_stamp_path()
|
|
if not stamp_file.is_file():
|
|
return True
|
|
|
|
try:
|
|
stamp_data = json.loads(stamp_file.read_text(encoding="utf-8"))
|
|
except (OSError, json.JSONDecodeError, KeyError):
|
|
return True
|
|
|
|
# If the mode changed (source vs packaged), force a rebuild
|
|
if stamp_data.get("sourceMode") != source_mode:
|
|
return True
|
|
|
|
saved_hash = stamp_data.get("contentHash")
|
|
if not saved_hash:
|
|
return True
|
|
|
|
current_hash = _compute_desktop_content_hash(project_root)
|
|
return current_hash != saved_hash
|
|
|
|
|
|
def _write_desktop_build_stamp(project_root: Path, *, source_mode: bool) -> None:
|
|
"""Write the desktop build stamp after a successful build."""
|
|
from hermes_cli.main import _desktop_stamp_path
|
|
stamp_file = _desktop_stamp_path()
|
|
try:
|
|
stamp_file.parent.mkdir(parents=True, exist_ok=True)
|
|
content_hash = _compute_desktop_content_hash(project_root)
|
|
from datetime import datetime, timezone
|
|
stamp_data = {
|
|
"contentHash": content_hash,
|
|
"sourceMode": source_mode,
|
|
"builtAt": datetime.now(timezone.utc).isoformat(),
|
|
}
|
|
stamp_file.write_text(json.dumps(stamp_data, indent=2) + "\n", encoding="utf-8")
|
|
except Exception as exc:
|
|
# Never let stamp-writing block or fail a build
|
|
logger.debug("Failed to write desktop build stamp: %s", exc)
|
|
|
|
|
|
def _desktop_packaged_executable(desktop_dir: Path) -> Optional[Path]:
|
|
"""Return the current platform's unpacked Electron app executable."""
|
|
return _desktop_packaged_executable_in(desktop_dir / "release")
|
|
|
|
|
|
def _desktop_packaged_executable_in(release_dir: Path) -> Optional[Path]:
|
|
"""Return the unpacked Electron app executable under *release_dir*.
|
|
|
|
*release_dir* is electron-builder's ``directories.output`` — the live
|
|
``apps/desktop/release`` or a stage-and-swap staging dir (#86443).
|
|
"""
|
|
from hermes_cli.main import _expected_windows_pe_machines
|
|
if sys.platform == "darwin":
|
|
candidates = list(release_dir.glob("mac*/Hermes.app/Contents/MacOS/Hermes"))
|
|
elif sys.platform == "win32":
|
|
candidates = [
|
|
release_dir / "win-unpacked" / "Hermes.exe",
|
|
release_dir / "win-ia32-unpacked" / "Hermes.exe",
|
|
release_dir / "win-arm64-unpacked" / "Hermes.exe",
|
|
]
|
|
else:
|
|
candidates = [
|
|
release_dir / "linux-unpacked" / "hermes",
|
|
release_dir / "linux-unpacked" / "Hermes",
|
|
release_dir / "linux-arm64-unpacked" / "hermes",
|
|
release_dir / "linux-arm64-unpacked" / "Hermes",
|
|
]
|
|
|
|
existing = [p for p in candidates if p.exists()]
|
|
if not existing:
|
|
return None
|
|
if sys.platform == "win32" and len(existing) > 1:
|
|
# Multiple unpacked trees can coexist (e.g. a stale win-arm64-unpacked
|
|
# left behind by a cross-arch experiment next to the real win-unpacked).
|
|
# Picking purely by mtime can then hand a wrong-architecture Hermes.exe
|
|
# to the launcher, which Windows rejects with "This app can't run on
|
|
# your computer" (#69179). Prefer candidates whose PE machine field
|
|
# matches the host; fall back to mtime when none can be parsed.
|
|
expected = _expected_windows_pe_machines()
|
|
matching = [p for p in existing if _pe_machine_or_none(p) in expected]
|
|
if matching:
|
|
existing = matching
|
|
return max(existing, key=lambda p: p.stat().st_mtime)
|
|
|
|
|
|
_DESKTOP_STAGING_PREFIX = ".staging-"
|
|
|
|
|
|
_DESKTOP_PREVIOUS_SUFFIX = ".previous"
|
|
|
|
|
|
def _desktop_staging_dir(desktop_dir: Path) -> Path:
|
|
"""Fresh, unique staging output dir: ``apps/desktop/.staging-<pid>-<ts>``.
|
|
|
|
A sibling of ``release/`` (same filesystem → the swap is a rename, not a
|
|
copy) but NOT inside it, so nothing globbing ``release/*-unpacked`` or
|
|
``release/mac*`` can mistake the half-built tree for the live app.
|
|
Leftovers from a killed earlier build are swept first (best-effort).
|
|
"""
|
|
for stale in desktop_dir.glob(f"{_DESKTOP_STAGING_PREFIX}*"):
|
|
shutil.rmtree(stale, ignore_errors=True)
|
|
return desktop_dir / f"{_DESKTOP_STAGING_PREFIX}{os.getpid()}-{int(_time_mod.time())}"
|
|
|
|
|
|
def _desktop_unpacked_root(exe: Path, release_dir: Path) -> Path:
|
|
"""The directory directly under *release_dir* that holds *exe*
|
|
(``linux-unpacked``, ``win-unpacked``, ``mac-arm64``…) — electron-builder's
|
|
``appOutDir``, the unit that gets swapped as a whole."""
|
|
unpacked = exe
|
|
while unpacked.parent != release_dir:
|
|
if unpacked.parent == unpacked:
|
|
raise ValueError(f"{exe} is not under {release_dir}")
|
|
unpacked = unpacked.parent
|
|
return unpacked
|
|
|
|
|
|
def _swap_staged_desktop_app(desktop_dir: Path, staging_dir: Path) -> Optional[Path]:
|
|
"""Promote a VERIFIED staged pack over the live ``release/`` app.
|
|
|
|
``release/<unpacked>`` → ``release/<unpacked>.previous``,
|
|
``<staging>/<unpacked>`` → ``release/<unpacked>``, then drop ``.previous``.
|
|
Two renames; the only window with no live app is between them, and a
|
|
failure there rolls ``.previous`` back. Returns the live executable, or
|
|
``None`` (live app untouched or restored) when the swap could not happen.
|
|
Best-effort cleanup of the staging dir; never raises.
|
|
"""
|
|
staged_exe = _desktop_packaged_executable_in(staging_dir)
|
|
if staged_exe is None:
|
|
shutil.rmtree(staging_dir, ignore_errors=True)
|
|
return None
|
|
release_dir = desktop_dir / "release"
|
|
try:
|
|
staged_root = _desktop_unpacked_root(staged_exe, staging_dir)
|
|
live_root = release_dir / staged_root.name
|
|
previous = release_dir / (staged_root.name + _DESKTOP_PREVIOUS_SUFFIX)
|
|
release_dir.mkdir(parents=True, exist_ok=True)
|
|
shutil.rmtree(previous, ignore_errors=True)
|
|
moved_aside = False
|
|
if live_root.exists():
|
|
os.rename(live_root, previous)
|
|
moved_aside = True
|
|
try:
|
|
os.rename(staged_root, live_root)
|
|
except OSError:
|
|
if moved_aside:
|
|
os.rename(previous, live_root) # restore; live app back as it was
|
|
raise
|
|
if moved_aside:
|
|
shutil.rmtree(previous, ignore_errors=True)
|
|
except (OSError, ValueError) as exc:
|
|
logger.warning("desktop stage-and-swap failed, live app kept: %s", exc)
|
|
return None
|
|
finally:
|
|
shutil.rmtree(staging_dir, ignore_errors=True)
|
|
return live_root / staged_exe.relative_to(staged_root)
|
|
|
|
|
|
def _discard_desktop_staging(staging_dir: Path) -> None:
|
|
shutil.rmtree(staging_dir, ignore_errors=True)
|
|
|
|
|
|
_PE_MACHINE_I386 = 0x014C
|
|
|
|
|
|
_PE_MACHINE_AMD64 = 0x8664
|
|
|
|
|
|
_PE_MACHINE_ARM64 = 0xAA64
|
|
|
|
|
|
_PE_MACHINE_NAMES = {
|
|
_PE_MACHINE_I386: "x86 (32-bit)",
|
|
_PE_MACHINE_AMD64: "x64 (AMD64)",
|
|
_PE_MACHINE_ARM64: "ARM64",
|
|
}
|
|
|
|
|
|
_PE_MACHINE_TO_NAME = {
|
|
_PE_MACHINE_ARM64: "ARM64",
|
|
_PE_MACHINE_AMD64: "AMD64",
|
|
_PE_MACHINE_I386: "X86",
|
|
}
|
|
|
|
|
|
# MACHINE_ATTRIBUTES bits (processthreadsapi.h). UserEnabled means the host
|
|
# can run user-mode code of that machine type — natively or under emulation.
|
|
_MACHINE_ATTRIBUTE_USER_ENABLED = 0x00000001
|
|
|
|
|
|
def _windows_native_machine_from_iswow64() -> Optional[str]:
|
|
"""Ask IsWow64Process2 for the OS-native machine (None if unavailable/fail).
|
|
|
|
ctypes defaults ``GetCurrentProcess``'s restype to ``c_int``, so the
|
|
current-process pseudo-handle ``(HANDLE)-1`` is truncated to
|
|
``0xFFFFFFFF`` and zero-extended into a 64-bit invalid handle. On Win64
|
|
that makes ``IsWow64Process2`` fail with ``ERROR_INVALID_HANDLE`` (6),
|
|
which is exactly the residual Windows-on-ARM failure after #71218: the
|
|
gate fell through to ``PROCESSOR_ARCHITECTURE=AMD64`` (the emulated
|
|
process arch) and rejected a correctly-built ARM64 ``Hermes.exe``.
|
|
Binding ``restype``/``argtypes`` to ``wintypes.HANDLE`` keeps the full
|
|
``0xFFFFFFFFFFFFFFFF`` pseudo-handle.
|
|
"""
|
|
import ctypes
|
|
from ctypes import wintypes
|
|
|
|
kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
|
|
kernel32.GetCurrentProcess.restype = wintypes.HANDLE
|
|
kernel32.GetCurrentProcess.argtypes = []
|
|
kernel32.IsWow64Process2.argtypes = [
|
|
wintypes.HANDLE,
|
|
ctypes.POINTER(wintypes.USHORT),
|
|
ctypes.POINTER(wintypes.USHORT),
|
|
]
|
|
kernel32.IsWow64Process2.restype = wintypes.BOOL
|
|
|
|
process_machine = wintypes.USHORT(0)
|
|
native_machine = wintypes.USHORT(0)
|
|
if not kernel32.IsWow64Process2(
|
|
kernel32.GetCurrentProcess(),
|
|
ctypes.byref(process_machine),
|
|
ctypes.byref(native_machine),
|
|
):
|
|
return None
|
|
return _PE_MACHINE_TO_NAME.get(native_machine.value)
|
|
|
|
|
|
def _windows_user_runnable_pe_machines() -> Optional[set]:
|
|
"""PE machines this host can run in user mode, via GetMachineTypeAttributes.
|
|
|
|
This asks the question the integrity gate actually cares about — "can this
|
|
Windows host load a PE of machine X?" — instead of inferring it from a
|
|
host-architecture name. It is also the only documented API that reports
|
|
AMD64-on-ARM64 emulation support; ``IsWow64GuestMachineSupported`` only
|
|
answers for 32-bit guests.
|
|
|
|
Returns None when the API is unavailable (pre-Windows-11 build 22000) or
|
|
reports nothing runnable, so callers fall back to name-based detection.
|
|
"""
|
|
import ctypes
|
|
from ctypes import wintypes
|
|
|
|
kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
|
|
kernel32.GetMachineTypeAttributes.argtypes = [
|
|
wintypes.USHORT,
|
|
ctypes.POINTER(ctypes.c_int),
|
|
]
|
|
kernel32.GetMachineTypeAttributes.restype = ctypes.c_long
|
|
|
|
runnable = set()
|
|
for machine in (_PE_MACHINE_ARM64, _PE_MACHINE_AMD64, _PE_MACHINE_I386):
|
|
attributes = ctypes.c_int(0)
|
|
# HRESULT: zero is success, any nonzero value is a failure.
|
|
if kernel32.GetMachineTypeAttributes(machine, ctypes.byref(attributes)):
|
|
continue
|
|
if attributes.value & _MACHINE_ATTRIBUTE_USER_ENABLED:
|
|
runnable.add(machine)
|
|
return runnable or None
|
|
|
|
|
|
def _windows_native_machine() -> str:
|
|
"""The Windows host OS's NATIVE machine architecture, normalized upper.
|
|
|
|
``platform.machine()`` reports the PROCESS architecture, which lies under
|
|
emulation: the desktop update chain runs an x64 hermes-setup.exe (and thus
|
|
x64 Python) on Windows-on-ARM devices, where ``platform.machine()``
|
|
returns ``AMD64`` even though the OS is ARM64. The #71119 integrity gate
|
|
then rejected the CORRECT ARM64 rebuild as an "architecture mismatch"
|
|
(#69179 follow-up report). Probe order:
|
|
|
|
1. ``IsWow64Process2`` with a correctly-typed current-process HANDLE
|
|
(#71218 + HANDLE-truncation fix). This is the only API that tells the
|
|
truth from an x64 process emulated on ARM64.
|
|
2. ``PROCESSOR_ARCHITEW6432`` / ``PROCESSOR_ARCHITECTURE`` — WOW64
|
|
(32-bit) hosts and pre-1511 Windows 10 without the newer API.
|
|
3. ``platform.machine()``.
|
|
|
|
Note ``GetNativeSystemInfo`` is deliberately NOT used: Microsoft documents
|
|
that it "also returns emulated processor details when run from an app
|
|
under emulation", so on the very WoA hosts this function exists to serve
|
|
it reports AMD64 — no better than the env-var rung below it.
|
|
"""
|
|
if sys.platform == "win32":
|
|
try:
|
|
name = _windows_native_machine_from_iswow64()
|
|
except (OSError, AttributeError, TypeError, ValueError):
|
|
# API missing (pre-1511), DLL load failure in tests, or a
|
|
# mistyped ctypes binding — fall through to the env vars.
|
|
name = None
|
|
if name:
|
|
return name
|
|
env_arch = os.environ.get("PROCESSOR_ARCHITEW6432") or os.environ.get(
|
|
"PROCESSOR_ARCHITECTURE"
|
|
)
|
|
if env_arch:
|
|
return env_arch.upper()
|
|
import platform as _platform
|
|
|
|
return (_platform.machine() or "").upper()
|
|
|
|
|
|
def _expected_windows_pe_machines() -> set:
|
|
"""PE machine values the current Windows host can natively load.
|
|
|
|
Preferred source is ``GetMachineTypeAttributes``, which answers this
|
|
question directly (including AMD64-on-ARM64 emulation) instead of
|
|
inferring it from an architecture name.
|
|
|
|
Fallback is name-based: AMD64 hosts run x64 and (via WOW64) x86. ARM64
|
|
hosts run ARM64 and (Windows 11 emulation) x64. 32-bit x86 hosts run only
|
|
x86. Unknown machines return the permissive full set so the integrity gate
|
|
can never brick launch on exotic hosts. Host detection uses the OS-native
|
|
machine (see ``_windows_native_machine``), not the process architecture.
|
|
"""
|
|
from hermes_cli.main import _windows_native_machine
|
|
if sys.platform == "win32":
|
|
try:
|
|
runnable = _windows_user_runnable_pe_machines()
|
|
except (OSError, AttributeError, TypeError, ValueError):
|
|
runnable = None
|
|
if runnable:
|
|
return runnable
|
|
machine = _windows_native_machine().upper()
|
|
if machine in ("AMD64", "X86_64", "X64"):
|
|
return {_PE_MACHINE_AMD64, _PE_MACHINE_I386}
|
|
if machine in ("ARM64", "AARCH64"):
|
|
return {_PE_MACHINE_ARM64, _PE_MACHINE_AMD64}
|
|
if machine in ("X86", "I386", "I486", "I586", "I686"):
|
|
return {_PE_MACHINE_I386}
|
|
return {_PE_MACHINE_AMD64, _PE_MACHINE_ARM64, _PE_MACHINE_I386}
|
|
|
|
|
|
def _parse_pe_machine(path: Path) -> int:
|
|
"""Parse ``path`` as a PE executable and return its COFF machine field.
|
|
|
|
Raises ``ValueError`` with a human-readable reason when the file is not a
|
|
structurally complete PE: missing MZ/PE magic (an HTML error page or JSON
|
|
body saved as .exe), header truncation, or raw section data extending past
|
|
the end of the file (the truncated-download / interrupted-extraction
|
|
shape). Purely a header walk — cheap even on a 200 MB Electron exe.
|
|
"""
|
|
import struct
|
|
|
|
try:
|
|
file_size = path.stat().st_size
|
|
except OSError as exc:
|
|
raise ValueError(f"unreadable: {exc}")
|
|
if file_size < 512:
|
|
raise ValueError(
|
|
f"file is only {file_size} bytes — far too small to be a Windows executable"
|
|
)
|
|
with path.open("rb") as fh:
|
|
head = fh.read(64)
|
|
if len(head) < 64 or head[:2] != b"MZ":
|
|
raise ValueError(
|
|
"missing MZ header — not a Windows executable "
|
|
"(a truncated or non-binary file saved as .exe?)"
|
|
)
|
|
e_lfanew = struct.unpack_from("<I", head, 0x3C)[0]
|
|
if e_lfanew <= 0 or e_lfanew + 24 > file_size:
|
|
raise ValueError("corrupt DOS header: PE header offset points past end of file")
|
|
fh.seek(e_lfanew)
|
|
pe_head = fh.read(24)
|
|
if len(pe_head) < 24 or pe_head[:4] != b"PE\x00\x00":
|
|
raise ValueError("missing PE signature — corrupt executable header")
|
|
machine, n_sections = struct.unpack_from("<HH", pe_head, 4)
|
|
size_of_optional = struct.unpack_from("<H", pe_head, 20)[0]
|
|
fh.seek(e_lfanew + 24 + size_of_optional)
|
|
max_section_end = 0
|
|
for _ in range(n_sections):
|
|
section = fh.read(40)
|
|
if len(section) < 40:
|
|
raise ValueError("truncated PE section table")
|
|
size_of_raw, pointer_to_raw = struct.unpack_from("<II", section, 16)
|
|
max_section_end = max(max_section_end, pointer_to_raw + size_of_raw)
|
|
if file_size < max_section_end:
|
|
raise ValueError(
|
|
f"truncated executable: file is {file_size} bytes but its PE "
|
|
f"sections extend to {max_section_end} bytes"
|
|
)
|
|
return machine
|
|
|
|
|
|
def _pe_machine_or_none(path: Path) -> Optional[int]:
|
|
from hermes_cli.main import _parse_pe_machine
|
|
try:
|
|
return _parse_pe_machine(path)
|
|
except ValueError:
|
|
return None
|
|
|
|
|
|
def _desktop_exe_integrity_error(path: Path) -> Optional[str]:
|
|
"""Return a human-readable reason ``path`` cannot run on this Windows host,
|
|
or ``None`` when the exe parses as a complete PE of a loadable architecture.
|
|
"""
|
|
from hermes_cli.main import _expected_windows_pe_machines, _parse_pe_machine, _windows_native_machine
|
|
try:
|
|
machine = _parse_pe_machine(path)
|
|
except ValueError as exc:
|
|
return str(exc)
|
|
expected = _expected_windows_pe_machines()
|
|
if machine not in expected:
|
|
got = _PE_MACHINE_NAMES.get(machine, f"unknown machine 0x{machine:04X}")
|
|
return (
|
|
f"architecture mismatch: built a {got} executable but this is a "
|
|
f"{_windows_native_machine()} Windows host"
|
|
)
|
|
return None
|
|
|
|
|
|
def _desktop_backup_unpacked_dir(packaged_executable: Path) -> Path:
|
|
"""The rollback tree before-pack.mjs preserves: ``<unpacked-dir>.bak``."""
|
|
unpacked = packaged_executable.parent
|
|
return unpacked.parent / (unpacked.name + ".bak")
|
|
|
|
|
|
def _rollback_desktop_from_backup(packaged_executable: Path) -> Optional[Path]:
|
|
"""Restore the previous unpacked desktop app from its ``.bak`` tree.
|
|
|
|
Returns the restored executable path, or ``None`` when no usable backup
|
|
exists (missing, or its exe fails the same integrity probe). The corrupt
|
|
tree is kept alongside as ``<unpacked-dir>.corrupt`` for diagnostics.
|
|
Best-effort: never raises.
|
|
"""
|
|
unpacked = packaged_executable.parent
|
|
backup_dir = _desktop_backup_unpacked_dir(packaged_executable)
|
|
backup_exe = backup_dir / packaged_executable.name
|
|
if not backup_exe.exists():
|
|
return None
|
|
if _desktop_exe_integrity_error(backup_exe) is not None:
|
|
return None
|
|
corrupt_dir = unpacked.parent / (unpacked.name + ".corrupt")
|
|
try:
|
|
shutil.rmtree(corrupt_dir, ignore_errors=True)
|
|
try:
|
|
unpacked.rename(corrupt_dir)
|
|
except OSError:
|
|
shutil.rmtree(unpacked, ignore_errors=True)
|
|
backup_dir.rename(unpacked)
|
|
except OSError:
|
|
return None
|
|
restored = unpacked / packaged_executable.name
|
|
return restored if restored.exists() else None
|
|
|
|
|
|
def _ensure_desktop_exe_launchable(
|
|
desktop_dir: Path, packaged_executable: Optional[Path]
|
|
) -> tuple:
|
|
"""Windows post-build integrity gate for the self-update rebuild (#69179).
|
|
|
|
Returns ``(verified_exe_or_None, rolled_back)``:
|
|
|
|
- exe passed the probe → ``(exe, False)``
|
|
- exe corrupt/wrong-arch, previous build restored → ``(old_exe, True)``
|
|
- exe corrupt and nothing restorable → ``(None, False)``
|
|
|
|
On any integrity failure the corrupt cached Electron zip is purged and the
|
|
desktop build stamp invalidated, so the updater's retry-once rebuild pulls
|
|
a fresh, SHASUM-verified Electron download instead of re-staging the same
|
|
corrupt bytes. No-op off Windows and when there is no executable to check.
|
|
"""
|
|
from hermes_cli.main import _desktop_stamp_path, _purge_electron_build_cache, _rollback_desktop_from_backup
|
|
if packaged_executable is None or sys.platform != "win32":
|
|
return packaged_executable, False
|
|
|
|
error = _desktop_exe_integrity_error(packaged_executable)
|
|
if error is None:
|
|
return packaged_executable, False
|
|
|
|
print(f"✗ The built Hermes.exe failed its integrity check: {error}")
|
|
print(f" at: {packaged_executable}")
|
|
|
|
# Self-heal setup for the retry: drop the (likely corrupt) cached Electron
|
|
# zip and the content stamp so the next rebuild is a genuine re-download +
|
|
# re-stage rather than a replay of the same broken extraction. Only the
|
|
# exe's OWN output dir is purged (a stage-and-swap staging dir, #86443),
|
|
# never the live release/ tree that still holds the last working app.
|
|
_purge_electron_build_cache(desktop_dir, release_dir=packaged_executable.parent.parent)
|
|
try:
|
|
_desktop_stamp_path().unlink()
|
|
except OSError:
|
|
pass
|
|
|
|
restored = _rollback_desktop_from_backup(packaged_executable)
|
|
if restored is not None:
|
|
print(" ↩ Update aborted — restored the previous working Hermes.exe from backup.")
|
|
print(" Your existing version was kept and still works. Run `hermes desktop`")
|
|
print(" (or the in-app update) again to retry with a fresh Electron download.")
|
|
return restored, True
|
|
|
|
print(" ✗ No usable backup was found to restore.")
|
|
print(" Run `hermes desktop --force-build` to rebuild, or re-run the Hermes")
|
|
print(" installer to repair the install.")
|
|
return None, False
|
|
|
|
|
|
def _electron_download_cache_dirs() -> list[Path]:
|
|
"""Return the per-user Electron download cache directories for this OS.
|
|
|
|
electron-builder's ``app-builder unpack-electron`` extracts the Electron
|
|
distribution from a zip stored in this cache (NOT from node_modules), so a
|
|
corrupt zip here — not a bad workspace install — is what poisons the build.
|
|
Honors the ``electron_config_cache`` / ``ELECTRON_CACHE`` overrides that
|
|
``@electron/get`` respects, then falls back to the platform defaults.
|
|
"""
|
|
home = Path.home()
|
|
candidates: list[Path] = []
|
|
override = os.environ.get("electron_config_cache") or os.environ.get("ELECTRON_CACHE")
|
|
if override:
|
|
candidates.append(Path(override))
|
|
if sys.platform == "darwin":
|
|
candidates.append(home / "Library" / "Caches" / "electron")
|
|
elif sys.platform == "win32":
|
|
local = os.environ.get("LOCALAPPDATA")
|
|
if local:
|
|
candidates.append(Path(local) / "electron" / "Cache")
|
|
candidates.append(home / "AppData" / "Local" / "electron" / "Cache")
|
|
else:
|
|
xdg = os.environ.get("XDG_CACHE_HOME")
|
|
if xdg:
|
|
candidates.append(Path(xdg) / "electron")
|
|
candidates.append(home / ".cache" / "electron")
|
|
|
|
seen: set[Path] = set()
|
|
out: list[Path] = []
|
|
for c in candidates:
|
|
rc = c.expanduser()
|
|
if rc not in seen:
|
|
seen.add(rc)
|
|
out.append(rc)
|
|
return out
|
|
|
|
|
|
def _purge_electron_build_cache(
|
|
desktop_dir: Path, release_dir: Optional[Path] = None
|
|
) -> list[Path]:
|
|
"""Clear the cached Electron download + half-written unpacked dir so the
|
|
next ``pack`` re-downloads and re-stages from scratch.
|
|
|
|
Root cause of the ``ENOENT … rename '…/linux-unpacked/electron' ->
|
|
'…/linux-unpacked/Hermes'`` desktop build failure: a corrupt zip in the
|
|
per-user Electron download cache (a partial download resumed into the same
|
|
file leaves prepended/concatenated junk, or an interrupted write truncates
|
|
it). electron-builder's ``app-builder unpack-electron`` extracts the
|
|
distribution from that cached zip (NOT from node_modules); a bad zip yields
|
|
a partial tree MISSING the 193 MB ``electron`` binary, so the final rename
|
|
dies. Re-running repeats the same broken extraction forever.
|
|
|
|
We deliberately do NOT try to detect corruption ourselves. stdlib
|
|
``zipfile`` silently tolerates the prepended/concatenated junk that is the
|
|
most common corruption here — it reads from the end-of-central-directory
|
|
backward, so ``testzip()`` returns clean on exactly the zips ``unzip -t``
|
|
and ``@electron/get`` reject. Gating the purge on a self-rolled validator
|
|
would therefore skip the real-world case and never self-heal. Instead, on a
|
|
packaged-build failure we unconditionally remove the version's cached zips
|
|
and the stale unpacked dir, then let the caller retry once: ``@electron/get``
|
|
re-downloads with its own SHASUM verification (the real source of truth),
|
|
and ``before-pack.cjs`` re-wipes the unpacked dir. If the failure was
|
|
unrelated, a clean re-download is harmless and the retry fails the same way.
|
|
|
|
Best-effort: never raises. Returns the paths removed so the caller can log
|
|
them and decide whether a retry is worthwhile (empty list ⇒ nothing to
|
|
clear, so no point retrying).
|
|
"""
|
|
from hermes_cli.main import _electron_download_cache_dirs
|
|
removed: list[Path] = []
|
|
|
|
for cache_dir in _electron_download_cache_dirs():
|
|
if not cache_dir.is_dir():
|
|
continue
|
|
for zip_path in sorted(cache_dir.rglob("electron-*.zip")):
|
|
try:
|
|
zip_path.unlink()
|
|
removed.append(zip_path)
|
|
except OSError:
|
|
# Locked/permission-denied entry is out of our hands; let the
|
|
# build report its own error rather than masking it.
|
|
pass
|
|
|
|
# Drop the half-written unpacked dir too: an interrupted prior pack leaves
|
|
# a partial tree that poisons the rename even after the zip is fixed.
|
|
# (before-pack.cjs also handles this, but clearing it here makes the retry
|
|
# robust even if the hook is somehow skipped.) ``release_dir`` lets a
|
|
# stage-and-swap caller point this at its STAGING output so a mid-retry
|
|
# purge never touches the live app under ``release/`` (#86443).
|
|
if release_dir is None:
|
|
release_dir = desktop_dir / "release"
|
|
if release_dir.is_dir():
|
|
for unpacked in release_dir.glob("*-unpacked"):
|
|
try:
|
|
shutil.rmtree(unpacked, ignore_errors=True)
|
|
removed.append(unpacked)
|
|
except OSError:
|
|
pass
|
|
|
|
return removed
|
|
|
|
|
|
# Last-resort Electron mirror after GitHub download fails (#47266). Only used
|
|
# when the user hasn't pinned ELECTRON_MIRROR.
|
|
_ELECTRON_FALLBACK_MIRROR = "https://npmmirror.com/mirrors/electron/"
|
|
|
|
|
|
def _electron_dir(project_root: Path) -> Path:
|
|
"""Return the Electron package directory the desktop workspace installs.
|
|
|
|
npm may keep workspace-only dev dependencies under
|
|
``apps/desktop/node_modules`` instead of hoisting them to the repo root.
|
|
Which layout you get depends on the npm version and what else is installed,
|
|
so a build path that assumes one or the other breaks intermittently across
|
|
machines. ``apps/desktop/package.json`` points electron-builder's
|
|
``electronDist`` at ``node_modules/electron/dist`` relative to the desktop
|
|
project, so prefer the workspace-local package and fall back to the root
|
|
hoist when that's where npm landed it.
|
|
"""
|
|
desktop_local = project_root / "apps" / "desktop" / "node_modules" / "electron"
|
|
if desktop_local.exists():
|
|
return desktop_local
|
|
return project_root / "node_modules" / "electron"
|
|
|
|
|
|
def _electron_dist_binary(project_root: Path) -> Path:
|
|
"""Return the path to the Electron main binary inside the installed package.
|
|
|
|
electron-builder reads the binary from ``build.electronDist`` since #38673,
|
|
so this is the exact file whose absence makes a pack fail with "The
|
|
specified electronDist does not exist". The basename differs per OS (the
|
|
platform Electron is named for the host the build runs on).
|
|
"""
|
|
dist = _electron_dir(project_root) / "dist"
|
|
if sys.platform == "darwin":
|
|
return dist / "Electron.app" / "Contents" / "MacOS" / "Electron"
|
|
if sys.platform == "win32":
|
|
return dist / "electron.exe"
|
|
return dist / "electron"
|
|
|
|
|
|
def _electron_dist_ok(project_root: Path) -> bool:
|
|
"""True when ``node_modules/electron/dist`` holds a usable Electron binary.
|
|
|
|
A directory that exists but is missing the binary (a partial extraction from
|
|
a corrupt cached zip, or an interrupted postinstall) counts as NOT ok, since
|
|
that is exactly the shape that makes electron-builder throw on the pinned
|
|
electronDist.
|
|
"""
|
|
from hermes_cli.main import _electron_dist_binary
|
|
try:
|
|
return _electron_dist_binary(project_root).exists()
|
|
except OSError:
|
|
return False
|
|
|
|
|
|
def _electron_pkg_staged_missing_dist(project_root: Path) -> bool:
|
|
"""electron staged (package.json + install.js) but dist missing — blocked postinstall."""
|
|
from hermes_cli.main import _electron_dist_ok
|
|
electron_dir = _electron_dir(project_root)
|
|
return (
|
|
(electron_dir / "package.json").is_file()
|
|
and (electron_dir / "install.js").is_file()
|
|
and not _electron_dist_ok(project_root)
|
|
)
|
|
|
|
|
|
def _redownload_electron_dist(
|
|
project_root: Path,
|
|
env: dict,
|
|
*,
|
|
mirror: Optional[str] = None,
|
|
) -> bool:
|
|
"""Best-effort: run electron's install.js to populate dist/ (optional mirror)."""
|
|
from hermes_cli.main import _electron_dist_ok
|
|
if _electron_dist_ok(project_root):
|
|
return True
|
|
|
|
electron_dir = _electron_dir(project_root)
|
|
installer = electron_dir / "install.js"
|
|
if not installer.is_file():
|
|
return False
|
|
from hermes_constants import find_node_executable, with_hermes_node_path
|
|
|
|
node = find_node_executable("node")
|
|
if not node:
|
|
return False
|
|
|
|
dist_dir = electron_dir / "dist"
|
|
shutil.rmtree(dist_dir, ignore_errors=True)
|
|
try:
|
|
(electron_dir / "path.txt").unlink()
|
|
except OSError:
|
|
pass
|
|
|
|
dl_env = with_hermes_node_path(env)
|
|
if mirror:
|
|
dl_env["ELECTRON_MIRROR"] = mirror
|
|
try:
|
|
subprocess.run([node, str(installer)], cwd=str(electron_dir), env=dl_env, check=False)
|
|
except OSError:
|
|
return False
|
|
return _electron_dist_ok(project_root)
|
|
|
|
|
|
def _try_redownload_electron_dist(project_root: Path, env: dict) -> bool:
|
|
"""Canonical download, then fallback mirror unless the user pinned one."""
|
|
from hermes_cli.main import _redownload_electron_dist
|
|
if _redownload_electron_dist(project_root, env):
|
|
return True
|
|
if env.get("ELECTRON_MIRROR"):
|
|
return False
|
|
return _redownload_electron_dist(project_root, env, mirror=_ELECTRON_FALLBACK_MIRROR)
|
|
|
|
|
|
def _stop_desktop_processes_locking_build(desktop_dir: Path) -> list[int]:
|
|
"""Terminate any running desktop app executing from this build's ``release``
|
|
dir so a rebuild can replace its (otherwise locked) executable.
|
|
|
|
On Windows a running ``Hermes.exe`` keeps an exclusive lock on
|
|
``release/win-unpacked/Hermes.exe``. electron-builder's pack then can't
|
|
delete the stale binary and dies with ``remove …\\Hermes.exe: Access is
|
|
denied`` / ``ERR_ELECTRON_BUILDER_CANNOT_EXECUTE`` (before-pack hits the same
|
|
EPERM cleaning the dir). The retry path repeats the failure because the lock
|
|
is still held. POSIX lets you unlink a running binary, so this is a no-op
|
|
off-Windows.
|
|
|
|
Scope is deliberately narrow: only processes whose executable lives *inside*
|
|
this desktop's ``release`` tree are stopped — a packaged install elsewhere or
|
|
an unrelated "Hermes" process is never touched. Best-effort: never raises.
|
|
Returns the PIDs we asked to stop.
|
|
"""
|
|
if sys.platform != "win32":
|
|
return []
|
|
try:
|
|
import psutil
|
|
except Exception:
|
|
return []
|
|
try:
|
|
release_dir = (desktop_dir / "release").resolve()
|
|
except OSError:
|
|
return []
|
|
if not release_dir.is_dir():
|
|
return []
|
|
|
|
me = os.getpid()
|
|
victims = []
|
|
try:
|
|
proc_iter = psutil.process_iter(["pid", "exe"])
|
|
except Exception:
|
|
return []
|
|
for proc in proc_iter:
|
|
try:
|
|
info = proc.info
|
|
except Exception:
|
|
continue
|
|
pid = info.get("pid")
|
|
exe = info.get("exe")
|
|
if not exe or pid is None or pid == me:
|
|
continue
|
|
try:
|
|
exe_path = Path(exe).resolve()
|
|
except (OSError, ValueError):
|
|
continue
|
|
if release_dir in exe_path.parents:
|
|
victims.append(proc)
|
|
|
|
stopped: list[int] = []
|
|
for proc in victims:
|
|
try:
|
|
proc.terminate()
|
|
stopped.append(int(proc.pid))
|
|
except Exception:
|
|
continue
|
|
if stopped:
|
|
# Wait for the handles (and thus the file locks) to actually release.
|
|
try:
|
|
_, alive = psutil.wait_procs(victims, timeout=5)
|
|
for proc in alive:
|
|
try:
|
|
proc.kill()
|
|
except Exception:
|
|
continue
|
|
except Exception:
|
|
pass
|
|
return stopped
|
|
|
|
|
|
def _desktop_macos_bundle_id(bundle: Path) -> Optional[str]:
|
|
"""Return a bundle/framework CFBundleIdentifier for local macOS signing."""
|
|
import plistlib
|
|
|
|
info = bundle / "Contents" / "Info.plist"
|
|
if not info.exists() and bundle.suffix == ".framework":
|
|
candidates = list(bundle.glob("Versions/*/Resources/Info.plist")) + list(
|
|
bundle.glob("Resources/Info.plist")
|
|
)
|
|
if candidates:
|
|
info = candidates[0]
|
|
if not info.exists():
|
|
return None
|
|
try:
|
|
data = plistlib.loads(info.read_bytes())
|
|
except Exception:
|
|
return None
|
|
ident = data.get("CFBundleIdentifier")
|
|
return str(ident) if ident else None
|
|
|
|
|
|
def _desktop_macos_local_signing_identity() -> Optional[str]:
|
|
"""Return the opt-in keychain identity for local macOS desktop signing.
|
|
|
|
``desktop.macos_signing_identity`` in config.yaml names a persistent
|
|
code-signing certificate in the user's login keychain (a self-signed
|
|
"Code Signing" cert made in Keychain Access is enough — no Apple Developer
|
|
account needed). Signing with any identity gives the app a
|
|
certificate-anchored Designated Requirement, which is the strongest way to
|
|
keep macOS TCC grants (Full Disk Access, Accessibility, Automation, Files
|
|
and Folders) stable across local rebuilds. Empty/unset keeps the default
|
|
identifier-pinned ad-hoc signing.
|
|
"""
|
|
if sys.platform != "darwin":
|
|
return None
|
|
try:
|
|
from hermes_cli.config import load_config
|
|
|
|
desktop = load_config().get("desktop", {})
|
|
if not isinstance(desktop, dict):
|
|
return None
|
|
identity = desktop.get("macos_signing_identity")
|
|
if not isinstance(identity, str):
|
|
return None
|
|
return identity.strip() or None
|
|
except Exception as exc:
|
|
print(
|
|
" (warning: could not load desktop.macos_signing_identity: "
|
|
f"{exc}; falling back to ad-hoc signing)"
|
|
)
|
|
return None
|
|
|
|
|
|
def _desktop_macos_has_valid_real_signature(app: Path) -> bool:
|
|
"""True when the bundle carries an intact non-ad-hoc (Team ID) signature.
|
|
|
|
Used to make the relaunch fixup a no-op on properly signed/notarized
|
|
builds even when CSC_LINK / APPLE_SIGNING_IDENTITY aren't in the
|
|
environment (e.g. a release DMG install being repaired) — clobbering a
|
|
Developer ID signature with an ad-hoc one would reset TCC grants and can
|
|
break the hardened runtime. A *stale* real signature (in-place rebuild
|
|
tampered with the bundle) fails --verify and returns False so the fixup
|
|
can repair it.
|
|
"""
|
|
codesign = shutil.which("codesign")
|
|
if not codesign:
|
|
return False
|
|
try:
|
|
info = subprocess.run(
|
|
[codesign, "-dv", str(app)], check=False, capture_output=True, text=True
|
|
)
|
|
output = f"{info.stdout}\n{info.stderr}"
|
|
if info.returncode != 0 or "TeamIdentifier=" not in output \
|
|
or "TeamIdentifier=not set" in output:
|
|
return False
|
|
verify = subprocess.run(
|
|
[codesign, "--verify", "--deep", "--strict", str(app)],
|
|
check=False, capture_output=True,
|
|
)
|
|
return verify.returncode == 0
|
|
except Exception:
|
|
return False
|
|
|
|
|
|
def _desktop_macos_local_codesign(
|
|
app: Path, *, desktop_dir: Path, identity: str = "-"
|
|
) -> bool:
|
|
"""Re-sign a local Desktop build so macOS TCC grants survive rebuilds.
|
|
|
|
A plain ``codesign --deep --sign -`` leaves the bundle with a cdhash-only
|
|
Designated Requirement and strips electron-builder's entitlements. Every
|
|
rebuild changes the cdhash, so TCC (Full Disk Access, Accessibility,
|
|
Automation, Files and Folders: Desktop/Downloads/Documents, microphone)
|
|
treats the rebuilt app as different code and the user must re-grant
|
|
everything — and the lost entitlements break microphone/JIT under the
|
|
hardened runtime.
|
|
|
|
Instead, sign inside-out (standalone Mach-O binaries, then nested
|
|
frameworks/helper apps, then the main bundle), preserving the repo's
|
|
entitlement plists, and pin an explicit identifier-based Designated
|
|
Requirement when signing ad-hoc. With a real ``identity`` the certificate
|
|
anchors the DR, so no explicit requirement is needed. Raises on signing
|
|
failure; returns True after strict verification passes.
|
|
"""
|
|
codesign = shutil.which("codesign")
|
|
if not codesign:
|
|
return False
|
|
|
|
ent_main = desktop_dir / "electron" / "entitlements.mac.plist"
|
|
ent_inherit = desktop_dir / "electron" / "entitlements.mac.inherit.plist"
|
|
if not (ent_main.exists() and ent_inherit.exists()):
|
|
# Hardened-runtime restrictions are enforced even for ad-hoc
|
|
# signatures. Signing with --options runtime but WITHOUT the allow-jit
|
|
# entitlements would leave Electron/V8 crashing on launch — strictly
|
|
# worse than the legacy plain ad-hoc sign. Bail out so the caller
|
|
# falls back to that legacy path instead.
|
|
raise FileNotFoundError(
|
|
f"desktop entitlement plists missing under {desktop_dir / 'electron'}"
|
|
)
|
|
|
|
def sign_path(
|
|
path: Path,
|
|
*,
|
|
entitlements: Optional[Path] = None,
|
|
identifier: Optional[str] = None,
|
|
runtime: bool = True,
|
|
) -> None:
|
|
args = [codesign, "--force", "--sign", identity, "--timestamp=none"]
|
|
if runtime:
|
|
args += ["--options", "runtime"]
|
|
if entitlements is not None and entitlements.exists():
|
|
args += ["--entitlements", str(entitlements)]
|
|
if identifier and identity == "-":
|
|
# Ad-hoc signatures get a cdhash-only DR by default; pin an
|
|
# identifier-based DR so TCC has something stable to persist.
|
|
args += ["--requirements", f'=designated => identifier "{identifier}"']
|
|
args.append(str(path))
|
|
subprocess.run(args, check=True, capture_output=True)
|
|
|
|
# 1) Standalone Mach-O files (native modules, dylibs, crashpad handler).
|
|
# Compare paths relative to the app root — the absolute path always
|
|
# contains the outer Hermes.app component, so an absolute-parts check
|
|
# would skip every file.
|
|
contents = app / "Contents"
|
|
standalone: list[Path] = []
|
|
for root, _dirs, files in os.walk(contents):
|
|
root_path = Path(root)
|
|
rel_parts = root_path.relative_to(app).parts
|
|
if any(part.endswith(".app") for part in rel_parts):
|
|
continue # nested helper apps are signed as bundles below
|
|
for name in files:
|
|
fp = root_path / name
|
|
if name in {"chrome_crashpad_handler", "spawn-helper"} or fp.suffix in {
|
|
".node",
|
|
".dylib",
|
|
}:
|
|
standalone.append(fp)
|
|
for fp in sorted(standalone, key=lambda p: len(p.parts), reverse=True):
|
|
sign_path(fp, runtime=False)
|
|
|
|
# 2) Nested frameworks and helper apps, deepest first.
|
|
bundles: list[Path] = []
|
|
frameworks_dir = contents / "Frameworks"
|
|
if frameworks_dir.exists():
|
|
for root, _dirs, _files in os.walk(frameworks_dir):
|
|
p = Path(root)
|
|
if p.suffix in {".framework", ".app"}:
|
|
bundles.append(p)
|
|
for bundle in sorted(set(bundles), key=lambda p: len(p.parts), reverse=True):
|
|
ent = ent_inherit if bundle.suffix == ".app" and "Helper" in bundle.name else None
|
|
sign_path(bundle, entitlements=ent, identifier=_desktop_macos_bundle_id(bundle))
|
|
|
|
# 3) The main bundle, with the app's own entitlements.
|
|
sign_path(app, entitlements=ent_main, identifier=_desktop_macos_bundle_id(app))
|
|
subprocess.run(
|
|
[codesign, "--verify", "--deep", "--strict", str(app)],
|
|
check=True, capture_output=True,
|
|
)
|
|
return True
|
|
|
|
|
|
def _desktop_macos_relaunchable_fixup(
|
|
desktop_dir: Path,
|
|
*,
|
|
publisher_signing_configured: Optional[bool] = None,
|
|
release_dir: Optional[Path] = None,
|
|
) -> bool:
|
|
"""Make a locally-built macOS desktop app survive in-place self-update
|
|
without resetting the user's TCC permission grants.
|
|
|
|
An ad-hoc-signed .app has no stable Designated Requirement, so when the
|
|
self-updater rebuilds the bundle in place (new cdhash) Gatekeeper reports
|
|
"Hermes is damaged and can't be opened" — and macOS TCC forgets every
|
|
permission the user granted (Full Disk Access, Desktop/Downloads/Documents,
|
|
Accessibility, Automation, microphone), re-prompting on every launch after
|
|
every update.
|
|
|
|
Clear the quarantine xattrs, then re-sign with a stable identity:
|
|
``desktop.macos_signing_identity`` (a persistent keychain cert — strongest)
|
|
when configured, else ad-hoc with identifier-pinned Designated Requirements,
|
|
preserving the repo's entitlement plists either way. No-op when a real
|
|
publisher identity is configured (CSC_LINK / APPLE_SIGNING_IDENTITY) or the
|
|
bundle already carries an intact Developer ID signature, so a properly
|
|
signed/notarized build is never clobbered. Callers that already made the
|
|
publisher-signing decision may pass it explicitly so a later dotenv load
|
|
can't reverse it. Falls back to the legacy deep ad-hoc sign if the
|
|
entitlement-preserving path fails. Best-effort: never raises. Returns True
|
|
when no work was needed or signing + strict verification succeeded.
|
|
"""
|
|
from hermes_cli.main import _desktop_macos_has_valid_real_signature, _desktop_macos_local_codesign, _desktop_macos_local_signing_identity
|
|
if sys.platform != "darwin":
|
|
return True
|
|
if publisher_signing_configured is None:
|
|
publisher_signing_configured = bool(
|
|
os.environ.get("CSC_LINK") or os.environ.get("APPLE_SIGNING_IDENTITY")
|
|
)
|
|
if publisher_signing_configured:
|
|
return True
|
|
# ``release_dir`` (stage-and-swap, #86443): sign the STAGED bundle before
|
|
# it is promoted, so the live app is never touched mid-sign.
|
|
exe = _desktop_packaged_executable_in(release_dir or (desktop_dir / "release"))
|
|
if exe is None:
|
|
return True
|
|
# exe = .../Hermes.app/Contents/MacOS/Hermes -> app bundle = .../Hermes.app
|
|
app = exe.parents[2]
|
|
if not str(app).endswith(".app") or not app.is_dir():
|
|
return True
|
|
codesign = shutil.which("codesign")
|
|
if not codesign:
|
|
return False
|
|
if _desktop_macos_has_valid_real_signature(app):
|
|
return True
|
|
subprocess.run(["xattr", "-cr", str(app)], check=False)
|
|
identity = _desktop_macos_local_signing_identity() or "-"
|
|
try:
|
|
if _desktop_macos_local_codesign(app, desktop_dir=desktop_dir, identity=identity):
|
|
label = "keychain identity" if identity != "-" else "stable ad-hoc identity"
|
|
print(f" → macOS desktop signed with {label}; TCC grants persist across rebuilds")
|
|
return True
|
|
except Exception as exc:
|
|
if identity != "-":
|
|
print(
|
|
f" (warning: configured macOS signing identity failed: {identity!r}; "
|
|
"falling back to ad-hoc — TCC grants may need to be re-granted)"
|
|
)
|
|
print(f" (warning: stable macOS signing failed ({exc}); using legacy ad-hoc sign)")
|
|
try:
|
|
# Legacy ad-hoc fallback: re-sign, but NEVER delete the safeStorage
|
|
# keychain item. Deleting it would permanently orphan every
|
|
# credential encrypted under it (gateway token, native OAuth access/
|
|
# refresh tokens) — and this path is reached exactly when the
|
|
# entitlement-preserving signer failed, so there is no verified
|
|
# successor identity to hand the key to. The keychain prompt macOS
|
|
# shows instead is recoverable ("Always Allow" updates the item's ACL
|
|
# partition list and preserves the key); deletion is not. The real
|
|
# fix (proof-carrying rotation/migration) belongs in Electron, where
|
|
# safeStorage can read the old key. Tracked as follow-up.
|
|
result = subprocess.run(
|
|
[codesign, "--force", "--deep", "--sign", "-", str(app)],
|
|
check=False, capture_output=True, text=True,
|
|
)
|
|
if result.returncode != 0:
|
|
print(
|
|
f" (warning: legacy ad-hoc re-sign failed (exit {result.returncode}); "
|
|
"leaving safeStorage keychain item untouched)"
|
|
)
|
|
return False
|
|
verify = subprocess.run(
|
|
[codesign, "--verify", "--deep", "--strict", str(app)],
|
|
check=False, capture_output=True, text=True,
|
|
)
|
|
if verify.returncode != 0:
|
|
print(
|
|
f" (warning: legacy ad-hoc re-sign did not pass strict verification; "
|
|
"leaving safeStorage keychain item untouched)"
|
|
)
|
|
return False
|
|
print(" → macOS desktop re-signed (legacy ad-hoc); safeStorage keychain item left untouched")
|
|
return True
|
|
except Exception as exc:
|
|
print(f" (warning: macOS relaunch fixup skipped: {exc})")
|
|
return False
|
|
|
|
|
|
def _macos_codesigning_identity_valid(security: str, identity: str) -> bool:
|
|
"""True when `identity` appears among VALID code-signing identities.
|
|
|
|
``security find-identity -p codesigning`` (without ``-v``) also lists
|
|
certificates macOS will refuse to sign with — e.g. a self-signed cert that
|
|
was imported but never trusted for the codeSign policy. Only the ``-v``
|
|
listing proves codesign can actually use it, so this is both the
|
|
idempotency probe and the success postcondition for
|
|
``--setup-tcc-identity``. Never raises.
|
|
"""
|
|
try:
|
|
result = subprocess.run(
|
|
[security, "find-identity", "-v", "-p", "codesigning"],
|
|
capture_output=True, text=True, check=False,
|
|
)
|
|
except Exception:
|
|
return False
|
|
|
|
return f'"{identity}"' in (result.stdout or "")
|
|
|
|
|
|
def _desktop_macos_setup_tcc_identity(identity: str = "Hermes Local Signing") -> bool:
|
|
"""Create/import a self-signed code-signing cert and configure Hermes to use it.
|
|
|
|
One-shot setup for ``hermes desktop --setup-tcc-identity``. Creates a
|
|
self-signed "Code Signing" certificate in the login keychain (the same
|
|
artifact the docs describe creating manually via Keychain Access), grants
|
|
``codesign`` access to it, writes ``desktop.macos_signing_identity`` to
|
|
config.yaml, and re-signs the already-packaged app so the next launch uses
|
|
the certificate-anchored identity.
|
|
|
|
Why this matters: macOS TCC grants (Full Disk Access, Accessibility,
|
|
Automation, Files and Folders, microphone) persist against the app's
|
|
code-signing identity, not its path. A plain ad-hoc signature gets a
|
|
cdhash-only Designated Requirement, so every rebuild looks like a new app
|
|
and the user must re-grant everything. A certificate-anchored identity is
|
|
stable across rebuilds — the same mechanism yabai/skhd users rely on.
|
|
|
|
Idempotent: re-running after an update finds the existing certificate and
|
|
only re-points the config + re-signs. Returns True on success (or when
|
|
already configured), False on failure. Never raises.
|
|
"""
|
|
from hermes_cli.main import PROJECT_ROOT, _desktop_macos_relaunchable_fixup, _desktop_packaged_executable
|
|
if sys.platform != "darwin":
|
|
print(" (--setup-tcc-identity is macOS-only; skipping)")
|
|
return False
|
|
|
|
openssl = shutil.which("openssl")
|
|
security = shutil.which("security")
|
|
codesign = shutil.which("codesign")
|
|
if not (openssl and security and codesign):
|
|
print(
|
|
" (--setup-tcc-identity requires openssl, security, and codesign; "
|
|
f"found openssl={bool(openssl)} security={bool(security)} codesign={bool(codesign)})"
|
|
)
|
|
return False
|
|
|
|
keychain = str(Path.home() / "Library" / "Keychains" / "login.keychain-db")
|
|
# A certificate that merely EXISTS in the keychain is not enough — macOS
|
|
# only treats it as a code-signing identity once it is trusted for the
|
|
# codeSign policy. Probe with `-v` (valid identities only) so a previously
|
|
# imported-but-untrusted cert is repaired rather than reported as done.
|
|
already_imported = _macos_codesigning_identity_valid(security, identity)
|
|
|
|
if not already_imported:
|
|
# Create a self-signed code-signing cert (valid 10 years) and import it
|
|
# into the login keychain with codesign access so signing works without
|
|
# an interactive unlock prompt.
|
|
tmp_dir = Path(tempfile.mkdtemp(prefix="hermes-tcc-"))
|
|
try:
|
|
key = tmp_dir / "sign.key"
|
|
crt = tmp_dir / "sign.crt"
|
|
p12 = tmp_dir / "sign.p12"
|
|
subprocess.run(
|
|
[
|
|
openssl, "req", "-x509", "-newkey", "rsa:2048",
|
|
"-keyout", str(key), "-out", str(crt),
|
|
"-days", "3650", "-nodes",
|
|
"-subj", f"/CN={identity}",
|
|
"-addext", "basicConstraints=critical,CA:TRUE",
|
|
"-addext", "keyUsage=critical,digitalSignature,keyCertSign",
|
|
"-addext", "extendedKeyUsage=codeSigning",
|
|
],
|
|
capture_output=True, check=True,
|
|
)
|
|
# OpenSSL 3 defaults to AES/SHA-2 PKCS#12 encryption that macOS
|
|
# `security import` rejects with "MAC verification failed during
|
|
# PKCS12 import (wrong password?)". The `-legacy` flag restores the
|
|
# RC2/SHA-1 format the importer accepts, but only exists on
|
|
# OpenSSL 3 — so try the plain export first and fall back to
|
|
# `-legacy` when the IMPORT fails with that signature. (Verified
|
|
# E2E on macOS 26.3.1 / OpenSSL 3.6.3 by @ctaylor86 on PR #77189.)
|
|
def _export_p12(extra_args: list) -> None:
|
|
subprocess.run(
|
|
[
|
|
openssl, "pkcs12", "-export", *extra_args,
|
|
"-inkey", str(key), "-in", str(crt),
|
|
"-out", str(p12), "-passout", "pass:hermeslocal",
|
|
],
|
|
capture_output=True, check=True,
|
|
)
|
|
|
|
def _import_p12():
|
|
return subprocess.run(
|
|
[
|
|
security, "import", str(p12), "-k", keychain,
|
|
"-P", "hermeslocal",
|
|
"-T", codesign, "-T", "/usr/bin/codesign_allocate",
|
|
],
|
|
capture_output=True, text=True, check=False,
|
|
)
|
|
|
|
_export_p12([])
|
|
imported = _import_p12()
|
|
if imported.returncode != 0 and "MAC verification failed" in (imported.stderr or ""):
|
|
try:
|
|
_export_p12(["-legacy"])
|
|
imported = _import_p12()
|
|
except subprocess.CalledProcessError:
|
|
# Older OpenSSL without -legacy: keep the original failure.
|
|
pass
|
|
if imported.returncode != 0:
|
|
print(f" (could not import signing identity into keychain: {imported.stderr.strip()})")
|
|
return False
|
|
|
|
# Importing is still not enough: without explicit trust for the
|
|
# codeSign policy, `security find-identity -v -p codesigning`
|
|
# reports 0 valid identities and codesign refuses the cert. Trust
|
|
# the self-signed root for code signing. This writes to the user's
|
|
# trust settings, so macOS may prompt for the login password ONCE
|
|
# here — that is the one-time setup cost this command exists to
|
|
# front-load.
|
|
trusted = subprocess.run(
|
|
[security, "add-trusted-cert", "-r", "trustRoot", "-p", "codeSign", "-k", keychain, str(crt)],
|
|
capture_output=True, text=True, check=False,
|
|
)
|
|
if trusted.returncode != 0:
|
|
print(
|
|
" (could not trust the certificate for code signing: "
|
|
f"{(trusted.stderr or trusted.stdout).strip()})"
|
|
)
|
|
return False
|
|
print(f" → created, imported, and trusted self-signed identity: {identity!r}")
|
|
except Exception as exc:
|
|
print(f" (certificate creation failed: {exc})")
|
|
return False
|
|
finally:
|
|
shutil.rmtree(tmp_dir, ignore_errors=True)
|
|
else:
|
|
print(f" → identity {identity!r} already valid in keychain")
|
|
|
|
# Postcondition gate: only report success once macOS actually agrees the
|
|
# identity is usable for code signing. Name-in-output checks pass for
|
|
# invalid identities; this is the check that failed silently before.
|
|
if not _macos_codesigning_identity_valid(security, identity):
|
|
print(
|
|
f" (identity {identity!r} was imported but is not a VALID code-signing identity; "
|
|
"run `security find-identity -v -p codesigning` to inspect, and see the manual "
|
|
"Keychain Access steps in the desktop docs)"
|
|
)
|
|
return False
|
|
|
|
# Point Hermes at the identity (config.yaml, not .env — it's not a secret).
|
|
try:
|
|
from hermes_cli.config import set_config_value
|
|
|
|
set_config_value("desktop.macos_signing_identity", identity)
|
|
print(f" → set desktop.macos_signing_identity = {identity!r}")
|
|
except Exception as exc:
|
|
print(f" (could not write desktop.macos_signing_identity: {exc})")
|
|
return False
|
|
|
|
# Re-sign the packaged app so the current build already uses the identity.
|
|
desktop_dir = PROJECT_ROOT / "apps" / "desktop"
|
|
if _desktop_packaged_executable(desktop_dir) is not None:
|
|
try:
|
|
if _desktop_macos_relaunchable_fixup(desktop_dir):
|
|
print(
|
|
" → packaged app re-signed with certificate-anchored identity; "
|
|
"TCC grants persist across rebuilds"
|
|
)
|
|
except Exception as exc:
|
|
print(f" (could not re-sign packaged app: {exc})")
|
|
|
|
print(
|
|
"\n Note: macOS will re-prompt for permissions ONE final time (the identity "
|
|
"changed). Grant them and they persist from then on. If a permission gets "
|
|
"stuck, reset it with: tccutil reset All com.nousresearch.hermes"
|
|
)
|
|
return True
|
|
|
|
|
|
def _force_adhoc_macos_signing(env: dict, *, source_mode: bool) -> bool:
|
|
"""Stop electron-builder grabbing a random keychain identity on self-update.
|
|
|
|
The desktop self-updater rebuilds *and re-signs the .app on the end user's
|
|
machine* (``hermes desktop --build-only`` → electron-builder ``--dir``).
|
|
With ``CSC_IDENTITY_AUTO_DISCOVERY`` on (its default), electron-builder
|
|
signs the ``type=distribution``, hardened-runtime bundle with whatever it
|
|
finds in that user's keychain — typically a personal "Apple Development"
|
|
cert. That stalls/fails the sign step (no Developer ID + no provisioning
|
|
profile) or clobbers your real notarized signature with an unusable one, so
|
|
every post-update launch trips Gatekeeper.
|
|
|
|
Force ad-hoc signing for the local packaged rebuild instead: deterministic,
|
|
and exactly what ``_desktop_macos_relaunchable_fixup`` already finishes off.
|
|
No-op for source runs, off-macOS, when a real identity is configured
|
|
(``CSC_LINK`` / ``APPLE_SIGNING_IDENTITY``), or when the caller already
|
|
pinned the flag. Mutates ``env``; returns True when it set the flag.
|
|
"""
|
|
if sys.platform != "darwin" or source_mode:
|
|
return False
|
|
if env.get("CSC_LINK") or env.get("APPLE_SIGNING_IDENTITY"):
|
|
return False
|
|
if "CSC_IDENTITY_AUTO_DISCOVERY" in env:
|
|
return False
|
|
env["CSC_IDENTITY_AUTO_DISCOVERY"] = "false"
|
|
return True
|
|
|
|
|
|
def _desktop_linux_needs_no_sandbox() -> bool:
|
|
"""Return True when Chromium/Electron should bypass the Linux sandbox.
|
|
|
|
Ubuntu 23.10+ can enable AppArmor's
|
|
``apparmor_restrict_unprivileged_userns`` hardening, which breaks
|
|
Chromium/Electron's user-namespace sandbox for normal users unless the app
|
|
ships a working root-owned 4755 ``chrome-sandbox`` helper. In headless or
|
|
non-interactive CLI contexts we may be unable to ``sudo chown/chmod`` that
|
|
helper, so detect the host restriction and fall back to ``--no-sandbox``
|
|
rather than hard-failing the launcher.
|
|
|
|
We intentionally do NOT return True for root users here: running Electron as
|
|
root without a sandbox is a qualitatively riskier path than launching as an
|
|
unprivileged desktop user on an AppArmor-restricted host. The root case
|
|
should remain an explicit user choice.
|
|
"""
|
|
if os.environ.get("ELECTRON_DISABLE_SANDBOX", 0) == "1":
|
|
return True
|
|
|
|
if sys.platform != "linux":
|
|
return False
|
|
if hasattr(os, "geteuid") and os.geteuid() == 0:
|
|
return False
|
|
try:
|
|
with open("/proc/sys/kernel/apparmor_restrict_unprivileged_userns", encoding="utf-8") as f:
|
|
return f.read().strip() == "1"
|
|
except OSError:
|
|
return False
|
|
|
|
|
|
def _desktop_linux_userns_sandbox_available() -> bool:
|
|
"""Return True when Chromium's unprivileged user-namespace sandbox works.
|
|
|
|
When an unprivileged process can create a user namespace, Chromium uses the
|
|
namespace sandbox and never consults the setuid ``chrome-sandbox`` helper,
|
|
so requiring the helper to be root-owned 4755 (and prompting for sudo) is
|
|
unnecessary. Probe the real capability with ``unshare`` instead of reading
|
|
distro-specific sysctls: the probe fails closed on hosts where user
|
|
namespaces are disabled or AppArmor-restricted, which then follow the
|
|
existing setuid-helper path.
|
|
"""
|
|
if sys.platform != "linux":
|
|
return False
|
|
unshare = shutil.which("unshare")
|
|
if not unshare:
|
|
return False
|
|
try:
|
|
return (
|
|
subprocess.run(
|
|
[unshare, "--user", "--map-root-user", "true"],
|
|
stdout=subprocess.DEVNULL,
|
|
stderr=subprocess.DEVNULL,
|
|
timeout=5,
|
|
check=False,
|
|
).returncode
|
|
== 0
|
|
)
|
|
except (OSError, subprocess.TimeoutExpired):
|
|
return False
|
|
|
|
|
|
def _desktop_linux_sandbox_helper_is_regular_file(packaged_executable: Path) -> bool:
|
|
"""Return True when ``chrome-sandbox`` exists as a regular file."""
|
|
if sys.platform != "linux":
|
|
return False
|
|
sandbox = packaged_executable.parent / "chrome-sandbox"
|
|
try:
|
|
sandbox_lstat = sandbox.lstat()
|
|
except OSError:
|
|
return False
|
|
return stat.S_ISREG(sandbox_lstat.st_mode)
|
|
|
|
|
|
def _desktop_linux_sandbox_fixup(packaged_executable: Path) -> bool:
|
|
"""Configure Electron's Linux SUID sandbox helper when required."""
|
|
from hermes_cli.main import _desktop_linux_userns_sandbox_available
|
|
if sys.platform != "linux":
|
|
return True
|
|
|
|
sandbox = packaged_executable.parent / "chrome-sandbox"
|
|
if not sandbox.exists():
|
|
print(f"✗ Hermes Desktop is missing Electron's Linux sandbox helper: {sandbox}")
|
|
return False
|
|
|
|
# Reject symlinks — chown/chmod must not follow an attacker-controlled
|
|
# link to an arbitrary path. Use lstat() so we inspect the link itself
|
|
# rather than the target, and require a regular file.
|
|
try:
|
|
sandbox_lstat = sandbox.lstat()
|
|
except OSError:
|
|
print(f"✗ Cannot stat Electron's Linux sandbox helper: {sandbox}")
|
|
return False
|
|
if not stat.S_ISREG(sandbox_lstat.st_mode):
|
|
print(f"✗ Electron's Linux sandbox helper is not a regular file: {sandbox}")
|
|
return False
|
|
|
|
if sandbox_lstat.st_uid == 0 and stat.S_IMODE(sandbox_lstat.st_mode) == 0o4755:
|
|
return True
|
|
|
|
if _desktop_linux_userns_sandbox_available():
|
|
print("✓ Using Chromium's user-namespace sandbox (setuid helper not needed).")
|
|
return True
|
|
|
|
sudo = shutil.which("sudo")
|
|
if not sudo:
|
|
print("✗ Hermes Desktop requires sudo to configure Electron's Linux sandbox helper.")
|
|
return False
|
|
|
|
print("→ Configuring Electron Linux sandbox helper (sudo required)...")
|
|
for command in ([sudo, "chown", "root:root", str(sandbox)], [sudo, "chmod", "4755", str(sandbox)]):
|
|
if subprocess.run(command, check=False).returncode != 0:
|
|
print(f"✗ Failed to configure Electron's Linux sandbox helper: {sandbox}")
|
|
return False
|
|
return True
|
|
|
|
|
|
def _desktop_linux_needs_disable_setuid_sandbox(packaged_executable: Path) -> bool:
|
|
"""Return True when Chromium should skip the present-but-non-setuid helper.
|
|
|
|
A user-owned ``chrome-sandbox`` still makes Chromium abort with
|
|
``setuid_sandbox_host`` even when the namespace sandbox works. Passing
|
|
``--disable-setuid-sandbox`` keeps the userns sandbox and avoids sudo.
|
|
Call only after ``_desktop_linux_sandbox_fixup`` succeeded without making
|
|
the helper root-owned 4755 (the userns path). Does not re-probe userns.
|
|
"""
|
|
if sys.platform != "linux":
|
|
return False
|
|
sandbox = packaged_executable.parent / "chrome-sandbox"
|
|
try:
|
|
sandbox_lstat = sandbox.lstat()
|
|
except OSError:
|
|
return False
|
|
if not stat.S_ISREG(sandbox_lstat.st_mode):
|
|
return False
|
|
if sandbox_lstat.st_uid == 0 and stat.S_IMODE(sandbox_lstat.st_mode) == 0o4755:
|
|
return False
|
|
return True
|
|
|
|
|
|
_LINUX_PASSWORD_STORES = frozenset({"gnome-libsecret", "kwallet", "kwallet5", "kwallet6", "basic"})
|
|
|
|
|
|
def _detect_linux_password_store() -> str | None:
|
|
"""Detect the Chromium password-store backend for the current Linux session.
|
|
|
|
Electron's safeStorage only reports encryption as available when Chromium
|
|
selects the right keychain backend, and Chromium's own detection routinely
|
|
fails under `hermes desktop` because the launcher environment doesn't look
|
|
like a full desktop session. Probe order: KDE session env vars, GNOME
|
|
Keyring's control socket, then a D-Bus ping of org.freedesktop.secrets
|
|
(covers any Secret Service implementation, e.g. KeePassXC). Returns None
|
|
when no keychain daemon is reachable.
|
|
"""
|
|
kde_version = os.environ.get("KDE_SESSION_VERSION", "").strip()
|
|
if kde_version == "6":
|
|
return "kwallet6"
|
|
if kde_version == "5":
|
|
return "kwallet5"
|
|
if kde_version:
|
|
return "kwallet"
|
|
if os.environ.get("KDE_FULL_SESSION"):
|
|
return "kwallet"
|
|
if os.environ.get("GNOME_KEYRING_CONTROL"):
|
|
return "gnome-libsecret"
|
|
try:
|
|
result = subprocess.run(
|
|
[
|
|
"dbus-send", "--session", "--print-reply", "--reply-timeout=2000",
|
|
"--dest=org.freedesktop.secrets",
|
|
"/org/freedesktop/secrets",
|
|
"org.freedesktop.DBus.Peer.Ping",
|
|
],
|
|
capture_output=True,
|
|
timeout=5,
|
|
)
|
|
if result.returncode == 0:
|
|
return "gnome-libsecret"
|
|
except Exception:
|
|
pass
|
|
return None
|
|
|
|
|
|
def _desktop_launch_options() -> tuple[list[str], str, str, str]:
|
|
"""Read `desktop.*` launch options from config.yaml.
|
|
|
|
Returns ``(electron_flags, disable_gpu, password_store, ozone_hint)`` where
|
|
``electron_flags`` is a list of extra Electron CLI flags, ``disable_gpu``
|
|
is one of "auto"/"1"/"0" (normalized for the HERMES_DESKTOP_DISABLE_GPU
|
|
env var the Electron app reads), ``password_store`` is "auto" or one
|
|
of the Chromium password-store backends (unknown values normalize to
|
|
"auto"), and ``ozone_hint`` is one of "auto"/"x11"/"wayland" (normalized
|
|
for ``ELECTRON_OZONE_PLATFORM_HINT``). Best-effort: any config error
|
|
yields the safe defaults ``([], "auto", "auto", "auto")`` so a malformed
|
|
config never blocks the launch.
|
|
"""
|
|
flags: list[str] = []
|
|
disable_gpu = "auto"
|
|
password_store = "auto"
|
|
ozone_hint = "auto"
|
|
try:
|
|
from hermes_cli.config import load_config
|
|
|
|
desktop_cfg = (load_config() or {}).get("desktop") or {}
|
|
except Exception:
|
|
return flags, disable_gpu, password_store, ozone_hint
|
|
|
|
raw_flags = desktop_cfg.get("electron_flags")
|
|
if isinstance(raw_flags, str):
|
|
flags = shlex.split(raw_flags, posix=(os.name != "nt"))
|
|
elif isinstance(raw_flags, (list, tuple)):
|
|
flags = [str(f) for f in raw_flags if str(f).strip()]
|
|
|
|
raw_gpu = desktop_cfg.get("disable_gpu", "auto")
|
|
if isinstance(raw_gpu, bool):
|
|
disable_gpu = "1" if raw_gpu else "0"
|
|
elif isinstance(raw_gpu, str):
|
|
low = raw_gpu.strip().lower()
|
|
if low in ("1", "true", "yes", "on"):
|
|
disable_gpu = "1"
|
|
elif low in ("0", "false", "no", "off"):
|
|
disable_gpu = "0"
|
|
else:
|
|
disable_gpu = "auto"
|
|
|
|
raw_store = desktop_cfg.get("password_store", "auto")
|
|
if isinstance(raw_store, str):
|
|
low_store = raw_store.strip().lower()
|
|
if low_store in _LINUX_PASSWORD_STORES:
|
|
password_store = low_store
|
|
|
|
raw_ozone = desktop_cfg.get("ozone_platform_hint", "auto")
|
|
if isinstance(raw_ozone, str):
|
|
low_ozone = raw_ozone.strip().lower()
|
|
if low_ozone in ("auto", "x11", "wayland"):
|
|
ozone_hint = low_ozone
|
|
return flags, disable_gpu, password_store, ozone_hint
|
|
|
|
|
|
def _register_linux_desktop_entry() -> None:
|
|
"""Install the XDG desktop entry for Hermes Desktop (Linux only, best-effort).
|
|
|
|
Gives the Electron app a launcher presence: a menu item and an icon.
|
|
``Exec`` and ``Icon`` are absolute, so the entry works outside a login
|
|
shell. ``hermes uninstall --gui`` removes it.
|
|
"""
|
|
from hermes_cli.main import PROJECT_ROOT
|
|
try:
|
|
from hermes_cli.linux_desktop_entry import install_desktop_entry, is_supported
|
|
|
|
if not is_supported():
|
|
return
|
|
entry = install_desktop_entry(PROJECT_ROOT)
|
|
if entry:
|
|
print(f"✓ Desktop launcher entry installed: {entry}")
|
|
except Exception as exc: # never block a launch on launcher plumbing
|
|
print(f"⚠ Could not install the desktop launcher entry: {exc}")
|
|
|
|
|
|
def _build_desktop_app(desktop_dir: Path, *, source_mode: bool, npm: str, env: dict) -> Optional[Path]:
|
|
"""npm-install + build the desktop app; stage-and-swap the packaged tree.
|
|
|
|
Returns the freshly installed packaged executable (non-source mode) or
|
|
None (source mode builds ``dist/`` in place). Exits the process on any
|
|
unrecoverable build failure, leaving the previous packaged app untouched.
|
|
"""
|
|
from hermes_cli.main import PROJECT_ROOT, _desktop_macos_relaunchable_fixup, _desktop_packaged_executable, _desktop_staging_dir, _electron_dist_ok, _ensure_desktop_exe_launchable, _purge_electron_build_cache, _redownload_electron_dist, _run_npm_install_deterministic, _stop_desktop_processes_locking_build, _swap_staged_desktop_app, _write_desktop_build_stamp
|
|
from hermes_constants import with_hermes_node_path
|
|
|
|
print("→ Installing desktop workspace dependencies...")
|
|
# Put the Hermes-managed Node on PATH so npm's child scripts (which
|
|
# shell out to bare `node`, e.g. electron-winstaller's
|
|
# select-7z-arch.js) resolve it even when the parent PATH is
|
|
# stripped — the desktop updater chain (Desktop → hermes-setup →
|
|
# hermes update) loses shell PATH customizations. Wrapping the
|
|
# NixOS build env keeps its PYTHON hint while restoring managed Node
|
|
# ahead of a bare PATH (same idiom as the `hermes update` path).
|
|
nixos_env = with_hermes_node_path(_nixos_build_env())
|
|
install_result = _run_npm_install_deterministic(npm, PROJECT_ROOT, capture_output=False, env=nixos_env)
|
|
if install_result.returncode != 0:
|
|
if not _electron_pkg_staged_missing_dist(PROJECT_ROOT):
|
|
print("✗ Desktop dependency install failed")
|
|
print(f" Run manually: cd {PROJECT_ROOT} && npm ci")
|
|
sys.exit(install_result.returncode or 1)
|
|
repaired = _try_redownload_electron_dist(PROJECT_ROOT, env)
|
|
if repaired:
|
|
print(" ⚠ Dependency install failed with a missing Electron dist; "
|
|
"repopulated it and continuing.")
|
|
else:
|
|
print(" ⚠ Dependency install failed with a missing Electron dist; "
|
|
"continuing to the build so electron-builder can attempt "
|
|
"the Electron fetch itself.")
|
|
|
|
build_label = "source build" if source_mode else "packaged app"
|
|
print(f"→ Building desktop {build_label}...")
|
|
build_script = "build" if source_mode else "pack"
|
|
if _force_adhoc_macos_signing(env, source_mode=source_mode):
|
|
print(" → No Developer ID configured; ad-hoc signing this local rebuild "
|
|
"(CSC_IDENTITY_AUTO_DISCOVERY=false)")
|
|
npm_build_env = _npm_lifecycle_env(env)
|
|
# Stage-and-swap (#86443): electron-builder packs IN PLACE and
|
|
# before-pack.mjs wipes release/<unpacked> first, so a pack that
|
|
# fails afterwards used to leave the user with NO app. Build into
|
|
# a fresh staging output dir instead; the live release/ tree is
|
|
# only replaced — by rename — after the staged result verifies.
|
|
staging_dir: Optional[Path] = None
|
|
build_cmd = [npm, "run", build_script]
|
|
if not source_mode:
|
|
staging_dir = _desktop_staging_dir(desktop_dir)
|
|
build_cmd += ["--", f"-c.directories.output={staging_dir}"]
|
|
# A running desktop instance launched from release/win-unpacked
|
|
# holds Hermes.exe locked on Windows, so the pack can't replace
|
|
# it ("Access is denied" / ERR_ELECTRON_BUILDER_CANNOT_EXECUTE).
|
|
# Stop it first so the rebuild — including the installer's
|
|
# headless --update rebuild — succeeds instead of failing cryptically.
|
|
stopped = _stop_desktop_processes_locking_build(desktop_dir)
|
|
if stopped:
|
|
print(f" ⚠ Stopped running desktop app to free the build output (pid {', '.join(map(str, stopped))})")
|
|
|
|
def _staged_exe() -> Optional[Path]:
|
|
return _desktop_packaged_executable_in(staging_dir) if staging_dir else None
|
|
|
|
build_result = subprocess.run(
|
|
build_cmd, cwd=desktop_dir, env=npm_build_env, check=False
|
|
)
|
|
if (
|
|
build_result.returncode != 0
|
|
and not source_mode
|
|
and _staged_exe() is None
|
|
):
|
|
# Corrupt cached Electron zip → partial unpack → ENOENT on rename.
|
|
# stdlib zipfile won't catch the common concat-junk case, so purge
|
|
# and retry once; @electron/get SHASUM is the real gate.
|
|
#
|
|
# Gate on a MISSING packaged executable: that is the signature of
|
|
# the corrupt-download class this recovery exists for. A late
|
|
# failure such as macOS code signing leaves the executable in
|
|
# place — redownloading Electron can't repair it, so the purge +
|
|
# retry would only add another slow, identical failure (#40187).
|
|
purged: list[Path] = []
|
|
restored = False
|
|
if not _electron_dist_ok(PROJECT_ROOT):
|
|
purged = _purge_electron_build_cache(desktop_dir, release_dir=staging_dir)
|
|
restored = _redownload_electron_dist(PROJECT_ROOT, env)
|
|
if restored:
|
|
print(" ⚠ Desktop build failed; refreshed the Electron download and retrying once...")
|
|
for p in purged:
|
|
print(f" - {p}")
|
|
# The purge can't remove a win-unpacked tree whose Hermes.exe
|
|
# is still locked by a running instance; stop it before retry.
|
|
_stop_desktop_processes_locking_build(desktop_dir)
|
|
build_result = subprocess.run(
|
|
build_cmd, cwd=desktop_dir, env=npm_build_env, check=False
|
|
)
|
|
if (
|
|
build_result.returncode != 0
|
|
and not source_mode
|
|
and not env.get("ELECTRON_MIRROR")
|
|
and _staged_exe() is None
|
|
):
|
|
print(" ⚠ Desktop build still failing; the Electron download from "
|
|
"GitHub looks blocked. Re-downloading via a public mirror "
|
|
"(npmmirror.com)... (set ELECTRON_MIRROR to use another mirror)")
|
|
mirror = _ELECTRON_FALLBACK_MIRROR
|
|
mirror_env = dict(npm_build_env)
|
|
mirror_env["ELECTRON_MIRROR"] = mirror
|
|
if not _electron_dist_ok(PROJECT_ROOT):
|
|
_redownload_electron_dist(PROJECT_ROOT, env, mirror=mirror)
|
|
_stop_desktop_processes_locking_build(desktop_dir)
|
|
build_result = subprocess.run(build_cmd, cwd=desktop_dir, env=mirror_env, check=False)
|
|
if build_result.returncode != 0:
|
|
print("✗ Desktop GUI build failed")
|
|
if staging_dir is not None:
|
|
_discard_desktop_staging(staging_dir)
|
|
if _desktop_packaged_executable(desktop_dir) is not None:
|
|
print(" ↩ The previous desktop app was left untouched and still works.")
|
|
print(f" Run manually: cd apps/desktop && npm run {build_script}")
|
|
if sys.platform == "win32":
|
|
print(" If this says \"Access is denied\" on Hermes.exe, close any")
|
|
print(" running Hermes desktop window and retry.")
|
|
print(" If the log shows Electron download retries, rebuild via a mirror:")
|
|
print(" ELECTRON_MIRROR=<mirror-base-url> hermes desktop --force-build")
|
|
sys.exit(build_result.returncode or 1)
|
|
if not source_mode:
|
|
assert staging_dir is not None
|
|
staged_executable = _staged_exe()
|
|
# Locally-built apps are ad-hoc signed; make them relaunchable after
|
|
# an in-place self-update (otherwise macOS reports "Hermes is
|
|
# damaged"). No-op on non-macOS and on real-identity builds.
|
|
# Signs the STAGED bundle so the live app is never half-signed.
|
|
_desktop_macos_relaunchable_fixup(desktop_dir, release_dir=staging_dir)
|
|
|
|
# Windows integrity gate (#69179): never declare the rebuild a
|
|
# success on a Hermes.exe Windows cannot load (truncated PE from
|
|
# a corrupt cached Electron zip, wrong-arch tree, interrupted
|
|
# rcedit rewrite). Verified on the STAGED exe: a failure here
|
|
# simply discards the staging dir — the live app was never
|
|
# touched — and fails loudly so the updater's retry-once
|
|
# rebuilds from a fresh Electron download.
|
|
verified_executable, rolled_back = _ensure_desktop_exe_launchable(
|
|
desktop_dir, staged_executable
|
|
)
|
|
if staged_executable is None or rolled_back or verified_executable is None:
|
|
_discard_desktop_staging(staging_dir)
|
|
if staged_executable is None:
|
|
print(f"✗ Desktop build produced no launchable app in {staging_dir}")
|
|
print(" ↩ The previous desktop app was left untouched and still works.")
|
|
sys.exit(1)
|
|
# Verified: swap the staged tree over the live one (rename).
|
|
packaged_executable = _swap_staged_desktop_app(desktop_dir, staging_dir)
|
|
if packaged_executable is None:
|
|
print(f"✗ Could not install the rebuilt desktop app into {desktop_dir / 'release'}")
|
|
print(" ↩ The previous desktop app was left untouched and still works.")
|
|
sys.exit(1)
|
|
|
|
# Build succeeded — write the stamp so next run can skip
|
|
_write_desktop_build_stamp(PROJECT_ROOT, source_mode=source_mode)
|
|
return packaged_executable if not source_mode else None
|
|
|
|
|
|
def cmd_gui(args: argparse.Namespace):
|
|
"""Build and launch the native Electron desktop GUI."""
|
|
from hermes_cli.main import PROJECT_ROOT, _desktop_build_needed, _desktop_dist_exists, _desktop_launch_options, _desktop_linux_needs_disable_setuid_sandbox, _desktop_linux_sandbox_fixup, _desktop_macos_setup_tcc_identity, _desktop_packaged_executable, _detect_linux_password_store, _register_linux_desktop_entry, _resolve_node_runtime_npm
|
|
desktop_dir = PROJECT_ROOT / "apps" / "desktop"
|
|
if not (desktop_dir / "package.json").exists():
|
|
print(f"Desktop GUI source not found at: {desktop_dir}")
|
|
sys.exit(1)
|
|
|
|
try:
|
|
from hermes_logging import setup_logging as _setup_logging_gui
|
|
_setup_logging_gui(mode="gui")
|
|
except Exception:
|
|
pass
|
|
|
|
from hermes_constants import with_hermes_node_path
|
|
|
|
# with_hermes_node_path() copies os.environ when called with no arg.
|
|
env = with_hermes_node_path()
|
|
if getattr(args, "fake_boot", False):
|
|
env["HERMES_DESKTOP_BOOT_FAKE"] = "1"
|
|
if getattr(args, "ignore_existing", False):
|
|
env["HERMES_DESKTOP_IGNORE_EXISTING"] = "1"
|
|
if getattr(args, "hermes_root", None):
|
|
env["HERMES_DESKTOP_HERMES_ROOT"] = str(Path(args.hermes_root).expanduser().resolve())
|
|
if getattr(args, "cwd", None):
|
|
env["HERMES_DESKTOP_CWD"] = str(Path(args.cwd).expanduser().resolve())
|
|
else:
|
|
env["HERMES_DESKTOP_CWD"] = os.getcwd()
|
|
|
|
# Desktop launch options from config.yaml (`desktop.electron_flags`,
|
|
# `desktop.disable_gpu`, `desktop.ozone_platform_hint`). The GPU policy
|
|
# and ozone hint are bridged to env vars the Electron/Chromium process
|
|
# already reads; an explicit env var still wins over config so
|
|
# `HERMES_DESKTOP_DISABLE_GPU=... hermes desktop` and
|
|
# `ELECTRON_OZONE_PLATFORM_HINT=... hermes desktop` keep working.
|
|
config_electron_flags, config_disable_gpu, config_password_store, config_ozone_hint = (
|
|
_desktop_launch_options()
|
|
)
|
|
if config_disable_gpu != "auto" and "HERMES_DESKTOP_DISABLE_GPU" not in os.environ:
|
|
env["HERMES_DESKTOP_DISABLE_GPU"] = config_disable_gpu
|
|
if config_ozone_hint != "auto" and "ELECTRON_OZONE_PLATFORM_HINT" not in os.environ:
|
|
env["ELECTRON_OZONE_PLATFORM_HINT"] = config_ozone_hint
|
|
|
|
# Linux keychain backend for safeStorage (`desktop.password_store`).
|
|
# Chromium needs the --password-store switch to pick the right keychain;
|
|
# without it safeStorage.isEncryptionAvailable() is often false and the
|
|
# desktop app refuses to persist remote gateway tokens. Config wins over
|
|
# detection; an explicit env var wins over both so
|
|
# `HERMES_DESKTOP_PASSWORD_STORE=... hermes desktop` keeps working.
|
|
if sys.platform == "linux" and "HERMES_DESKTOP_PASSWORD_STORE" not in os.environ:
|
|
password_store = (
|
|
config_password_store
|
|
if config_password_store != "auto"
|
|
else _detect_linux_password_store()
|
|
)
|
|
if password_store:
|
|
env["HERMES_DESKTOP_PASSWORD_STORE"] = password_store
|
|
|
|
source_mode = getattr(args, "source", False)
|
|
skip_build = getattr(args, "skip_build", False)
|
|
force_build = getattr(args, "force_build", False)
|
|
|
|
# macOS-only one-shot: create a self-signed code-signing identity so TCC
|
|
# grants survive rebuilds, then exit without building/launching.
|
|
if getattr(args, "setup_tcc_identity", False):
|
|
identity = getattr(args, "identity", None) or "Hermes Local Signing"
|
|
ok = _desktop_macos_setup_tcc_identity(identity)
|
|
sys.exit(0 if ok else 1)
|
|
|
|
packaged_executable = _desktop_packaged_executable(desktop_dir)
|
|
|
|
if source_mode or not skip_build:
|
|
npm = _resolve_node_runtime_npm()
|
|
if not npm:
|
|
print("Desktop GUI requires Node.js/npm, but npm was not found on PATH.")
|
|
print("Install Node.js, then run: hermes gui")
|
|
sys.exit(1)
|
|
else:
|
|
npm = None
|
|
|
|
if skip_build:
|
|
if source_mode:
|
|
if not _desktop_dist_exists(desktop_dir):
|
|
print(f"✗ --skip-build --source was passed but no desktop dist found at: {desktop_dir / 'dist'}")
|
|
print(" Pre-build first: cd apps/desktop && npm run build")
|
|
print(" Or drop --skip-build to install dependencies and build automatically.")
|
|
sys.exit(1)
|
|
if not (_electron_dir(PROJECT_ROOT) / "package.json").exists():
|
|
print("✗ --skip-build --source requires existing desktop workspace dependencies.")
|
|
print(f" Install first: cd {PROJECT_ROOT} && npm ci")
|
|
print(" Or drop --skip-build to install dependencies and build automatically.")
|
|
sys.exit(1)
|
|
print(f"→ Skipping desktop source build (--skip-build --source); using dist at {desktop_dir / 'dist'}")
|
|
elif packaged_executable is None:
|
|
print(f"✗ --skip-build was passed but no packaged desktop app was found at: {desktop_dir / 'release'}")
|
|
print(" Pre-build first: cd apps/desktop && npm run pack")
|
|
print(" Or drop --skip-build to package automatically.")
|
|
sys.exit(1)
|
|
else:
|
|
print(f"→ Skipping desktop package build (--skip-build); using {packaged_executable}")
|
|
else:
|
|
# Check the content-hash stamp before doing any build work.
|
|
# If the source tree hasn't changed since the last successful build,
|
|
# skip the npm install + build entirely (saves a ton of useless work).
|
|
# --force-build overrides the stamp and always rebuilds.
|
|
build_needed = force_build or _desktop_build_needed(
|
|
desktop_dir, PROJECT_ROOT, source_mode=source_mode
|
|
)
|
|
if not build_needed:
|
|
build_label = "source build" if source_mode else "packaged app"
|
|
print(f"✓ Desktop {build_label} is up to date (content stamp matches)")
|
|
else:
|
|
built = _build_desktop_app(desktop_dir, source_mode=source_mode, npm=npm, env=env)
|
|
if not source_mode:
|
|
packaged_executable = built
|
|
|
|
# Linux: register the app in the desktop launcher, so Hermes shows up
|
|
# in the application menu with its icon. Best-effort and idempotent.
|
|
# A failure must never stop the app from launching.
|
|
_register_linux_desktop_entry()
|
|
|
|
# --build-only: produce the artifact but do NOT launch. The installer's
|
|
# --update flow drives the rebuild headlessly and then launches the desktop
|
|
# itself (detached, after the old exe has exited), so the launch must NOT
|
|
# happen here — it would block the installer and, on Windows, the old exe
|
|
# is still being replaced. Verify the expected artifact exists so a silent
|
|
# "built nothing" can't slip past, then return success.
|
|
if getattr(args, "build_only", False):
|
|
if source_mode:
|
|
if not _desktop_dist_exists(desktop_dir):
|
|
print(f"✗ --build-only --source produced no dist at: {desktop_dir / 'dist'}")
|
|
sys.exit(1)
|
|
print(f"✓ Desktop source build ready at {desktop_dir / 'dist'} (not launching; --build-only)")
|
|
elif packaged_executable is None:
|
|
print(f"✗ --build-only produced no launchable app at: {desktop_dir / 'release'}")
|
|
print(" Expected an unpacked Electron app for the current OS.")
|
|
sys.exit(1)
|
|
else:
|
|
print(f"✓ Desktop packaged app ready: {packaged_executable} (not launching; --build-only)")
|
|
return
|
|
|
|
if source_mode:
|
|
print("→ Launching Hermes Desktop from source build...")
|
|
electron_argv = [npm, "exec", "--", "electron", "."]
|
|
if getattr(args, "local", False):
|
|
electron_argv.append("--local")
|
|
launch_result = subprocess.run(electron_argv, cwd=desktop_dir, env=env, check=False)
|
|
sys.exit(launch_result.returncode)
|
|
|
|
if packaged_executable is None:
|
|
print(f"✗ Desktop package build completed but no launchable app was found at: {desktop_dir / 'release'}")
|
|
print(" Expected an unpacked Electron app for the current OS.")
|
|
sys.exit(1)
|
|
|
|
launch_command = [str(packaged_executable)]
|
|
if not _desktop_linux_sandbox_fixup(packaged_executable):
|
|
if _desktop_linux_needs_no_sandbox() and _desktop_linux_sandbox_helper_is_regular_file(packaged_executable):
|
|
print("⚠ Falling back to --no-sandbox because this Linux host restricts unprivileged user namespaces and the Electron sandbox helper could not be configured.")
|
|
launch_command.append("--no-sandbox")
|
|
else:
|
|
sys.exit(1)
|
|
elif _desktop_linux_needs_disable_setuid_sandbox(packaged_executable):
|
|
launch_command.append("--disable-setuid-sandbox")
|
|
|
|
launch_command.extend(config_electron_flags)
|
|
if getattr(args, "local", False):
|
|
launch_command.append("--local")
|
|
print(f"→ Launching packaged Hermes Desktop: {' '.join(launch_command)}")
|
|
launch_result = subprocess.run(launch_command, cwd=desktop_dir, env=env, check=False)
|
|
sys.exit(launch_result.returncode)
|