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
|
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
|
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 pathlib import Path
|
||||||
from typing import Callable, Mapping, Optional
|
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
|
# XDG startup notification: set by an app-grid / menu launch, absent for terminal and detached
|
||||||
# (updater relaunch) launches. See launched_from_shell().
|
# (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
|
# 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
|
# ``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
|
# 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
|
# ``primary is None`` implies ``rerouted is None`` (the rerun only hides argv[0]), so only the
|
||||||
# probe can still find anything.
|
# probe can still find anything.
|
||||||
if primary and rerouted is not None and not _inside_checkout(
|
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
|
# 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
|
# 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
|
# 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
|
# 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.
|
# 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"
|
"Terminal=false\n"
|
||||||
"Categories=Utility;\n"
|
"Categories=Utility;\n"
|
||||||
"StartupNotify=true\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:
|
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
|
``desktop.manage_launcher_entry: false`` opts out of the every-launch
|
||||||
rewrite: a hand-edited ``hermes.desktop`` is then left alone instead
|
rewrite: a hand-edited entry is then left alone instead
|
||||||
of silently reverting (#101097's clobber complaint). A MISSING entry
|
of silently reverting (#101097's clobber complaint), and the pre-rename
|
||||||
is still created regardless — the opt-out protects user edits, not
|
retirement is skipped with it — deletion is management too. A MISSING
|
||||||
first-run presence. Any config error reads as enabled (default).
|
entry is still created regardless — the opt-out protects user edits,
|
||||||
|
not first-run presence. Any config error reads as enabled (default).
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
from hermes_cli.config import load_config_readonly
|
from hermes_cli.config import load_config_readonly
|
||||||
@@ -595,11 +606,32 @@ def _launcher_entry_management_enabled() -> bool:
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
def install_desktop_entry(project_root: Path) -> Optional[Path]:
|
def _remove_legacy_desktop_entry(applications_dir: Path) -> None:
|
||||||
"""Create or refresh the entry, respecting the opt-out for existing entries.
|
"""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
|
Without it the menu lists Hermes twice, and an old taskbar pin keeps resolving to an entry
|
||||||
fail a launch.
|
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():
|
if not is_supported():
|
||||||
return None
|
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
|
# Opt-out honored only for an entry that already exists: the flag
|
||||||
# stops the every-launch clobber, not first-run creation.
|
# 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
|
return entry_path
|
||||||
|
|
||||||
icon = icon_path(project_root)
|
icon = icon_path(project_root)
|
||||||
@@ -638,6 +671,11 @@ def install_desktop_entry(project_root: Path) -> Optional[Path]:
|
|||||||
except OSError:
|
except OSError:
|
||||||
return None
|
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)
|
refresh_desktop_databases(entry_path.parent)
|
||||||
return entry_path
|
return entry_path
|
||||||
|
|
||||||
|
|||||||
@@ -191,7 +191,8 @@ stdenv.mkDerivation {
|
|||||||
cp ${../hermes_cli/linux_desktop_entry.py} "$PYTHONPATH/linux_desktop_entry.py"
|
cp ${../hermes_cli/linux_desktop_entry.py} "$PYTHONPATH/linux_desktop_entry.py"
|
||||||
export DESKTOP_EXEC="$out/bin/hermes-desktop"
|
export DESKTOP_EXEC="$out/bin/hermes-desktop"
|
||||||
export DESKTOP_ICON="$out/share/icons/hicolor/1024x1024/apps/hermes.png"
|
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
|
runHook postInstall
|
||||||
'';
|
'';
|
||||||
|
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ def test_install_writes_entry_with_absolute_exec_and_icon(
|
|||||||
|
|
||||||
entry = lde.install_desktop_entry(root)
|
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"))
|
values = _parse(entry.read_text(encoding="utf-8"))
|
||||||
|
|
||||||
# Exec must be the absolute path of the resolved binary. The launcher
|
# 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
|
persisting the venv form; the next DE launch re-resolves to the durable
|
||||||
wrapper and flips the bytes back. Alternating writers alternate the
|
wrapper and flips the bytes back. Alternating writers alternate the
|
||||||
file content (captured: wrapper -> venv -> wrapper inside one update
|
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
|
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
|
50.x crash this module already guards against. A PATH hit inside the
|
||||||
checkout must fall through to the durable probe.
|
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
|
None outright. The early `return primary` that used to fire here skipped
|
||||||
the durable-wrapper probe, so the persisted Exec flipped to the bare
|
the durable-wrapper probe, so the persisted Exec flipped to the bare
|
||||||
`<python> -m hermes_cli.main desktop` module form. Each flip between the
|
`<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
|
rewrite that lands while gnome-shell's ShellApp for the entry is still
|
||||||
STARTING crashes the shell (shell_app_dispose `state == STOPPED`
|
STARTING crashes the shell (shell_app_dispose `state == STOPPED`
|
||||||
assertion, gnome-shell 50.4). The entry must converge on the durable
|
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
|
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):
|
def test_install_is_idempotent_and_skips_cache_refresh(tmp_path, xdg_home, monkeypatch):
|
||||||
root = _make_project(tmp_path)
|
root = _make_project(tmp_path)
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ from pathlib import Path
|
|||||||
|
|
||||||
import pytest
|
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")
|
@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")
|
config.write_text("desktop:\n manage_launcher_entry: false\n", encoding="utf-8")
|
||||||
root = tmp_path / "checkout"
|
root = tmp_path / "checkout"
|
||||||
root.mkdir()
|
root.mkdir()
|
||||||
entry = tmp_path / "xdg/applications/hermes.desktop"
|
entry = tmp_path / "xdg" / "applications" / DESKTOP_ENTRY_NAME
|
||||||
entry.parent.mkdir(parents=True)
|
entry.parent.mkdir(parents=True)
|
||||||
custom = b"[Desktop Entry]\nType=Application\nName=Custom Hermes\nExec=/opt/custom-hermes desktop\n"
|
custom = b"[Desktop Entry]\nType=Application\nName=Custom Hermes\nExec=/opt/custom-hermes desktop\n"
|
||||||
for setting in ("false", '"false"'):
|
for setting in ("false", '"false"'):
|
||||||
|
|||||||
Reference in New Issue
Block a user