Files
hermes-agent/hermes_cli/linux_desktop_entry.py
Octopustank a6686cc396 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.
2026-09-27 07:46:31 -05:00

762 lines
33 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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
themed name backed by a copy in the user's hicolor tree. Cache refresh is best-effort and
tool-gated (``update-desktop-database``, ``gtk-update-icon-cache``, ``kbuildsycoca6``/``5``); a
missing tool is not an error.
"""
from __future__ import annotations
import io
import os
import re
import shutil
import struct
import subprocess
import sys
import threading
import time
from pathlib import Path
from typing import Callable, Mapping, Optional
# 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().
SHELL_LAUNCH_ENV_VAR = "DESKTOP_STARTUP_ID"
# Write end of the reveal pipe handed to Electron; one byte means "main window is on screen".
READY_FD_ENV_VAR = "HERMES_DESKTOP_READY_FD"
REVEAL_BYTE = b"r" # what linux-launcher-ready.ts writes; anything else is finish()'s wake-up
_SHELL_NAMES = ("bash", "sh", "dash", "zsh", "ksh")
def is_supported() -> bool:
"""XDG desktop entries exist only on Linux and BSD."""
return sys.platform.startswith(("linux", "freebsd", "openbsd", "netbsd"))
def _xdg_data_home() -> Path:
raw = os.environ.get("XDG_DATA_HOME")
return Path(raw).expanduser() if raw and raw.strip() else Path.home() / ".local" / "share"
def desktop_entry_path() -> Path:
return _xdg_data_home() / "applications" / DESKTOP_ENTRY_NAME
def icon_path(project_root: Path) -> Path:
return project_root / "apps" / "desktop" / "assets" / "icon.png"
def _running_interpreter() -> str:
"""Venv-semantic interpreter path for the persisted ``Exec=`` line.
``sys.executable`` inside a venv is commonly a SYMLINK into a shared base-interpreter tree
(uv, pyenv, conda). ``resolve()`` follows it out of the venv, and CPython discovers
``pyvenv.cfg`` from the *lexical* argv[0] — so a dereferenced path boots without the venv's
site-packages. Keep the lexical form when any ancestor holds a ``pyvenv.cfg``.
See #80547, #90292.
Idea credit: the lexical-preservation rule was independently proposed in #92516/#94115/#94544 and by
nosliwhtes' review of this PR; the pyvenv.cfg-detection refinement here keeps both properties.
"""
lexical = os.path.abspath(sys.executable)
path = Path(lexical)
if any((base / "pyvenv.cfg").is_file() for base in (path.parent, *path.parent.parents)):
return lexical
return str(path.resolve())
_probe_cache: "dict[str, bool]" = {}
def _can_import_hermes_cli(interpreter: Path) -> bool:
"""Whether *interpreter* can import ``hermes_cli.main`` unaided.
Runs under ``-I`` (no user site, no PYTHONPATH, no cwd on ``sys.path``) from a neutral cwd, so
the answer matches a cold desktop environment. Cached per process; an unprobeable interpreter
(missing binary, spawn failure, timeout) is assumed capable and deliberately NOT cached, so one
transient hiccup doesn't freeze the assumption for the session.
Probe design per @nosliwhtes' isolated-mode capability check (#92122 lineage, commit 4150501f641).
"""
key = str(interpreter)
if key in _probe_cache:
return _probe_cache[key]
ok = _run_quiet(
[key, "-I", "-c", "import hermes_cli.main"],
cwd=os.path.abspath(os.sep), timeout=15, on_error=None,
)
if ok is None:
return True
_probe_cache[key] = ok
return ok
def _running_interpreter_fallback() -> str:
"""The RUNNING interpreter — it has ``hermes_cli`` importable by definition."""
return os.path.abspath(sys.executable)
def resolve_exec_command(project_root: Optional[Path] = None) -> str:
"""Build the absolute ``Exec=`` command line for ``hermes desktop``.
Prefer the real ``hermes`` launcher; fall back to ``<python> -m hermes_cli.main desktop``.
"""
from hermes_cli.relaunch import resolve_hermes_bin
bin_path = _resolve_hermes_bin_for_desktop_entry(resolve_hermes_bin, checkout_root=project_root)
interpreter = _running_interpreter()
if not _can_import_hermes_cli(Path(interpreter)):
# Persisting an interpreter that can't import the CLI writes a dead entry (the DE spawns
# Exec in a cold environment where exactly this import must succeed).
# The candidate interpreter cannot actually import hermes_cli.main (checked in isolated mode from a
# neutral cwd — so the probe can't be fooled by a checkout cwd or an inherited PYTHONPATH). Fall
# back to the module form under the RUNNING interpreter, which by definition has the CLI importable.
# Probe design follows the isolated-mode capability check proposed by @nosliwhtes (#92122 review
# lineage, commit 4150501f641) — cached here per-process so a desktop launch pays the subprocess
# cost at most once.
interpreter = _running_interpreter_fallback()
argv = [interpreter, "-m", "hermes_cli.main", "desktop"]
if bin_path:
resolved = Path(bin_path).resolve()
# A Python launcher whose shebang points OUTSIDE the venv (e.g. the repo's `hermes` script
# with `#!/usr/bin/env python3`) would die silently on the first third-party import under
# Terminal=false — run it under the venv interpreter explicitly.
prefix = [interpreter] if _needs_interpreter(resolved) else []
# See #90292.
argv = [*prefix, str(resolved), "desktop"]
return " ".join(_quote_exec_arg(a) for a in argv)
def _is_interpreter(candidate: Path) -> bool:
"""A python interpreter binary (``bin/python*``), not a launcher: strict basename match
(rejects ``python3-config``, ``pythonw``) inside a bin/Scripts dir (rejects a stray script
named ``python`` elsewhere).
Regex approach proposed independently in 94051; kept here with the parent-dir guard so a script named
``python`` outside a bin/Scripts tree is not misclassified. See #94051.
"""
return bool(re.fullmatch(r"python[23]?(\d+)?(\.\d+)?", candidate.name.lower())) and (
candidate.parent.name in {"bin", "scripts"}
)
def _inside_checkout(candidate: str, checkout_root: Path, original_argv0: str) -> bool:
"""Whether a launcher candidate is a launch-context artifact rather than a durable launcher."""
try:
path = Path(candidate).resolve()
except OSError:
return False
# Anything shipped in the tree (e.g. the repo `hermes` script) is checkout-internal. Compare
# against BOTH the lexical and resolved roots: candidates resolve, so a symlinked home needs
# the resolved comparison too.
try:
resolved_root = checkout_root.resolve()
except OSError:
resolved_root = None
for root in {checkout_root, resolved_root}:
if root is not None and (path == root or root in path.parents):
return True
# The `python -m hermes_cli.main` relaunch context surfaces the invoking interpreter as
# argv[0]; an interpreter is never a launchable entry target (it would persist a bare
# `<python> desktop`). Compare against argv[0]'s own file, not sys.executable — under test
# harnesses they differ.
try:
return path.samefile(original_argv0) and _is_interpreter(path)
except OSError:
return False
def _resolve_hermes_bin_for_desktop_entry(
resolve_fn=None,
checkout_root: Optional[Path] = None,
) -> Optional[str]:
"""Resolve the launcher binary for the persisted ``.desktop`` entry.
Wraps :func:`hermes_cli.relaunch.resolve_hermes_bin` with one rule: an ``argv[0]`` inside this
checkout is a launch-context artifact, not a durable installed launcher — persisting it makes
the entry depend on how the previous launch happened (a bootstrap loop). Skip such candidates
and fall through to PATH, then to the installer's known wrapper locations. ``resolve_fn`` is
injectable for tests.
See #90492.
"""
if resolve_fn is None:
from hermes_cli.relaunch import resolve_hermes_bin as resolve_fn
if checkout_root is None:
checkout_root = _project_root()
# Keep the LEXICAL root: the installer writes $INSTALL_DIR lexically into the shim text, so a
# symlinked home would otherwise mismatch. Callers pass main.py's realpath'd PROJECT_ROOT; the
# module-lexical root is tried alongside it.
checkout_root = Path(os.path.abspath(checkout_root))
module_lexical_root = _project_root()
original_argv0 = sys.argv[0]
# An external primary (another install's /opt/.../bin/hermes, a venv console script) wins
# BEFORE any known-location probing, which could silently switch the entry to a different
# installation. Only rerun the resolver with argv[0] hidden when the primary could actually
# be checkout-internal (also shortens the window a concurrent reader sees mutated sys.argv).
primary = resolve_fn()
if primary and not _inside_checkout(primary, checkout_root, original_argv0):
return primary
# A primary that is NOT checkout-internal and not the invoking interpreter is an external launcher (e.g.
# /opt/.../bin/hermes from another install method, or a venv console script). It must be evaluated
# BEFORE any known-location probing: probing first could silently switch the entry to a different
# installation (#94443 review case 3).
# Only reroute when argv[0] actually drove the resolution: re-run the resolver with argv[0] hidden and
# compare. If PATH yields nothing, keep the resolver's original answer (its fallback chain stays
# authoritative; #90492 semantics preserved).
sys.argv[0] = ""
try:
rerouted = resolve_fn()
finally:
sys.argv[0] = original_argv0
# 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 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(
rerouted, checkout_root, original_argv0
):
return rerouted
# A PATH hit inside this checkout is the same launch-context artifact as argv[0]: the
# 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 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.
# argv[0] was checkout-internal AND PATH yielded no DURABLE launcher (miss, or a hit inside
# this checkout) — common in stripped systemd user sessions, autostart relaunches, and the
# update hand-off with <checkout>/venv/bin on PATH. Probe the installer's known wrapper
# locations; each
# candidate must be DE-safe and target THIS checkout (a foreign wrapper would make the entry
# stable-but-wrong). No durable wrapper → None, so resolve_exec_command emits its runnable
# module fallback instead of the self-regenerating checkout-internal form.
for candidate in _known_wrapper_candidates():
if (
candidate.is_file()
and os.access(candidate, os.X_OK)
and _wrapper_shebang_safe(candidate)
and (
_wrapper_targets_checkout(candidate, checkout_root)
or _wrapper_targets_checkout(candidate, module_lexical_root)
)
):
return str(candidate)
return None
def _shebang_tokens(shebang: str) -> "list[str]":
return shebang[2:].strip().split()
def _is_native_binary(head: bytes) -> bool:
return head[:4] == b"\x7fELF" or head.startswith(b"MZ")
def _read_head(path: Path, size: int = 4096) -> Optional[bytes]:
try:
with open(path, "rb") as fh:
return fh.read(size)
except OSError:
return None
def _wrapper_shebang_safe(wrapper: Path) -> bool:
"""Whether an executable wrapper can actually run in the DE context.
Native binaries and shell launchers are safe by construction (the shell script execs the right
interpreter itself). A python-shebang wrapper is safe only when its interpreter stays inside
the RUNNING venv; anything unknown fails safe toward the module fallback.
"""
head = _read_head(wrapper)
if head is None:
return False
if _is_native_binary(head):
return True
if not head.startswith(b"#!"):
return False
shebang = head.decode("utf-8", errors="replace").splitlines()[0]
tokens = _shebang_tokens(shebang)
if not tokens:
return False
interp = Path(tokens[0])
if interp.name == "env":
# `#!/usr/bin/env bash` is the installer's own launcher form; skip env's flags (-S, -u
# VAR) and inspect the first real token. Only python-flavored `env` shebangs escape.
target = next((Path(t) for t in tokens[1:] if not t.startswith("-")), Path(""))
return target.name in _SHELL_NAMES or not _shebang_escapes_running_env(shebang)
if interp.name in _SHELL_NAMES:
return True
if "python" not in interp.name.lower():
return False
return not _shebang_escapes_running_env(shebang)
_ROOT_TERMINATORS = ('"', "'", " ", "\n", "\t", "\r", "$", "\x00", "/")
def _wrapper_targets_checkout(wrapper: Path, checkout_root: Path) -> bool:
"""Whether a candidate launcher script actually launches THIS checkout.
Boundary-aware: a bare substring test would also accept sibling paths that EXTEND this
checkout's path (``<checkout>-old``, ``<checkout>.bak``), so the root must end at a quote /
whitespace / EOL or continue INTO the tree (``<root>/``). Both lexical and resolved roots are
tried: the installer writes $INSTALL_DIR lexically, so with a symlinked home the shim text
carries the lexical path while the caller may pass a resolved one.
"""
head = _read_head(wrapper)
if head is None:
return False
if _is_native_binary(head):
# Native binary: cannot verify, cannot be another checkout's bash shim either — accept.
return True
text = head.decode("utf-8", errors="replace")
lexical_root = os.path.abspath(str(checkout_root))
roots = {str(checkout_root), lexical_root}
try:
roots.add(str(Path(lexical_root).resolve()))
except OSError:
pass
stripped = text.rstrip("\r\n")
return any(
stripped.endswith(root) or any(root + t in text for t in _ROOT_TERMINATORS)
for root in roots
)
def _known_wrapper_candidates():
"""Durable installed-launcher locations, most likely first.
Mirrors the installer's ``get_command_link_dir()`` layouts: Termux (``$PREFIX/bin``), root FHS
(``/usr/local/bin``), and user (``~/.local/bin``). The wrapper is always named ``hermes``.
"""
candidates = []
prefix = os.environ.get("PREFIX")
if prefix:
candidates.append(Path(prefix) / "bin" / "hermes")
if hasattr(os, "geteuid") and os.geteuid() == 0:
candidates.append(Path("/usr/local/bin/hermes"))
candidates.append(Path.home() / ".local" / "bin" / "hermes")
return candidates
def _project_root() -> Path:
"""``<checkout>`` from this file's location — lexical (no ``.resolve()``) so shim-text matching
against the installer's lexically-written $INSTALL_DIR works on symlinked homes."""
return Path(os.path.abspath(__file__)).parent.parent
def _needs_interpreter(bin_path: Path) -> bool:
"""Whether ``bin_path`` is a Python script whose shebang escapes the running venv.
Native binaries (uv shim, PyInstaller, distro package) and shell wrappers (the installer's
bash launcher execs the venv python itself) never need one.
"""
head = _read_head(bin_path, 256)
if head is None or not head.startswith(b"#!"):
return False
shebang = head.decode("utf-8", errors="replace").splitlines()[0].strip()
if "python" not in shebang.lower():
return False
return _shebang_escapes_running_env(shebang)
def _shebang_escapes_running_env(shebang: str) -> bool:
"""Whether a python shebang resolves OUTSIDE the running interpreter's directory.
Compares PATH COMPONENTS, never substrings: ``<venv>/bin-extra/python`` is not inside
``<venv>/bin``. ``env`` shebangs ALWAYS escape — ``env`` resolves through the DE's cold PATH,
not the shell that installed the venv — except the rare ``env -S <abs-interpreter>`` form,
which is judged by its absolute target.
Tokenizes the shebang (interpreter path plus any flags) and compares PATH COMPONENTS, never substrings:
``<venv>/bin-extra/python`` is not inside ``<venv>/bin`` even though it starts with it
(sibling-directory confusion; independently surfaced in nosliwhtes' #92122 hardening ``b96427d0`` —
reimplemented here with two extensions).
The comparison uses the LEXICAL interpreter directory (abspath, not resolve()): on uv venvs the resolved
parent is the base interpreter's dir, which makes a valid ``.venv/bin/python`` shebang look foreign
(#94443 review case 1). Both sides use the SAME case operation (``.lower()``): interpreter paths
legitimately carry uppercase (conda env names, usernames, uv's ephemeral build dirs) and an asymmetric
compare would flag the venv's own console script as foreign.
"""
tokens = _shebang_tokens(shebang)
if not tokens:
return True # bare "#!python": resolves via PATH
interp = Path(tokens[0])
if interp.name in ("env", "env.exe"):
rest = [t for t in tokens[1:] if not t.startswith("-")]
if not (rest and Path(rest[0]).is_absolute()):
return True
interp = Path(rest[0])
running_dir = os.path.dirname(os.path.abspath(sys.executable)).lower()
return str(interp.parent).lower() != running_dir
def _quote_exec_arg(arg: str) -> str:
"""Quote one ``Exec`` argument per the desktop entry spec."""
if not any(c in arg for c in " \t\n\"'\\><~|&;$*?#()`"):
return arg
escaped = arg.replace("\\", "\\\\").replace('"', '\\"')
return f'"{escaped}"'
def render_desktop_entry(exec_command: str, icon: str) -> str:
return (
"[Desktop Entry]\n"
"Type=Application\n"
"Name=Hermes\n"
"GenericName=Hermes Desktop\n"
"Comment=Launch Hermes Desktop\n"
f"Exec={exec_command}\n"
f"Icon={icon}\n"
"Terminal=false\n"
"Categories=Utility;\n"
"StartupNotify=true\n"
f"StartupWMClass={APP_ID}\n"
)
def refresh_desktop_databases(applications_dir: Path) -> "list[str]":
"""Reindex the menu caches. Run each tool only when it exists."""
ran: list[str] = []
update_db = shutil.which("update-desktop-database")
if update_db and _run_quiet([update_db, str(applications_dir)]):
ran.append("update-desktop-database")
# Plasma 6 first, then Plasma 5. Only one of them is ever installed.
for tool in ("kbuildsycoca6", "kbuildsycoca5"):
resolved = shutil.which(tool)
if resolved:
if _run_quiet([resolved, "--noincremental"]):
ran.append(tool)
break
return ran
def _run_quiet(cmd: "list[str]", *, timeout: int = 60, on_error: Optional[bool] = False, **kwargs) -> Optional[bool]:
"""Exit-status success of a silenced subprocess; ``on_error`` when it could not be run at all."""
try:
result = subprocess.run(
cmd,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
check=False,
timeout=timeout,
**kwargs,
)
except (OSError, subprocess.SubprocessError):
return on_error
return result.returncode == 0
# Sizes a typical hicolor ``index.theme`` lists. ``scalable`` is SVG-only — a raster PNG there is
# what Cinnamon's panel draws as a mangled low-res blob. The shipped asset is 1024×1024 (not an
# indexed dir name), so a copy-only fallback lands in ``256x256``.
_HICOLOR_INDEXED_SIZES = (16, 22, 24, 32, 36, 48, 64, 72, 96, 128, 192, 256, 512)
# Cinnamon's panel is ~24–32px: write exact rasters so the theme needn't downscale a 1024px PNG.
_HICOLOR_INSTALL_SIZES = (24, 32, 48, 256)
def _png_dimensions(raw: bytes) -> Optional[tuple[int, int]]:
"""``(width, height)`` from a PNG IHDR, or ``None`` if unreadable."""
if len(raw) >= 24 and raw[:8] == b"\x89PNG\r\n\x1a\n" and raw[12:16] == b"IHDR":
return struct.unpack(">II", raw[16:24])
return None
def _hicolor_subdir(dimensions: Optional[tuple[int, int]]) -> str:
"""Pick a fixed-size hicolor dir the theme indexes. Never ``scalable``."""
if dimensions is None:
return "256x256"
width, height = dimensions
if width in _HICOLOR_INDEXED_SIZES and width == height:
return f"{width}x{width}"
if width != height or width <= 0 or width > 256:
return "256x256"
nearest = min(_HICOLOR_INDEXED_SIZES, key=lambda size: abs(size - width))
return f"{nearest}x{nearest}"
def _hicolor_icon_dest(subdir: str) -> Path:
return _xdg_data_home() / "icons" / "hicolor" / subdir / "apps" / "hermes.png"
def _remove_stale_scalable_icon() -> bool:
"""Drop a leftover PNG from ``scalable/`` (the pre-fix install path); True if removed."""
stale = _hicolor_icon_dest("scalable")
try:
removed = stale.is_file()
if removed:
stale.unlink()
return removed
except OSError:
return False
def _refresh_hicolor_cache() -> None:
"""Best-effort reindex of the user hicolor tree. Missing tool is fine."""
hicolor = _xdg_data_home() / "icons" / "hicolor"
for tool in ("gtk-update-icon-cache", "gtk4-update-icon-cache"):
resolved = shutil.which(tool)
if resolved:
_run_quiet([resolved, "-f", "-t", str(hicolor)])
return
def _resized_hicolor_pngs(raw: bytes) -> Optional[dict[str, bytes]]:
"""Lanczos-resize *raw* to each panel size; ``None`` when it will not decode (truncated/fake
PNG) so the caller falls back to a copy. Pillow is imported lazily to keep the uninstaller
import-light."""
try:
from PIL import Image
except ImportError:
return None
try:
with Image.open(io.BytesIO(raw)) as im:
rgba = im.convert("RGBA")
out: dict[str, bytes] = {}
for size in _HICOLOR_INSTALL_SIZES:
buf = io.BytesIO()
rgba.resize((size, size), Image.Resampling.LANCZOS).save(buf, format="PNG")
out[f"{size}x{size}"] = buf.getvalue()
return out
except (OSError, ValueError):
return None
def _write_hicolor_pngs(files: dict[str, bytes]) -> bool:
"""Write *files* keyed by hicolor size dir. Return True if any file changed."""
wrote = False
for subdir, data in files.items():
dest = _hicolor_icon_dest(subdir)
if dest.is_file() and dest.read_bytes() == data:
continue
dest.parent.mkdir(parents=True, exist_ok=True)
dest.write_bytes(data)
wrote = True
return wrote
def _install_icon_to_hicolor(icon: Path) -> bool:
"""Install the app icon into the user's hicolor tree so ``Icon=hermes`` resolves without an
absolute checkout path. Raster PNGs go to indexed fixed-size dirs, never ``scalable``."""
try:
raw = icon.read_bytes()
resized = _resized_hicolor_pngs(raw)
if resized is not None:
wrote = _write_hicolor_pngs(resized)
else:
dest = _hicolor_icon_dest(_hicolor_subdir(_png_dimensions(raw)))
wrote = not (dest.is_file() and dest.read_bytes() == raw)
if wrote:
dest.parent.mkdir(parents=True, exist_ok=True)
shutil.copyfile(icon, dest)
if _remove_stale_scalable_icon() or wrote:
_refresh_hicolor_cache()
return True
except OSError:
return False
def _launcher_entry_management_enabled() -> bool:
"""Whether config.yaml allows touching an EXISTING launcher entry.
``desktop.manage_launcher_entry: false`` opts out of the every-launch
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
desktop_cfg = (load_config_readonly() or {}).get("desktop") or {}
raw = desktop_cfg.get("manage_launcher_entry", True)
if isinstance(raw, bool):
return raw
if isinstance(raw, str):
return raw.strip().lower() not in ("false", "0", "no", "off")
return True
except Exception:
return True
def _remove_legacy_desktop_entry(applications_dir: Path) -> None:
"""Delete the pre-rename ``hermes.desktop`` left beside the app-id entry.
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
entry_path = desktop_entry_path()
# Opt-out honored only for an entry that already exists: the flag
# stops the every-launch clobber, not first-run creation.
manage_enabled = _launcher_entry_management_enabled()
if entry_path.is_file() and not manage_enabled:
return entry_path
icon = icon_path(project_root)
# Prefer the themed name: the icon is COPIED into the hicolor tree, so the entry outlives the
# checkout (an absolute Icon= path breaks when the checkout moves). Absolute path only when
# the copy is impossible (read-only tree); themed name when the checkout has no icon at all.
icon_value = str(icon) if icon.is_file() else "hermes"
if icon.is_file() and _install_icon_to_hicolor(icon):
icon_value = "hermes"
contents = render_desktop_entry(resolve_exec_command(project_root), icon_value)
try:
entry_path.parent.mkdir(parents=True, exist_ok=True)
# When nothing changed, skip the rewrite. Then a launch does not
# churn the menu caches.
if entry_path.is_file() and entry_path.read_text(encoding="utf-8-sig") == contents:
return entry_path
# Atomic replace: an interrupted plain write leaves a zero-byte entry, which permanently
# breaks the taskbar pin (nothing later rewrites a file that exists at the right path).
# The temp+rename dance in utils.atomic_write_text is the codebase's shared implementation — ported
# from #80547, which closed unmerged with this piece unlanded.
from utils import atomic_write_text
atomic_write_text(entry_path, contents, create_mode=0o755)
# Some launchers (and older Plasma) offer the entry only when it is executable.
entry_path.chmod(0o755)
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
def launched_from_shell(environ: Optional[Mapping[str, str]] = None) -> bool:
"""True when this process was started from the app grid / menu (XDG startup notification).
A grid launch has a gnome-shell ShellApp in STARTING until our window maps; unpatched
shells (before GNOME MR !4428) drop that app's last reference when its own ``.desktop``
entry changes, and the next idle GC kills the whole Wayland session (#111906). A false
negative degrades to the pre-#111906 behaviour; a false positive only delays a heal.
"""
env = os.environ if environ is None else environ
return bool(env.get(SHELL_LAUNCH_ENV_VAR))
class DeferredDesktopEntryInstall:
"""Install the entry once the desktop window is on screen — never while the shell's
ShellApp is STARTING.
The launcher hands Electron the write end of a pipe (``HERMES_DESKTOP_READY_FD``); Electron
writes one byte when the main window is revealed and a worker thread then installs the entry.
An exit without a reveal (boot crash, early quit) does NOT heal: gnome-shell keeps the ShellApp
in STARTING until the startup-notification sequence completes or times out (mutter, ~15 s),
not until the process dies, so a write right after such an exit still lands in the arming
window. The next terminal/updater launch or revealed grid launch installs the entry instead.
Terminal and detached launches never build one of these: they install immediately, as before.
"""
def __init__(
self,
project_root: Path,
install: Optional[Callable[[Path], Optional[Path]]] = None,
settle_seconds: float = 2.0,
) -> None:
self._project_root = project_root
self._install = install or install_desktop_entry
# Electron reports the reveal before the compositor has necessarily mapped the surface;
# a short settle after the signal keeps the write on the RUNNING side. It is a margin
# after the condition, not a substitute for it.
self._settle_seconds = settle_seconds
self._read_fd, self.write_fd = os.pipe()
self._thread = threading.Thread(target=self._wait_for_reveal, name="desktop-entry-install", daemon=True)
def child_env(self, env: dict) -> dict:
env[READY_FD_ENV_VAR] = str(self.write_fd)
return env
@property
def pass_fds(self) -> tuple[int, ...]:
return (self.write_fd,)
def start(self) -> None:
self._thread.start()
def _wait_for_reveal(self) -> None:
try:
data = os.read(self._read_fd, 1)
except OSError:
return
if data != REVEAL_BYTE: # woken by finish(): the app exited without a reveal, skip the heal
return
if self._settle_seconds:
time.sleep(self._settle_seconds)
try:
entry = self._install(self._project_root)
if entry:
print(f"✓ Desktop launcher entry installed: {entry}")
except Exception as exc: # never fail a launch on launcher plumbing
print(f"⚠ Could not install the desktop launcher entry: {exc}")
def finish(self) -> None:
"""Electron exited: let a heal already triggered by the reveal complete, never start one."""
try:
os.write(self.write_fd, b"x") # wake a still-waiting reader so join() returns promptly
except OSError:
pass
self._thread.join(timeout=15)
for fd in (self._read_fd, self.write_fd):
try:
os.close(fd)
except OSError:
pass