diff --git a/hermes_cli/main_desktop.py b/hermes_cli/main_desktop.py index 9a2f03033f..10f4d86c0f 100644 --- a/hermes_cli/main_desktop.py +++ b/hermes_cli/main_desktop.py @@ -953,6 +953,15 @@ def _install_rebuilt_desktop_app(desktop_dir: Path) -> tuple[list[Path], list[st rebuilt_exe.parents[2], _installed_desktop_apps(), running=_running_macos_app_bundles()) +def _refresh_installed_desktop_apps(desktop_dir: Path) -> None: + """Install the rebuilt bundle over stale installed copies and report each outcome.""" + installed, problems = _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}") + + def _update_owned_macos_bundles(candidates: list[Path]) -> list[Path]: """The existing bundles in *candidates* that only ``hermes update`` keeps current (#52339). @@ -988,6 +997,25 @@ def _installed_desktop_apps() -> list[Path]: return _update_owned_macos_bundles(packaged_gui_app_paths()) +def _installed_desktop_launch_target(desktop_dir: Path, packaged_executable: Path) -> Path: + """The executable ``hermes desktop`` launches: the installed app once it IS the checkout build. + + Finder, the Dock and Spotlight open the installed ``Hermes.app``; launching the ``release/`` + bundle beside it ran the same app from a second path while the installed copy went stale + (#52339). Refresh the installed copies, then launch the first one that matches the checkout + build. The checkout bundle stays the fallback: nothing installed, or a copy that is running + or could not be replaced. + """ + if sys.platform != "darwin": + return packaged_executable + _refresh_installed_desktop_apps(desktop_dir) + rebuilt_hash = _app_asar_hash(packaged_executable.parents[2]) + for app in _installed_desktop_apps(): + if rebuilt_hash is not None and _app_asar_hash(app) == rebuilt_hash: + return app / "Contents" / "MacOS" / "Hermes" + return packaged_executable + + 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`` @@ -1574,7 +1602,8 @@ def cmd_gui(args: argparse.Namespace): print(f"✗ Desktop package build completed but no launchable app was found at: {desktop_dir / 'release'}") print(" Expected an unpacked Electron app for the current OS.") sys.exit(1) - launch_command = _packaged_desktop_launch_command(packaged_executable) + launch_command = _packaged_desktop_launch_command( + _installed_desktop_launch_target(desktop_dir, packaged_executable)) launch_command.extend(config_electron_flags) if getattr(args, "local", False): launch_command.append("--local") diff --git a/hermes_cli/source_build.py b/hermes_cli/source_build.py index 656b82ac54..61190cbe2e 100644 --- a/hermes_cli/source_build.py +++ b/hermes_cli/source_build.py @@ -121,7 +121,7 @@ def build_update_products(project_root: Path, *, desktop: bool) -> None: publish_stage("Building the web UI") build_source_web(project_root, env=env) if desktop: - from hermes_cli.main_desktop import _install_rebuilt_desktop_app, build_prepared_desktop + from hermes_cli.main_desktop import _refresh_installed_desktop_apps, build_prepared_desktop publish_stage("Building the desktop app") build_prepared_desktop( @@ -130,11 +130,7 @@ def build_update_products(project_root: Path, *, desktop: bool) -> None: ) # A current release/ can still sit beside a stale installed copy (an earlier # update rebuilt but never installed); healing must not wait for the next build. - installed, problems = _install_rebuilt_desktop_app(project_root / "apps/desktop") - for app in installed: - print(f" ✓ Installed the rebuilt Desktop app at {app}") - for problem in problems: - print(f" ⚠ {problem}") + _refresh_installed_desktop_apps(project_root / "apps/desktop") # A configured memory provider that no longer ships in core is installed from the # catalog for every profile home sharing this venv (config, data and tool names # unchanged). The update must finish even if the migration blows up. diff --git a/tests/hermes_cli/test_gui_command.py b/tests/hermes_cli/test_gui_command.py index 5539631ae0..7d08d6677b 100644 --- a/tests/hermes_cli/test_gui_command.py +++ b/tests/hermes_cli/test_gui_command.py @@ -176,6 +176,42 @@ def test_source_launch_reads_bom_electron_path_without_provisioning(tmp_path, mo assert len(calls) == 1 +def _stamped_macos_bundle(app: Path, asar: bytes) -> Path: + (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) + (app / "Contents" / "Resources" / "install-stamp.json").write_text('{"updateMechanism": "self"}') + return app + + +@pytest.mark.platforms("macos") +def test_packaged_launch_opens_the_refreshed_installed_app(tmp_path, monkeypatch): + """#52339: Finder and the Dock open the installed Hermes.app, so ``hermes desktop`` must launch + that copy (brought up to the checkout build) instead of a second bundle under release/.""" + import shutil + + root = _make_desktop_tree(tmp_path) + _stamped_macos_bundle(root / "apps" / "desktop" / "release" / "mac-arm64" / "Hermes.app", b"checkout build") + installed = _stamped_macos_bundle(tmp_path / "Applications" / "Hermes.app", b"stale build") + monkeypatch.setattr("hermes_cli.gui_uninstall.packaged_gui_app_paths", lambda: [installed]) + monkeypatch.setattr("hermes_constants.get_default_hermes_root", lambda **kw: tmp_path) # root is its hermes-agent + monkeypatch.setattr(main_desktop, "_stage_macos_bundle_copy", lambda src, dst: shutil.copytree(src, dst, symlinks=True)) + monkeypatch.setattr(main_desktop, "_running_macos_app_bundles", lambda: set()) + monkeypatch.setattr(cli_main, "PROJECT_ROOT", root) + monkeypatch.setattr(main_desktop, "_desktop_launch_env", lambda args: ({}, [])) + calls = [] + monkeypatch.setattr(main_desktop.subprocess, "run", + lambda cmd, **kw: calls.append(cmd) or subprocess.CompletedProcess(cmd, 0)) + + with pytest.raises(SystemExit) as exit_info: + main_desktop.cmd_gui(_ns(skip_build=True)) + + assert exit_info.value.code == 0 + assert calls == [[str(installed / "Contents" / "MacOS" / "Hermes")]] + assert (installed / "Contents" / "Resources" / "app.asar").read_bytes() == b"checkout build" + + def test_packaged_renderer_bom_does_not_bypass_entry_validation(tmp_path): import json import struct