From 032cf8438ff9680a5e780315b816dd040738bd31 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Thu, 17 Sep 2026 00:20:50 -0700 Subject: [PATCH] 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). --- hermes_cli/main.py | 1 - hermes_cli/main_desktop.py | 185 ++++----- hermes_cli/update_cmd_deps.py | 33 +- .../test_desktop_install_after_update.py | 379 +++--------------- .../test_update_desktop_stale_warning.py | 6 +- website/docs/getting-started/updating.md | 2 +- 6 files changed, 140 insertions(+), 466 deletions(-) diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 49a9c35a5a..9e713ba3ab 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -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, diff --git a/hermes_cli/main_desktop.py b/hermes_cli/main_desktop.py index 3d5aa5df02..ce72d8a16a 100644 --- a/hermes_cli/main_desktop.py +++ b/hermes_cli/main_desktop.py @@ -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: diff --git a/hermes_cli/update_cmd_deps.py b/hermes_cli/update_cmd_deps.py index f93d6b3e32..ee52dd9909 100644 --- a/hermes_cli/update_cmd_deps.py +++ b/hermes_cli/update_cmd_deps.py @@ -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 diff --git a/tests/hermes_cli/test_desktop_install_after_update.py b/tests/hermes_cli/test_desktop_install_after_update.py index 77958baa2c..ef0fc99a32 100644 --- a/tests/hermes_cli/test_desktop_install_after_update.py +++ b/tests/hermes_cli/test_desktop_install_after_update.py @@ -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() diff --git a/tests/hermes_cli/test_update_desktop_stale_warning.py b/tests/hermes_cli/test_update_desktop_stale_warning.py index 1f01bc8a1c..c4f54c4c39 100644 --- a/tests/hermes_cli/test_update_desktop_stale_warning.py +++ b/tests/hermes_cli/test_update_desktop_stale_warning.py @@ -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( diff --git a/website/docs/getting-started/updating.md b/website/docs/getting-started/updating.md index 82ee38d05c..c5a6bfc3a1 100644 --- a/website/docs/getting-started/updating.md +++ b/website/docs/getting-started/updating.md @@ -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//` 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).