Wire the desktop app onto the pm store for real distribution: - MSIX bundle: electron-builder config, appx assets, manifest, copilot key + deep-link routing, App Installer + Windows Store variant (sign only the msix; inner binaries covered by the package block map) - Rust CLI shim (apps/desktop/shim) — bundled builds run from the store python + shim, never the venv; payload symlinks relativized so the relocatable venv survives relocation - Cloudflare R2 release pipeline: publish binaries + update feeds, nightly channels/tags, stamp-first version resolution - Update system: gate, uninstall steward, boot bootstrap, release channels, update receipts - install.ps1 reduced to a 361-line stage-protocol bootstrapper (heavy deps are pm's job); darwin updater + update-channel mirror ripped - doctor: main's re-landed TCC anchor kept, termux branches removed Rebuilt from ethie/pm onto the pm-store stack. 22 hot files hand-merged; uv.lock + package-lock.json keep main's newer dep tree; test_engines reads the pm/lock.json pin; lazy_deps.py deleted (all 222 importers migrated to pm in the foundation commit).
180 lines
6.0 KiB
Python
180 lines
6.0 KiB
Python
"""Where the running BUNDLED desktop app lives, and how to start it.
|
|
|
|
A bundled artifact runs its agent out of ``<app>/resources/agent-payload``,
|
|
so a Python process inside that payload sits at a known offset from the app
|
|
the user double-clicks. This module turns that offset into the launcher path
|
|
and starts it — the two things every in-bundle caller needs and each one
|
|
used to work out for itself.
|
|
|
|
The shape authority is the build stamp (``payload: bundled``, read through
|
|
:func:`hermes_cli.steward.is_bundled_payload`), never a filesystem probe: a
|
|
probe answers "is this artifact intact?", not "which shape am I?". Callers
|
|
gate on the stamp, then ask here WHERE the app is. A stamp that says bundled
|
|
over a tree that is not one is damage, and :func:`resolve_bundle_layout`
|
|
says so instead of degrading to a checkout.
|
|
|
|
Two consumers today: ``hermes desktop`` (start the app this CLI ships
|
|
inside) and the sealed self-updater (stop the app, then relaunch it).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
|
|
__all__ = [
|
|
"BundleLayout",
|
|
"NotBundledApp",
|
|
"PAYLOAD_DIR_NAME",
|
|
"launch_detached",
|
|
"resolve_bundle_layout",
|
|
]
|
|
|
|
#: The resources-resident payload directory every bundled artifact ships.
|
|
PAYLOAD_DIR_NAME = "agent-payload"
|
|
|
|
#: Executables that sit beside a Linux Electron launcher without being one.
|
|
#: AppRun is the AppImage entry stub, which re-execs the launcher below.
|
|
_LINUX_NON_LAUNCHERS = frozenset({"AppRun", "chrome-sandbox", "chrome_crashpad_handler"})
|
|
|
|
|
|
class NotBundledApp(Exception):
|
|
"""Raised when a tree is not a bundled desktop payload."""
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class BundleLayout:
|
|
"""The three directories and the launcher of a bundled desktop app.
|
|
|
|
``launcher`` is None when the app directory holds no single obvious
|
|
executable. That is a real state on a damaged or unusual install, and
|
|
both callers would rather report it than act on a guess.
|
|
"""
|
|
|
|
app_root: Path
|
|
resources: Path
|
|
payload: Path
|
|
launcher: Path | None
|
|
|
|
|
|
def resolve_bundle_layout(
|
|
project_root: Path | str, *, platform: str | None = None
|
|
) -> BundleLayout:
|
|
"""Locate the desktop app that contains the payload at *project_root*.
|
|
|
|
*project_root* is the payload's ``repo/`` tree — the install root a
|
|
bundled backend runs from. The app sits two directories above the
|
|
payload on Windows and Linux, and four above it on macOS, where the
|
|
payload lands in ``Hermes.app/Contents/Resources``.
|
|
|
|
The macOS offset is detected from the directory names themselves
|
|
rather than from *platform*, so a layout can be resolved for any host
|
|
from any host. *platform* (default :data:`sys.platform`) only decides
|
|
the launcher-naming rule.
|
|
|
|
Raises :class:`NotBundledApp` when the tree is not a bundled payload —
|
|
a source checkout, or the docker/nix sealed shape, which has no app.
|
|
"""
|
|
repo = Path(project_root).resolve()
|
|
payload = repo.parent
|
|
if payload.name != PAYLOAD_DIR_NAME:
|
|
raise NotBundledApp(
|
|
f"{repo} is not inside a {PAYLOAD_DIR_NAME}/ payload — no desktop app contains it"
|
|
)
|
|
|
|
resources = payload.parent
|
|
app_root = resources.parent
|
|
# macOS: .../Hermes.app/Contents/Resources/agent-payload/repo
|
|
if (
|
|
resources.name == "Resources"
|
|
and app_root.name == "Contents"
|
|
and app_root.parent.suffix == ".app"
|
|
):
|
|
app_root = app_root.parent
|
|
|
|
if not app_root.is_dir():
|
|
raise NotBundledApp(f"no app directory above the payload at {payload}")
|
|
|
|
return BundleLayout(
|
|
app_root=app_root,
|
|
resources=resources,
|
|
payload=payload,
|
|
launcher=_resolve_launcher(app_root, platform or sys.platform),
|
|
)
|
|
|
|
|
|
def _resolve_launcher(app_root: Path, platform: str) -> Path | None:
|
|
"""The executable that starts the app, or None when it is ambiguous.
|
|
|
|
One rule per platform, each one "the single launcher among files that
|
|
are not launchers":
|
|
|
|
* macOS — the one file in ``Contents/MacOS``.
|
|
* Windows — the one top-level ``.exe`` that is not the uninstaller.
|
|
* Linux — the one top-level executable that is not an Electron helper
|
|
or a shared library.
|
|
"""
|
|
if platform == "darwin":
|
|
return _only(_files(app_root / "Contents" / "MacOS"))
|
|
|
|
if platform == "win32":
|
|
return _only(
|
|
[
|
|
p
|
|
for p in _files(app_root)
|
|
if p.suffix.lower() == ".exe"
|
|
and not p.name.lower().startswith("uninstall")
|
|
]
|
|
)
|
|
|
|
return _only(
|
|
[
|
|
p
|
|
for p in _files(app_root)
|
|
if p.name not in _LINUX_NON_LAUNCHERS
|
|
and ".so" not in p.name
|
|
and os.access(p, os.X_OK)
|
|
]
|
|
)
|
|
|
|
|
|
def _files(directory: Path) -> list[Path]:
|
|
"""Regular files directly in *directory*; empty when it is unreadable."""
|
|
try:
|
|
return sorted(p for p in directory.iterdir() if p.is_file())
|
|
except OSError:
|
|
return []
|
|
|
|
|
|
def _only(candidates: list[Path]) -> Path | None:
|
|
return candidates[0] if len(candidates) == 1 else None
|
|
|
|
|
|
def launch_detached(
|
|
argv: list[str], *, env: dict[str, str] | None = None, cwd: Path | str | None = None
|
|
) -> int:
|
|
"""Start *argv* as its own process and return its pid.
|
|
|
|
The child outlives this process: it leads a new session on POSIX, and
|
|
on Windows it gets the detach creation flags (new process group, own
|
|
hidden console, breakaway from any job object). Its stdio goes to the
|
|
null device, because the terminal that ran ``hermes desktop`` is free
|
|
to close the moment this call returns.
|
|
"""
|
|
from hermes_cli._subprocess_compat import windows_detach_popen_kwargs
|
|
|
|
child = subprocess.Popen(
|
|
argv,
|
|
cwd=str(cwd) if cwd is not None else None,
|
|
env=env,
|
|
stdin=subprocess.DEVNULL,
|
|
stdout=subprocess.DEVNULL,
|
|
stderr=subprocess.DEVNULL,
|
|
close_fds=True,
|
|
**windows_detach_popen_kwargs(),
|
|
)
|
|
return child.pid
|