fix(desktop): carry the window app id in the Linux launcher entry

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
`<app_id>.desktop` file name and every launch fell back to the placeholder
icon, with the raw app id in the tooltip.

- write `<app_id>.desktop` with `StartupWMClass=<app_id>` (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.
This commit is contained in:
Octopustank
2026-09-25 13:51:23 +08:00
committed by brooklyn!
parent 2a977be999
commit a6686cc396
4 changed files with 153 additions and 21 deletions

View File

@@ -1,4 +1,4 @@
"""Install and remove the Linux desktop entry (``hermes.desktop``).
"""Install and remove the Linux desktop entry (``<app_id>.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 `<app_id>.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 <checkout>/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

View File

@@ -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
'';

View File

@@ -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
`<python> -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(

View File

@@ -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"'):