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.
762 lines
33 KiB
Python
762 lines
33 KiB
Python
"""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
|