fix(update): scope the installed-bundle refresh to macOS, keep the signature, never swap under a live app

Rework of the salvaged #59942 hook so it fixes the whole #52339 class without
regressing what the Desktop updater already gets right:

- macOS only. The Windows arm rmtree'd a possibly-running NSIS install
  (partial deletion under a lock) and windows.ps1 already owns that swap;
  Linux packages stay with their package manager.
- No re-sign. The rebuilt release/ bundle already carries the stable local
  signing identity from _desktop_macos_relaunchable_fixup; a deep `codesign -s -`
  on the installed copy replaced it with a fresh ad-hoc cdhash and reset every
  TCC grant. ditto preserves the signature, so nothing is signed here.
- Running bundles are reported, not swapped: Electron loads app.asar and helper
  apps lazily, so renaming the bundle away and deleting the old tree crashes
  the live app. The detached updater waits for exit; a terminal `hermes update`
  with the app open now prints what to do instead.
- Failures are printed as warnings; the old `Path | None` return read every
  failure as "Desktop app up to date".
- The refresh also runs on the "build stamp current" path, so a stale
  /Applications copy left by an earlier update heals on the next `hermes update`
  even when there is nothing to rebuild.
- Core is host-independent (_install_rebuilt_macos_bundles takes paths as data);
  the two invariant tests run on every OS instead of `skipif(darwin)` tests that
  ran nowhere.

Covers the Desktop-button path too: posix.sh runs `hermes update`, so an app
running from apps/desktop/release/ now refreshes the /Applications copy Finder
launches (the Discord report: new shell right after the update, old shell on
the next Dock launch).
This commit is contained in:
teknium1
2026-09-17 00:20:50 -07:00
committed by Teknium
parent d49fda1d15
commit 032cf8438f
6 changed files with 140 additions and 466 deletions

View File

@@ -808,7 +808,6 @@ from hermes_cli.main_desktop import (
)
from hermes_cli.main_desktop import ( # frozen updater surface: update_cmd*.py resolve these via _m()
_desktop_build_needed,
_desktop_bundle_install_supported,
_desktop_dist_exists,
_desktop_macos_relaunchable_fixup,
_desktop_packaged_executable,

View File

@@ -1064,35 +1064,6 @@ def _app_asar_hash(app_path: Path) -> str | None:
return None
def _macos_adhoc_sign_bundle(app: Path) -> None:
"""Clear quarantine and apply a deep ad-hoc signature on a macOS .app bundle.
Mirrors what ``_desktop_macos_relaunchable_fixup`` does for the release/
copy, but operates on an arbitrary bundle path (e.g. /Applications/Hermes.app).
No-op when a real signing identity is configured or off-macOS. Best-effort.
"""
if sys.platform != "darwin":
return
if os.environ.get("CSC_LINK") or os.environ.get("APPLE_SIGNING_IDENTITY"):
return
if not str(app).endswith(".app") or not app.is_dir():
return
codesign = shutil.which("codesign")
if not codesign:
return
try:
subprocess.run(["xattr", "-cr", str(app)], check=False)
subprocess.run([codesign, "--force", "--deep", "--sign", "-", str(app)], check=False)
except Exception as exc:
logger.debug("macOS ad-hoc signing of %s skipped: %s", app, exc)
def _desktop_bundle_install_supported() -> bool:
"""Return whether the current platform has a copyable installed bundle."""
platform = sys.platform
return platform == "darwin" or platform == "win32"
def _swap_in_new_macos_bundle(tmp: Path, target: Path, old: Path) -> None:
"""Move a staged macOS bundle into place without losing the old bundle."""
moved_old = False
@@ -1124,104 +1095,82 @@ def _swap_in_new_macos_bundle(tmp: Path, target: Path, old: Path) -> None:
shutil.rmtree(old, ignore_errors=True)
def _install_rebuilt_desktop_app(desktop_dir: Path) -> Path | None:
"""Install the freshly rebuilt desktop app to the system install location.
def _running_macos_app_bundles() -> set[Path]:
"""``.app`` bundles of every live Hermes Desktop process. A running bundle is never swapped
under: Electron loads ``app.asar`` chunks and helper apps lazily, so renaming its bundle away
and deleting the old tree crashes the live app (the detached updater waits for it to exit)."""
import psutil # noqa: PLC0415
bundles: set[Path] = set()
for proc in psutil.process_iter(["exe"]):
exe = proc.info.get("exe") or ""
if exe.endswith("/Contents/MacOS/Hermes"):
bundles.add(Path(exe).resolve().parents[2])
return bundles
``hermes desktop --build-only`` (called by ``hermes update``) rebuilds the
Electron app into ``apps/desktop/release/`` but does NOT copy it to the
standard install location (e.g. ``/Applications/Hermes.app``). The
in-app updater in ``main.cjs`` handles the swap via a detached script;
this function covers the CLI ``hermes update`` path so the installed app
does not go stale.
Only installs when the current platform has a copyable installed bundle
(a macOS ``.app`` or Windows NSIS directory), a rebuilt package exists,
and an installed copy already exists at a standard location. Linux
AppImage/deb/rpm installs remain owned by their original package mechanism;
``packaged_gui_app_paths`` only returns ``.desktop`` launchers there.
def _stage_macos_bundle_copy(src: Path, dst: Path) -> None:
"""``ditto`` copies a bundle with its signature, xattrs and symlinks intact (``shutil`` drops
the resource-fork metadata codesign verifies)."""
subprocess.run(["/usr/bin/ditto", str(src), str(dst)], check=True, capture_output=True)
Compares the macOS ``app.asar`` SHA-256 to avoid unnecessary copies, then
clears quarantine and re-applies ad-hoc signing. Returns the installed path
on success, or ``None`` when no install was needed or possible.
def _install_rebuilt_desktop_app(desktop_dir: Path) -> tuple[list[Path], list[str]]:
"""Copy the rebuilt macOS bundle over every stale installed ``Hermes.app`` (#52339).
``hermes desktop --build-only`` (what ``hermes update`` runs) packages into
``apps/desktop/release/`` only. Finder, the Dock and Spotlight launch the copy in
``/Applications`` (or ``~/Applications``), so without this step every update leaves the
installed shell one build behind the backend it boots. The detached Desktop updater swaps
only the bundle it was launched from, so an app running from ``release/`` never refreshed
the installed copy either.
Returns ``(installed, problems)``: bundles that were replaced, and one user-facing line per
bundle that could not be (running, copy or swap failure). Both empty means every installed
copy was already current.
"""
if not _desktop_bundle_install_supported():
logger.debug(
"Skipping post-update Desktop bundle install on unsupported platform %s",
sys.platform,
)
return None
if sys.platform != "darwin":
return [], []
rebuilt_exe = _desktop_packaged_executable(desktop_dir)
if rebuilt_exe is None:
return None
return [], []
from hermes_cli.gui_uninstall import packaged_gui_app_paths # noqa: PLC0415
# .../Hermes.app/Contents/MacOS/Hermes -> .../Hermes.app
return _install_rebuilt_macos_bundles(
rebuilt_exe.parents[2], packaged_gui_app_paths(), running=_running_macos_app_bundles())
# Resolve the rebuilt .app bundle directory
rebuilt_app: Path | None = None
if sys.platform == "darwin":
# exe = .../Hermes.app/Contents/MacOS/Hermes -> app = .../Hermes.app
if len(rebuilt_exe.parents) >= 2 and str(rebuilt_exe.parents[2]).endswith(".app"):
rebuilt_app = rebuilt_exe.parents[2]
elif sys.platform == "win32":
# win-unpacked is a directory, not a .app bundle
rebuilt_app = rebuilt_exe.parent
else:
return None
if rebuilt_app is None or not rebuilt_app.is_dir():
return None
# Find existing installed copies at standard locations
from hermes_cli.gui_uninstall import packaged_gui_app_paths
installed_apps = [
path
for path in packaged_gui_app_paths()
if path.is_dir()
and (sys.platform == "win32" or path.suffix.casefold() == ".app")
]
if not installed_apps:
return None # nothing installed → nothing to update
rebuilt_hash = _app_asar_hash(rebuilt_app) if sys.platform == "darwin" else None
for installed_app in installed_apps:
# Skip if the installed copy is already current
if sys.platform == "darwin" and rebuilt_hash:
installed_hash = _app_asar_hash(installed_app)
if installed_hash and installed_hash == rebuilt_hash:
continue # already up to date
# On macOS, use ditto for a metadata-preserving staged copy, then move
# the installed bundle aside and atomically-as-possible swap the staged
# copy in. The old bundle is restored if either rename fails. Windows
# installs are directories, so copytree replaces their contents.
try:
if sys.platform == "darwin":
ditto = shutil.which("ditto")
if not ditto:
continue
tmp = installed_app.parent / f"{installed_app.name}.hermes-update-new"
old = installed_app.parent / f"{installed_app.name}.hermes-update-old"
shutil.rmtree(tmp, ignore_errors=True)
shutil.rmtree(old, ignore_errors=True)
subprocess.run([ditto, str(rebuilt_app), str(tmp)], check=True, capture_output=True)
_swap_in_new_macos_bundle(tmp, installed_app, old)
else:
if installed_app.is_dir():
shutil.rmtree(installed_app, ignore_errors=True)
shutil.copytree(rebuilt_app, installed_app, dirs_exist_ok=True)
# Apply macOS quarantine clear + ad-hoc signing directly on
# the installed bundle (not the release/ copy).
if sys.platform == "darwin":
_macos_adhoc_sign_bundle(installed_app)
return installed_app
except Exception as exc:
logger.debug("Desktop app install to %s failed: %s", installed_app, exc)
def _install_rebuilt_macos_bundles(
rebuilt_app: Path, candidates: list[Path], *, running: set[Path]) -> tuple[list[Path], list[str]]:
"""Stage-and-swap ``rebuilt_app`` over each existing bundle in ``candidates`` whose ``app.asar``
differs. The rebuilt bundle already carries the stable local signing identity and no
quarantine xattr (``_desktop_macos_relaunchable_fixup``); ``ditto`` preserves both, so nothing
is re-signed here and TCC grants survive."""
rebuilt_hash = _app_asar_hash(rebuilt_app)
if rebuilt_hash is None:
return [], []
installed: list[Path] = []
problems: list[str] = []
for app in candidates:
if not app.is_dir() or _app_asar_hash(app) == rebuilt_hash:
continue
return None
if app.resolve() in running:
problems.append(
f"{app} is running and was not refreshed; quit Hermes Desktop and run "
"`hermes update` again (or update from inside the app)")
continue
tmp = app.parent / f"{app.name}.hermes-update-new"
old = app.parent / f"{app.name}.hermes-update-old"
shutil.rmtree(tmp, ignore_errors=True)
shutil.rmtree(old, ignore_errors=True)
try:
_stage_macos_bundle_copy(rebuilt_app, tmp)
_swap_in_new_macos_bundle(tmp, app, old)
except (OSError, subprocess.CalledProcessError) as exc:
shutil.rmtree(tmp, ignore_errors=True)
problems.append(f"{app} could not be replaced ({exc}); the previous app was kept")
continue
installed.append(app)
return installed, problems
def _force_adhoc_macos_signing(env: dict, *, source_mode: bool) -> bool:

View File

@@ -852,6 +852,18 @@ def _desktop_app_present(desktop_dir: Path) -> bool:
or _m()._desktop_dist_exists(desktop_dir))
def _report_installed_desktop_app(desktop_dir: Path) -> None:
"""Refresh the installed macOS bundle from release/ and print the outcome (#52339)."""
from hermes_cli.update_cmd import _m
installed, problems = _m()._install_rebuilt_desktop_app(desktop_dir)
for app in installed:
print(f" ✓ Installed the rebuilt Desktop app at {app}")
for problem in problems:
print(f" ⚠ {problem}")
if not installed and not problems:
print(" ✓ Desktop app up to date")
def _rebuild_desktop_after_update(
desktop_dir: Path, *, had_desktop_app_before_update: bool) -> bool:
"""Rebuild an installed Desktop app when its source or artifact changed. Returns ``False``
@@ -878,7 +890,9 @@ def _rebuild_desktop_after_update(
except Exception:
skip_desktop_build = False
if skip_desktop_build:
print(" ✓ Desktop app up to date")
# A current release/ can still sit beside a stale /Applications copy (an earlier update
# rebuilt but never installed); healing it must not wait for the next source change.
_report_installed_desktop_app(desktop_dir)
return True
desktop_build_cmd = [sys.executable, "-m", "hermes_cli.main", "desktop", "--build-only"]
@@ -900,22 +914,7 @@ def _rebuild_desktop_after_update(
from hermes_constants import display_hermes_home as _dhh
print(f" Full build log: {_dhh()}/logs/update.log")
return False
# The build succeeded. `--build-only` rebuilds into the
# release/ tree but does NOT install the rebuilt app to the
# system location (e.g. /Applications/Hermes.app). The
# in-app updater handles that swap itself, but a CLI
# `hermes update` otherwise leaves the installed app stale.
if _m()._desktop_bundle_install_supported():
installed = _m()._install_rebuilt_desktop_app(desktop_dir)
if installed:
print(f" ✓ Desktop app updated at {installed}")
else:
print(" ✓ Desktop app up to date")
else:
print(
" ✓ Desktop app rebuilt; automatic installed-package "
f"replacement is unsupported on {sys.platform}"
)
_report_installed_desktop_app(desktop_dir)
return True

View File

@@ -1,343 +1,74 @@
"""Tests for _install_rebuilt_desktop_app — CLI update installs rebuilt app.
"""``hermes update`` refreshes the installed macOS ``Hermes.app`` from the rebuilt bundle (#52339).
Verifies that ``hermes update`` (via ``_install_rebuilt_desktop_app``) copies
the freshly rebuilt desktop app to the system install location when the
installed copy is stale, and skips when hashes already match.
``hermes desktop --build-only`` only packages into ``apps/desktop/release/``; Finder launches the
copy in ``/Applications``. These pin the contract of ``_install_rebuilt_macos_bundles``: a stale
installed copy is replaced, a current one and a running one are never touched, and a failed swap
leaves the previous bundle launchable.
"""
import hashlib
import shutil
import sys
from pathlib import Path
from unittest.mock import patch, MagicMock
import pytest
from hermes_cli import main_desktop
@pytest.fixture
def fake_desktop_dir(tmp_path):
"""A fake desktop_dir with a rebuilt macOS app bundle."""
release = tmp_path / "apps" / "desktop" / "release" / "mac-arm64" / "Hermes.app"
# Mimic the real structure: Contents/MacOS/Hermes + Contents/Resources/app.asar
exe = release / "Contents" / "MacOS" / "Hermes"
exe.parent.mkdir(parents=True)
exe.write_bytes(b"\xcf\xfa\xed\xfe") # minimal Mach-O magic
asar = release / "Contents" / "Resources" / "app.asar"
asar.parent.mkdir(parents=True)
asar.write_bytes(b"rebuilt-asar-content")
return tmp_path / "apps" / "desktop"
def _bundle(root: Path, asar: bytes) -> Path:
app = root / "Hermes.app"
(app / "Contents" / "MacOS").mkdir(parents=True)
(app / "Contents" / "MacOS" / "Hermes").write_bytes(b"\xcf\xfa\xed\xfe")
(app / "Contents" / "Resources").mkdir()
(app / "Contents" / "Resources" / "app.asar").write_bytes(asar)
return app
def _asar(app: Path) -> bytes:
return (app / "Contents" / "Resources" / "app.asar").read_bytes()
@pytest.fixture
def fake_installed_app(tmp_path):
"""A fake installed Hermes.app with a DIFFERENT app.asar."""
installed = tmp_path / "Applications" / "Hermes.app"
exe = installed / "Contents" / "MacOS" / "Hermes"
exe.parent.mkdir(parents=True)
exe.write_bytes(b"\xcf\xfa\xed\xfe")
asar = installed / "Contents" / "Resources" / "app.asar"
asar.parent.mkdir(parents=True)
asar.write_bytes(b"stale-asar-content")
return installed
def rebuilt(tmp_path, monkeypatch):
monkeypatch.setattr(
main_desktop, "_stage_macos_bundle_copy",
lambda src, dst: shutil.copytree(src, dst, symlinks=True))
return _bundle(tmp_path / "apps" / "desktop" / "release" / "mac-arm64", b"rebuilt")
def _sha256(data: bytes) -> str:
return hashlib.sha256(data).hexdigest()
def test_stale_bundle_is_replaced_current_and_running_are_left_alone(rebuilt, tmp_path):
stale = _bundle(tmp_path / "Applications", b"stale")
current = _bundle(tmp_path / "home" / "Applications", b"rebuilt")
running = _bundle(tmp_path / "Volumes" / "Applications", b"older")
current_marker = current / "Contents" / "marker"
current_marker.write_text("untouched")
installed, problems = main_desktop._install_rebuilt_macos_bundles(
rebuilt, [stale, current, running, tmp_path / "missing" / "Hermes.app"],
running={running.resolve()})
assert installed == [stale]
assert _asar(stale) == b"rebuilt"
assert not (stale.parent / "Hermes.app.hermes-update-old").exists()
assert not (stale.parent / "Hermes.app.hermes-update-new").exists()
assert current_marker.read_text() == "untouched"
# A live app is reported, never swapped under.
assert _asar(running) == b"older"
assert len(problems) == 1 and str(running) in problems[0] and "quit Hermes Desktop" in problems[0]
def _copy_ditto_bundle(command, **_kwargs):
"""Emulate ``ditto SOURCE DEST`` with an ordinary directory copy."""
shutil.copytree(Path(command[1]), Path(command[2]))
return MagicMock(returncode=0)
def test_failed_swap_keeps_the_previous_bundle_launchable(rebuilt, tmp_path, monkeypatch):
stale = _bundle(tmp_path / "Applications", b"stale")
real_rename = Path.rename
def fail_final_rename(self, target):
if self.name.endswith(".hermes-update-new"):
raise OSError("simulated rename failure")
return real_rename(self, target)
monkeypatch.setattr(Path, "rename", fail_final_rename)
@pytest.mark.skipif(sys.platform != "darwin", reason="macOS .app bundle test")
def test_install_when_stale(fake_desktop_dir, fake_installed_app):
"""_install_rebuilt_desktop_app copies the rebuilt app when hashes differ."""
from hermes_cli.main_desktop import _install_rebuilt_desktop_app
installed, problems = main_desktop._install_rebuilt_macos_bundles(rebuilt, [stale], running=set())
# Patch _desktop_packaged_executable to find our fake rebuilt app
fake_rebuilt_exe = (
fake_desktop_dir
/ "release"
/ "mac-arm64"
/ "Hermes.app"
/ "Contents"
/ "MacOS"
/ "Hermes"
)
with (
patch(
"hermes_cli.main._desktop_packaged_executable",
return_value=fake_rebuilt_exe,
),
patch(
"hermes_cli.gui_uninstall.packaged_gui_app_paths",
return_value=[fake_installed_app],
),
patch("hermes_cli.main_desktop._macos_adhoc_sign_bundle") as mock_sign,
):
# Let ditto/codesign run for real — they exist on macOS
result = _install_rebuilt_desktop_app(fake_desktop_dir)
assert result == fake_installed_app
mock_sign.assert_called_once_with(fake_installed_app)
# The installed app.asar should now match the rebuilt one
rebuilt_asar = (
fake_desktop_dir
/ "release"
/ "mac-arm64"
/ "Hermes.app"
/ "Contents"
/ "Resources"
/ "app.asar"
).read_bytes()
installed_asar = (
fake_installed_app / "Contents" / "Resources" / "app.asar"
).read_bytes()
assert rebuilt_asar == installed_asar
@pytest.mark.skipif(sys.platform != "darwin", reason="macOS .app bundle test")
def test_skip_when_hashes_match(fake_desktop_dir, tmp_path):
"""_install_rebuilt_desktop_app returns None when hashes already match."""
from hermes_cli.main_desktop import _install_rebuilt_desktop_app, _app_asar_hash
# Create an installed app with the SAME app.asar as the rebuilt one
rebuilt_asar = (
fake_desktop_dir
/ "release"
/ "mac-arm64"
/ "Hermes.app"
/ "Contents"
/ "Resources"
/ "app.asar"
).read_bytes()
installed = tmp_path / "Applications" / "Hermes.app"
asar = installed / "Contents" / "Resources" / "app.asar"
asar.parent.mkdir(parents=True)
asar.write_bytes(rebuilt_asar)
fake_rebuilt_exe = (
fake_desktop_dir
/ "release"
/ "mac-arm64"
/ "Hermes.app"
/ "Contents"
/ "MacOS"
/ "Hermes"
)
with (
patch(
"hermes_cli.main._desktop_packaged_executable",
return_value=fake_rebuilt_exe,
),
patch(
"hermes_cli.gui_uninstall.packaged_gui_app_paths",
return_value=[installed],
),
patch("shutil.which", return_value="/usr/bin/ditto"),
patch("subprocess.run") as mock_run,
):
result = _install_rebuilt_desktop_app(fake_desktop_dir)
assert result is None
# ditto should NOT have been called
mock_run.assert_not_called()
def test_returns_none_when_no_installed_app(fake_desktop_dir):
"""_install_rebuilt_desktop_app returns None when nothing is installed."""
from hermes_cli.main_desktop import _install_rebuilt_desktop_app
fake_rebuilt_exe = (
fake_desktop_dir
/ "release"
/ "mac-arm64"
/ "Hermes.app"
/ "Contents"
/ "MacOS"
/ "Hermes"
)
with (
patch(
"hermes_cli.main._desktop_packaged_executable",
return_value=fake_rebuilt_exe,
),
patch(
"hermes_cli.gui_uninstall.packaged_gui_app_paths",
return_value=[],
),
):
result = _install_rebuilt_desktop_app(fake_desktop_dir)
assert result is None
def test_returns_none_when_no_rebuilt_app(fake_desktop_dir):
"""_install_rebuilt_desktop_app returns None when no rebuilt app exists."""
from hermes_cli.main_desktop import _install_rebuilt_desktop_app
with patch(
"hermes_cli.main._desktop_packaged_executable",
return_value=None,
):
result = _install_rebuilt_desktop_app(fake_desktop_dir)
assert result is None
def test_app_asar_hash_consistency(fake_desktop_dir):
"""_app_asar_hash returns consistent hashes for the same content."""
from hermes_cli.main_desktop import _app_asar_hash
rebuilt_app = fake_desktop_dir / "release" / "mac-arm64" / "Hermes.app"
h1 = _app_asar_hash(rebuilt_app)
h2 = _app_asar_hash(rebuilt_app)
assert h1 is not None
assert h1 == h2
assert len(h1) == 64 # SHA-256 hex
def test_app_asar_hash_none_for_missing(tmp_path):
"""_app_asar_hash returns None when app.asar doesn't exist."""
from hermes_cli.main_desktop import _app_asar_hash
fake_app = tmp_path / "Fake.app"
fake_app.mkdir()
assert _app_asar_hash(fake_app) is None
def test_backup_rename_failure_leaves_installed_bundle_untouched(
fake_desktop_dir, fake_installed_app, monkeypatch
):
"""Failure to park the old bundle must not delete or replace it."""
from hermes_cli import main_desktop as hermes_main
rebuilt_exe = (
fake_desktop_dir
/ "release"
/ "mac-arm64"
/ "Hermes.app"
/ "Contents"
/ "MacOS"
/ "Hermes"
)
original_rename = Path.rename
def fail_backup_rename(path, target):
if path == fake_installed_app:
raise OSError("cannot move installed bundle aside")
return original_rename(path, target)
monkeypatch.setattr(Path, "rename", fail_backup_rename)
with (
patch.object(hermes_main.sys, "platform", "darwin"),
patch.object(
hermes_main,
"_desktop_packaged_executable",
return_value=rebuilt_exe,
),
patch(
"hermes_cli.gui_uninstall.packaged_gui_app_paths",
return_value=[fake_installed_app],
),
patch.object(hermes_main.shutil, "which", return_value="/usr/bin/ditto"),
patch.object(hermes_main.subprocess, "run", side_effect=_copy_ditto_bundle),
patch.object(hermes_main, "_macos_adhoc_sign_bundle") as mock_sign,
):
result = hermes_main._install_rebuilt_desktop_app(fake_desktop_dir)
assert result is None
assert (
fake_installed_app / "Contents" / "Resources" / "app.asar"
).read_bytes() == b"stale-asar-content"
assert not fake_installed_app.with_name("Hermes.app.hermes-update-new").exists()
assert not fake_installed_app.with_name("Hermes.app.hermes-update-old").exists()
mock_sign.assert_not_called()
def test_new_bundle_rename_failure_restores_installed_bundle(
fake_desktop_dir, fake_installed_app, monkeypatch
):
"""Failure to install the staged bundle must roll the old bundle back."""
from hermes_cli import main_desktop as hermes_main
rebuilt_exe = (
fake_desktop_dir
/ "release"
/ "mac-arm64"
/ "Hermes.app"
/ "Contents"
/ "MacOS"
/ "Hermes"
)
tmp = fake_installed_app.with_name("Hermes.app.hermes-update-new")
old = fake_installed_app.with_name("Hermes.app.hermes-update-old")
original_rename = Path.rename
def fail_new_bundle_rename(path, target):
if path == tmp and target == fake_installed_app:
raise OSError("cannot move staged bundle into place")
return original_rename(path, target)
monkeypatch.setattr(Path, "rename", fail_new_bundle_rename)
with (
patch.object(hermes_main.sys, "platform", "darwin"),
patch.object(
hermes_main,
"_desktop_packaged_executable",
return_value=rebuilt_exe,
),
patch(
"hermes_cli.gui_uninstall.packaged_gui_app_paths",
return_value=[fake_installed_app],
),
patch.object(hermes_main.shutil, "which", return_value="/usr/bin/ditto"),
patch.object(hermes_main.subprocess, "run", side_effect=_copy_ditto_bundle),
patch.object(hermes_main, "_macos_adhoc_sign_bundle") as mock_sign,
):
result = hermes_main._install_rebuilt_desktop_app(fake_desktop_dir)
assert result is None
assert (
fake_installed_app / "Contents" / "Resources" / "app.asar"
).read_bytes() == b"stale-asar-content"
assert not tmp.exists()
assert not old.exists()
mock_sign.assert_not_called()
def test_linux_desktop_launchers_are_not_install_payloads(fake_desktop_dir, tmp_path):
"""Linux .desktop launchers must never be copytree destinations."""
from hermes_cli import main_desktop as hermes_main
rebuilt_exe = fake_desktop_dir / "release" / "linux-unpacked" / "hermes"
rebuilt_exe.parent.mkdir(parents=True)
rebuilt_exe.write_bytes(b"linux executable")
launcher = tmp_path / "share" / "applications" / "hermes.desktop"
launcher.parent.mkdir(parents=True)
launcher.write_text("[Desktop Entry]\nExec=hermes\n", encoding="utf-8")
with (
patch.object(hermes_main.sys, "platform", "linux"),
patch.object(
hermes_main,
"_desktop_packaged_executable",
return_value=rebuilt_exe,
),
patch(
"hermes_cli.gui_uninstall.packaged_gui_app_paths",
return_value=[launcher],
) as mock_paths,
patch.object(hermes_main.shutil, "copytree") as mock_copytree,
):
result = hermes_main._install_rebuilt_desktop_app(fake_desktop_dir)
assert result is None
assert launcher.read_text(encoding="utf-8") == "[Desktop Entry]\nExec=hermes\n"
mock_paths.assert_not_called()
mock_copytree.assert_not_called()
assert installed == []
assert len(problems) == 1 and "previous app was kept" in problems[0]
assert stale.is_dir() and _asar(stale) == b"stale"
assert not (stale.parent / "Hermes.app.hermes-update-new").exists()

View File

@@ -52,13 +52,9 @@ def desktop_env(tmp_path, monkeypatch):
calls["builds"] += 1
return _Result(1, stdout="Error: [stage-native-deps] boom")
@staticmethod
def _desktop_bundle_install_supported():
return True
@staticmethod
def _install_rebuilt_desktop_app(_desktop_dir):
return None
return [], []
monkeypatch.setattr(update_cmd, "_m", lambda: _FakeMain)
monkeypatch.setattr(

View File

@@ -40,7 +40,7 @@ When you run `hermes update`, the following steps occur:
After this point the updater re-executes itself on the freshly pulled code (`update.log` shows `=== hermes update continued on the pulled code ===`), so the remaining steps never mix old and new modules in one process. If you see two `hermes update` processes for a moment, that is the hand-off.
4. **Dependency install** — runs `uv pip install -e ".[all]"` to pick up new or changed dependencies
5. **Config migration** — detects new config options added since your version and prompts you to set them
6. **Desktop rebuild (stage-and-swap)** — if the Hermes Desktop app was built from this checkout, it is rebuilt so the GUI matches the new code. The rebuild packs into a temporary staging directory next to `apps/desktop/release/`, verifies the staged app, and only then renames it over the previous build. A rebuild that fails at any point — corrupt Electron download, missing dependency, disk full — leaves the previous app untouched and launchable; the update reports `⚠ Update partially complete` and `hermes desktop` retries the rebuild.
6. **Desktop rebuild (stage-and-swap)** — if the Hermes Desktop app was built from this checkout, it is rebuilt so the GUI matches the new code. The rebuild packs into a temporary staging directory next to `apps/desktop/release/`, verifies the staged app, and only then renames it over the previous build. A rebuild that fails at any point — corrupt Electron download, missing dependency, disk full — leaves the previous app untouched and launchable; the update reports `⚠ Update partially complete` and `hermes desktop` retries the rebuild. On macOS the rebuilt bundle is then copied (with `ditto`, signature intact) over a stale `/Applications/Hermes.app` or `~/Applications/Hermes.app`, so the copy Finder and the Dock launch matches the backend; an installed copy that is currently running is left alone and the update tells you to quit it and run `hermes update` again.
7. **Gateway auto-restart** — running gateways are refreshed after the update completes so the new code takes effect immediately. Service-managed gateways (systemd on Linux, launchd on macOS) are restarted through the service manager. Manual gateways are relaunched automatically when Hermes can map the running PID back to a profile. Manually-launched `hermes serve` / `hermes dashboard` backends (for example a network-bound serve powering a remote Desktop) are handled the same way: each backend records its bind address in the install's spawn ledger at startup, so the update stops it before the code swap and relaunches it afterward on the **same host and port** — a remote Desktop pointed at that endpoint reconnects instead of stranding. Backends owned by a running Desktop app are left to the app's own respawn.
8. **Multiplex migration (multi-profile installs)** — once the fleet is verified on the new code, an install with two or more profiles that still run **one gateway per profile** is folded into a single multiplexed default gateway when nothing blocks it (same as `hermes gateway migrate --multiplex --yes`); if a blocker exists (a bot token shared by two profiles, a secondary profile binding a port with no `/p/<profile>/` ingress) the update prints the blockers with their fixes and changes nothing. Single-profile installs are never touched. See [Migrating from per-profile gateways](../user-guide/multi-profile-gateways.md#migrating-from-per-profile-gateways).