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:
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
'';
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"'):
|
||||
|
||||
Reference in New Issue
Block a user