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:
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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).
|
||||
|
||||
|
||||
Reference in New Issue
Block a user