From a6686cc396e1b3a43aba63f6539c77d6c29be52b Mon Sep 17 00:00:00 2001 From: Octopustank Date: Fri, 25 Sep 2026 13:51:23 +0800 Subject: [PATCH] fix(desktop): carry the window app id in the Linux launcher entry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Packaged windows claim `com.nousresearch.hermes` as their Wayland app id (electron-builder bakes product-identity.cjs's `appId` into extraMetadata.desktopName; Electron hands that string to the compositor verbatim), while the entry was written as `hermes.desktop` with `StartupWMClass=Hermes` — so GNOME matched neither StartupWMClass nor a `.desktop` file name and every launch fell back to the placeholder icon, with the raw app id in the tooltip. - write `.desktop` with `StartupWMClass=` (Name= stays "Hermes") - retire a leftover `hermes.desktop` once the new entry is on disk, and only when the file still names this app and launcher management is enabled; foreign files at that path are left alone - nix/desktop.nix derives the entry file name from the module instead of hardcoding it - tests: the installed entry carries the app id, legacy retirement, foreign-file preservation, opt-out preservation, plus a node probe asserting APP_ID == product-identity.cjs appId Known consequence: an existing taskbar pin points at the old entry id and has to be re-added once. --- hermes_cli/linux_desktop_entry.py | 68 ++++++++++--- nix/desktop.nix | 3 +- tests/hermes_cli/test_linux_desktop_entry.py | 99 ++++++++++++++++++- .../test_linux_launcher_management.py | 4 +- 4 files changed, 153 insertions(+), 21 deletions(-) diff --git a/hermes_cli/linux_desktop_entry.py b/hermes_cli/linux_desktop_entry.py index 3d31361bfa..e8cbf63064 100644 --- a/hermes_cli/linux_desktop_entry.py +++ b/hermes_cli/linux_desktop_entry.py @@ -1,4 +1,4 @@ -"""Install and remove the Linux desktop entry (``hermes.desktop``). +"""Install and remove the Linux desktop entry (``.desktop``). The entry must be launch-context independent: ``Exec=`` is an absolute launcher that survives the venv (no ``#!/usr/bin/env python3`` escapes, no checkout-internal argv[0]), and ``Icon=`` is the @@ -21,7 +21,17 @@ import time from pathlib import Path from typing import Callable, Mapping, Optional -DESKTOP_ENTRY_NAME = "hermes.desktop" +# Identity the packaged app claims for its window: electron-builder bakes product-identity.cjs's +# `appId` into extraMetadata.desktopName, and Electron hands that string to the compositor +# verbatim (Wayland app_id, CHROME_DESKTOP). GNOME links a window to a launcher by StartupWMClass +# or by a `.desktop` file name, so the entry has to carry the same id — under the old +# "hermes.desktop" name a packaged launch matches neither rung and lands on the placeholder icon. +APP_ID = "com.nousresearch.hermes" +DESKTOP_ENTRY_NAME = f"{APP_ID}.desktop" + +# Entry name written before the app-id rename; a successful install retires it so the menu does +# not list Hermes twice (see _remove_legacy_desktop_entry). +LEGACY_DESKTOP_ENTRY_NAME = "hermes.desktop" # XDG startup notification: set by an app-grid / menu launch, absent for terminal and detached # (updater relaunch) launches. See launched_from_shell(). @@ -221,7 +231,7 @@ def _resolve_hermes_bin_for_desktop_entry( # A resolver miss (argv[0] is ``-c`` under ``python -m`` on a cold relaunch AND PATH has no # ``hermes``) must NOT return None here: that skipped the durable-wrapper probe below and persisted # the module form, so the entry's bytes flipped on every alternating launch context — and - # gnome-shell 50.x crashes when hermes.desktop changes while its ShellApp is STARTING (#110885). + # gnome-shell 50.x crashes when the entry changes while its ShellApp is STARTING (#110885). # ``primary is None`` implies ``rerouted is None`` (the rerun only hides argv[0]), so only the # probe can still find anything. if primary and rerouted is not None and not _inside_checkout( @@ -232,7 +242,7 @@ def _resolve_hermes_bin_for_desktop_entry( # desktop-update hand-off hands the updater /venv/bin at the front of PATH, so # persisting a reroute to the venv console script pins the entry to WHO wrote it. The next # DE-launched context re-resolves to the durable wrapper and flips the bytes back — and - # every flip rewrites hermes.desktop, which arms the gnome-shell 50.x crash this function's + # every flip rewrites the entry, which arms the gnome-shell 50.x crash this function's # callers guard against when the write lands inside a launch's STARTING window. Fall # through to the durable probe below, exactly as a PATH miss does. @@ -424,7 +434,7 @@ def render_desktop_entry(exec_command: str, icon: str) -> str: "Terminal=false\n" "Categories=Utility;\n" "StartupNotify=true\n" - "StartupWMClass=Hermes\n" + f"StartupWMClass={APP_ID}\n" ) @@ -573,13 +583,14 @@ def _install_icon_to_hicolor(icon: Path) -> bool: def _launcher_entry_management_enabled() -> bool: - """Whether config.yaml allows rewriting an EXISTING launcher entry. + """Whether config.yaml allows touching an EXISTING launcher entry. ``desktop.manage_launcher_entry: false`` opts out of the every-launch - rewrite: a hand-edited ``hermes.desktop`` is then left alone instead - of silently reverting (#101097's clobber complaint). A MISSING entry - is still created regardless — the opt-out protects user edits, not - first-run presence. Any config error reads as enabled (default). + rewrite: a hand-edited entry is then left alone instead + of silently reverting (#101097's clobber complaint), and the pre-rename + retirement is skipped with it — deletion is management too. A MISSING + entry is still created regardless — the opt-out protects user edits, + not first-run presence. Any config error reads as enabled (default). """ try: from hermes_cli.config import load_config_readonly @@ -595,11 +606,32 @@ def _launcher_entry_management_enabled() -> bool: return True -def install_desktop_entry(project_root: Path) -> Optional[Path]: - """Create or refresh the entry, respecting the opt-out for existing entries. +def _remove_legacy_desktop_entry(applications_dir: Path) -> None: + """Delete the pre-rename ``hermes.desktop`` left beside the app-id entry. - ``None`` on non-Linux platforms or when the write fails — a convenience, never a reason to - fail a launch. + Without it the menu lists Hermes twice, and an old taskbar pin keeps resolving to an entry + that no longer matches the window. Only a file that still names this app is removed — + anything else at that path (a hand-written launcher, another vendor's file) is left alone. + """ + legacy = applications_dir / LEGACY_DESKTOP_ENTRY_NAME + try: + text = legacy.read_text(encoding="utf-8-sig") + except OSError: + return + if not any(line.strip() == "Name=Hermes" for line in text.splitlines()): + return + try: + legacy.unlink() + except OSError: + pass + + +def install_desktop_entry(project_root: Path) -> Optional[Path]: + """Create or refresh the app-id entry, respecting the opt-out for existing entries. + + Only the app-id entry is written; a pre-rename ``hermes.desktop`` beside it is retired once + the new entry exists, and only while launcher management is enabled. ``None`` on non-Linux + platforms or when the write fails — a convenience, never a reason to fail a launch. """ if not is_supported(): return None @@ -608,7 +640,8 @@ def install_desktop_entry(project_root: Path) -> Optional[Path]: # Opt-out honored only for an entry that already exists: the flag # stops the every-launch clobber, not first-run creation. - if entry_path.is_file() and not _launcher_entry_management_enabled(): + manage_enabled = _launcher_entry_management_enabled() + if entry_path.is_file() and not manage_enabled: return entry_path icon = icon_path(project_root) @@ -638,6 +671,11 @@ def install_desktop_entry(project_root: Path) -> Optional[Path]: except OSError: return None + # Retiring the old entry is management too: with the opt-out set, an existing + # launcher stays even here, in the missing-entry path where the new entry is + # still created. + if manage_enabled: + _remove_legacy_desktop_entry(entry_path.parent) refresh_desktop_databases(entry_path.parent) return entry_path diff --git a/nix/desktop.nix b/nix/desktop.nix index ffbf9bcebd..b638a9e576 100644 --- a/nix/desktop.nix +++ b/nix/desktop.nix @@ -191,7 +191,8 @@ stdenv.mkDerivation { cp ${../hermes_cli/linux_desktop_entry.py} "$PYTHONPATH/linux_desktop_entry.py" export DESKTOP_EXEC="$out/bin/hermes-desktop" export DESKTOP_ICON="$out/share/icons/hicolor/1024x1024/apps/hermes.png" - python3 -c 'import os; from linux_desktop_entry import render_desktop_entry; print(render_desktop_entry(os.environ["DESKTOP_EXEC"], os.environ["DESKTOP_ICON"]))' > $out/share/applications/hermes.desktop + entry_name=$(python3 -c 'from linux_desktop_entry import DESKTOP_ENTRY_NAME; print(DESKTOP_ENTRY_NAME)') + python3 -c 'import os; from linux_desktop_entry import render_desktop_entry; print(render_desktop_entry(os.environ["DESKTOP_EXEC"], os.environ["DESKTOP_ICON"]))' > "$out/share/applications/$entry_name" runHook postInstall ''; diff --git a/tests/hermes_cli/test_linux_desktop_entry.py b/tests/hermes_cli/test_linux_desktop_entry.py index 0250e6eea4..fbff1958e9 100644 --- a/tests/hermes_cli/test_linux_desktop_entry.py +++ b/tests/hermes_cli/test_linux_desktop_entry.py @@ -80,7 +80,7 @@ def test_install_writes_entry_with_absolute_exec_and_icon( entry = lde.install_desktop_entry(root) - assert entry == xdg_home / "applications" / "hermes.desktop" + assert entry == xdg_home / "applications" / lde.DESKTOP_ENTRY_NAME values = _parse(entry.read_text(encoding="utf-8")) # Exec must be the absolute path of the resolved binary. The launcher @@ -432,7 +432,7 @@ def test_exec_never_persists_a_checkout_internal_path_hit(tmp_path, xdg_home, mo persisting the venv form; the next DE launch re-resolves to the durable wrapper and flips the bytes back. Alternating writers alternate the file content (captured: wrapper -> venv -> wrapper inside one update - cycle), and every flip rewrites hermes.desktop. A rewrite landing + cycle), and every flip rewrites the entry. A rewrite landing inside a grid launch's STARTING window is the arm for the gnome-shell 50.x crash this module already guards against. A PATH hit inside the checkout must fall through to the durable probe. @@ -486,7 +486,7 @@ def test_exec_finds_known_wrapper_when_resolver_has_no_candidate( None outright. The early `return primary` that used to fire here skipped the durable-wrapper probe, so the persisted Exec flipped to the bare ` -m hermes_cli.main desktop` module form. Each flip between the - wrapper and module forms rewrites hermes.desktop on the next launch; any + wrapper and module forms rewrites the entry on the next launch; any rewrite that lands while gnome-shell's ShellApp for the entry is still STARTING crashes the shell (shell_app_dispose `state == STOPPED` assertion, gnome-shell 50.4). The entry must converge on the durable @@ -639,6 +639,99 @@ def test_known_wrapper_candidates_cover_installer_layouts( assert "/usr/local/bin/hermes" not in candidates +def test_installed_entry_carries_the_window_app_id(tmp_path, xdg_home, monkeypatch): + """The window's app_id is what GNOME matches the entry against — not the old "Hermes".""" + _stub_install(tmp_path, monkeypatch) + root = _make_project(tmp_path) + + entry = lde.install_desktop_entry(root) + + assert entry is not None + assert entry.name == f"{lde.APP_ID}.desktop" + values = _parse(entry.read_text(encoding="utf-8")) + assert values["StartupWMClass"] == lde.APP_ID + assert values["Name"] == "Hermes" # the menu label is not part of the identity + + +def test_install_retires_the_legacy_entry_name(tmp_path, xdg_home, monkeypatch): + """A leftover hermes.desktop would surface as a second Hermes in the app grid.""" + _stub_install(tmp_path, monkeypatch) + root = _make_project(tmp_path) + legacy = xdg_home / "applications" / lde.LEGACY_DESKTOP_ENTRY_NAME + legacy.parent.mkdir(parents=True) + legacy.write_text( + "[Desktop Entry]\nType=Application\nName=Hermes\nExec=hermes desktop\n", + encoding="utf-8", + ) + + entry = lde.install_desktop_entry(root) + + assert entry is not None and entry.is_file() + assert not legacy.exists() + + +def test_install_keeps_foreign_files_at_the_legacy_path(tmp_path, xdg_home, monkeypatch): + """Only our own entry retires; another app's file at that name is not ours to delete.""" + _stub_install(tmp_path, monkeypatch) + root = _make_project(tmp_path) + foreign = xdg_home / "applications" / lde.LEGACY_DESKTOP_ENTRY_NAME + foreign.parent.mkdir(parents=True) + foreign.write_text( + "[Desktop Entry]\nType=Application\nName=Someone else\nExec=other-app\n", + encoding="utf-8", + ) + + lde.install_desktop_entry(root) + + assert foreign.is_file() + assert "Name=Someone else" in foreign.read_text(encoding="utf-8") + + +def test_install_opt_out_preserves_the_legacy_entry(tmp_path, xdg_home, monkeypatch): + """The opt-out protects user edits, so it also stops the legacy retirement. + + The missing-entry path still creates the app-id entry; the deletion is + management too and must not run when the user asked to be left alone. + """ + hermes_home = tmp_path / "hermes-home" + hermes_home.mkdir() + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + (hermes_home / "config.yaml").write_text( + "desktop:\n manage_launcher_entry: false\n", encoding="utf-8" + ) + _stub_install(tmp_path, monkeypatch) + root = _make_project(tmp_path) + legacy = xdg_home / "applications" / lde.LEGACY_DESKTOP_ENTRY_NAME + legacy.parent.mkdir(parents=True) + legacy.write_text( + "[Desktop Entry]\nType=Application\nName=Hermes\nExec=hermes desktop\n", + encoding="utf-8", + ) + + entry = lde.install_desktop_entry(root) + + assert entry == xdg_home / "applications" / lde.DESKTOP_ENTRY_NAME + assert legacy.is_file(), "the opt-out must keep the legacy entry in place" + + +def test_app_id_matches_the_desktop_build_identity(): + """APP_ID mirrors apps/desktop/product-identity.cjs; the two must not drift apart.""" + import shutil + import subprocess + + node = shutil.which("node") + if node is None: + pytest.skip("node is not available") + repo = Path(__file__).resolve().parents[2] + probe = "console.log(require('./apps/desktop/product-identity.cjs').appId)" + result = subprocess.run( + [node, "-e", probe], cwd=repo, capture_output=True, text=True, timeout=60 + ) + if result.returncode != 0: + pytest.skip(f"product-identity.cjs did not evaluate: {result.stderr.strip()[:200]}") + assert result.stdout.strip() == lde.APP_ID + + def test_install_is_idempotent_and_skips_cache_refresh(tmp_path, xdg_home, monkeypatch): root = _make_project(tmp_path) monkeypatch.setattr( diff --git a/tests/hermes_cli/test_linux_launcher_management.py b/tests/hermes_cli/test_linux_launcher_management.py index ed6af58e5d..b16b199df7 100644 --- a/tests/hermes_cli/test_linux_launcher_management.py +++ b/tests/hermes_cli/test_linux_launcher_management.py @@ -3,7 +3,7 @@ from pathlib import Path import pytest -from hermes_cli.linux_desktop_entry import install_desktop_entry +from hermes_cli.linux_desktop_entry import DESKTOP_ENTRY_NAME, install_desktop_entry @pytest.mark.platforms("linux") @@ -19,7 +19,7 @@ def test_launcher_optout_preserves_custom_entry_but_creates_missing(tmp_path, mo config.write_text("desktop:\n manage_launcher_entry: false\n", encoding="utf-8") root = tmp_path / "checkout" root.mkdir() - entry = tmp_path / "xdg/applications/hermes.desktop" + entry = tmp_path / "xdg" / "applications" / DESKTOP_ENTRY_NAME entry.parent.mkdir(parents=True) custom = b"[Desktop Entry]\nType=Application\nName=Custom Hermes\nExec=/opt/custom-hermes desktop\n" for setting in ("false", '"false"'):