Files
aiturk-hermes-ide/hermes_cli/linux_desktop_entry.py
T

807 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 (``hermes.desktop``).
``hermes desktop`` builds and launches the Electron app. On Linux, a
freshly-built app has no launcher presence: no menu item, no icon. This
module writes the XDG desktop entry that gives it one.
``hermes uninstall --gui`` removes the entry again.
Two values must be absolute for the entry to work:
- ``Exec`` — the launcher runs without shell ``PATH`` customizations, so
a bare ``hermes desktop`` fails when hermes lives in ``~/.local/bin``
or a venv. Resolve the real binary and write its full path.
- ``Icon`` — an unqualified icon name needs an indexed icon theme. The
spec allows an absolute path instead, so point at the app icon in the
checkout. Do not copy the icon: ``Exec`` already depends on that tree.
Cache refresh is best-effort and tool-gated: ``update-desktop-database``
for the freedesktop menu cache, ``gtk-update-icon-cache`` for the user
hicolor tree, and ``kbuildsycoca6``/``kbuildsycoca5`` for Plasma. Run
each tool only when it exists. A missing tool is not an error.
Import-light and side-effect-free at import time: the uninstaller uses
this without loading the full CLI.
"""
from __future__ import annotations
import io
import os
import shutil
import struct
import subprocess
import sys
from pathlib import Path
from typing import Optional
DESKTOP_ENTRY_NAME = "hermes.desktop"
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")
if raw and raw.strip():
return Path(raw).expanduser()
return Path.home() / ".local" / "share"
def desktop_entry_path() -> Path:
"""Where the ``hermes.desktop`` entry lives."""
return _xdg_data_home() / "applications" / DESKTOP_ENTRY_NAME
def icon_path(project_root: Path) -> Path:
"""The app icon shipped in the desktop workspace."""
return project_root / "apps" / "desktop" / "assets" / "icon.png"
def _running_interpreter() -> str:
"""The 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). ``Path.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 and dies on the first third-party import (#90292, one
level up; identified in #80547's review and confirmed on real Zorin/uv
hardware in this PR's review).
Keep the lexical path only when it actually is venv-semantic (a
``pyvenv.cfg`` sits at or above it in the tree); otherwise the
dereferenced absolute path is the more durable form (survives the
symlink being re-pointed or its parent moving).
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)
for base in (path.parent, *path.parent.parents):
if (base / "pyvenv.cfg").is_file():
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 the import in a subprocess under ``-I`` (isolated mode: no
user site, no PYTHONPATH inheritance, no cwd on ``sys.path``) from
a neutral cwd, so the answer matches what a cold desktop
environment would get — a checkout cwd or an inherited
``PYTHONPATH`` cannot produce a false positive. Bounded by a
timeout so a hung interpreter cannot stall entry generation.
Result is cached per interpreter path for the process lifetime, so
a desktop launch pays the subprocess cost at most once.
Probe design per @nosliwhtes' isolated-mode capability check
(#92122 lineage, commit 4150501f641).
"""
key = str(interpreter)
cached = _probe_cache.get(key)
if cached is not None:
return cached
try:
result = subprocess.run(
[key, "-I", "-c", "import hermes_cli.main"],
cwd=os.path.abspath(os.sep),
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
check=False,
timeout=15,
)
ok = result.returncode == 0
except (OSError, subprocess.SubprocessError):
# Unprobeable (missing binary, spawn failure, timeout): do not
# punish the entry on infra hiccups — assume capable and let the
# existing fallback chain handle a genuinely broken interpreter.
# This error-derived answer is deliberately NOT cached: one
# transient hiccup must not freeze the "capable" assumption for
# the whole session; the next install attempt re-probes.
return True
_probe_cache[key] = ok
return ok
def _running_interpreter_fallback() -> str:
"""The interpreter to persist when the candidate fails the import probe.
The RUNNING interpreter by definition has ``hermes_cli`` importable
(this module is executing), so the module-form entry under it is the
safe landing when every candidate path failed the capability check.
"""
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`` executable (argv[0] or PATH). When Hermes
runs as a module with no launcher installed, use the current
interpreter, also absolute.
The persisted entry must be launch-context independent: whatever
process writes it, the next launch must read and rewrite the same
bytes. ``resolve_hermes_bin()`` prefers ``sys.argv[0]``, which differs
per launch path (wrapper, repo script, ``python -m``), so for this
one caller an argv[0] that points inside the checkout is not a
durable installed launcher — skip it and resolve from PATH instead.
Otherwise a broken entry keeps regenerating itself (the repo-script
form pins a mutable uv interpreter path; the ``python -m`` form
persists a bare ``<python> desktop`` that no DE can run).
``project_root`` pins which checkout counts as "internal"; defaults to
the running checkout.
"""
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)):
# 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). Persisting
# it would write a dead entry: the DE spawns the Exec line in a cold
# environment where exactly this import has to succeed. 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()
if bin_path:
resolved = Path(bin_path).resolve()
if _needs_interpreter(resolved):
# The resolved launcher is a Python script whose shebang points at
# a NON-venv interpreter (e.g. the repo's `hermes` script with
# `#!/usr/bin/env python3` when argv[0] came from the shell
# installer's bash wrapper). Launched from the .desktop entry that
# shebang resolves to the SYSTEM python and dies on the first
# third-party import (#90292) — silently, since Terminal=false.
# sys.executable is the interpreter actually running Hermes (the
# venv one), so prefix it explicitly.
argv = [interpreter, str(resolved), "desktop"]
else:
argv = [str(resolved), "desktop"]
else:
argv = [
interpreter,
"-m",
"hermes_cli.main",
"desktop",
]
return " ".join(_quote_exec_arg(a) for a in argv)
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
desktop-entry-specific rule: an ``argv[0]`` that points inside this
checkout is a launch-context artifact (the repo ``hermes`` script the
wrapper execs with, or an interpreter binary surfaced by programmatic
relaunch paths), not a durable installed launcher. Persisting it makes
the entry a function of however the previous launch happened — the
bootstrap loop behind #90492's incomplete fix. Skip argv[0]/relative
candidates in that case and fall through to PATH, where the shell
installer's wrapper lives.
``resolve_fn`` is injectable for tests.
"""
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 form: _inside_checkout resolves candidates for its
# own comparison anyway, and _wrapper_targets_checkout needs the
# lexical root because the installer writes $INSTALL_DIR lexically
# into the shim text (symlinked homes would otherwise mismatch).
# Production callers pass main.py's realpath'd PROJECT_ROOT; the
# module-lexical root derived from __file__ is added alongside so a
# symlinked home still matches the shim's lexically-written paths.
checkout_root = Path(os.path.abspath(checkout_root))
module_lexical_root = _project_root()
original_argv0 = sys.argv[0]
def _inside_checkout(candidate: str) -> bool:
try:
path = Path(candidate).resolve()
except OSError:
return False
# The repo `hermes` script and anything else shipped in the tree is
# checkout-internal. Compare against BOTH the lexical and resolved
# roots (checkout_root is kept lexical; candidates resolve, so a
# symlinked home needs the resolved comparison too).
resolved_root = None
try:
resolved_root = checkout_root.resolve()
except OSError:
pass
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 (or a non-executable main.py, which the
# resolver already skips) as argv[0]; an interpreter is never a
# durable, launchable entry target (it would persist a bare
# `<python> desktop`). Compare against the *invoking* interpreter
# (argv[0]'s own file), not sys.executable — under test harnesses
# they differ.
try:
if path.samefile(original_argv0) and _is_interpreter(path):
return True
except OSError:
pass
return False
def _is_interpreter(candidate: Path) -> bool:
"""A python interpreter binary (``bin/python*``), not a launcher.
Strict basename match — accepts ``python``, ``python3``,
``python3.11``, ``python2.7``; rejects lookalikes such as
``python3-config``, ``pythonw``, and anything else merely
*containing* "python". 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.
"""
import re
name = candidate.name.lower()
if not re.fullmatch(r"python[23]?(\d+)?(\.\d+)?", name):
return False
return candidate.parent.name in {"bin", "scripts"}
# Resolve the primary FIRST and only rerun the resolver with argv[0]
# hidden when the primary could actually be checkout-internal: for an
# already-external primary the comparison can never change the
# outcome, so skipping the rerun saves a resolver call and shortens
# the window in which a concurrent reader could see the mutated
# sys.argv.
primary = resolve_fn()
# 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).
if primary and not _inside_checkout(primary):
return primary
# 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
if primary and _inside_checkout(primary) and rerouted:
return rerouted
if rerouted is None and primary:
# argv[0] was checkout-internal AND PATH had no `hermes` — common
# in stripped systemd user sessions and autostart relaunches.
# The installer's wrapper lives at known locations; probe them
# directly before giving up, otherwise we'd silently persist the
# checkout-internal form this fix exists to prevent. The probe
# runs only after the primary was proven non-durable above, and
# each candidate must itself target THIS checkout (a wrapper
# from another install would make the entry stable-but-wrong —
# same failure class the external-primary-first rule avoids).
probe = _known_wrapper_candidates()
for candidate in probe:
if candidate.is_file() and os.access(candidate, os.X_OK):
if not _wrapper_shebang_safe(candidate):
# The wrapper targets this checkout but its own shebang
# would die in the DE context (e.g. `#!/usr/bin/env
# python3` resolving past the venv): skip it the same
# way a foreign-install wrapper is skipped. Idea
# credited to autumn8's #92122 rung-2 safety check;
# implemented on our ownership machinery.
continue
if _wrapper_targets_checkout(
candidate, checkout_root
) or _wrapper_targets_checkout(candidate, module_lexical_root):
return str(candidate)
# No durable wrapper for THIS checkout exists anywhere (PATH
# miss, known locations miss or belong to another install).
# Persisting the checkout-internal primary would produce an
# entry that regenerates itself or dies on the venv escape;
# dropping to None lets resolve_exec_command emit its runnable
# module fallback.
return None
return primary
def _wrapper_shebang_safe(wrapper: Path) -> bool:
"""Whether an executable wrapper can actually run in the DE context.
A wrapper whose own shebang escapes the venv (``#!/usr/bin/env
python3`` or a bare interpreter name) would die exactly like the
broken entry this module exists to fix — the checkout reference in
its body does not save it. Native binaries and shell launchers are
safe by construction (they exec the right interpreter themselves).
A python-shebang wrapper is safe only when its interpreter resolves
to the RUNNING venv's interpreter directory.
"""
try:
with open(wrapper, "rb") as fh:
head = fh.read(4096)
except OSError:
return False
if head[:4] == b"\x7fELF" or head.startswith(b"MZ"):
return True
if not head.startswith(b"#!"):
# No shebang: the kernel cannot exec it directly either — but it
# may be sourced or exec'd via `sh` by DE-specific glue. Fail
# safe toward the module fallback.
return False
shebang = head.decode("utf-8", errors="replace").splitlines()[0]
tokens = shebang[2:].strip().split()
if not tokens:
return False
interp = Path(tokens[0])
# `#!/usr/bin/env bash` (the installer's own launcher form): `env`
# here is the standard trick to find bash on PATH, and the script
# itself execs the right interpreter. Only python-flavored `env`
# shebangs are the escape hazard.
if interp.name == "env":
# Skip env's own flags (-S, -u VAR, ...) and inspect the first
# real token: `env -S bash` is still a shell launcher.
target = next(
(Path(t) for t in tokens[1:] if not t.startswith("-")),
Path(""),
)
if target.name in ("bash", "sh", "dash", "zsh", "ksh"):
return True
return not _shebang_escapes_running_env(shebang)
if interp.name in ("bash", "sh", "dash", "zsh", "ksh"):
# A shell launcher execs the right interpreter itself.
return True
if "python" not in interp.name.lower():
# Not a python interpreter either — fail safe toward the module
# fallback rather than trusting an unknown interpreter.
return False
# Python wrapper: its shebang must stay inside the RUNNING venv.
return not _shebang_escapes_running_env(shebang)
def _wrapper_targets_checkout(wrapper: Path, checkout_root: Path) -> bool:
"""Whether a candidate launcher script actually launches THIS checkout.
Expects the LEXICAL checkout root (the caller keeps it un-resolved):
the installer writes ``$INSTALL_DIR`` lexically into the shim, so on a
symlinked home the shim text and the resolved root would never match.
Both lexical and resolved forms of the root are tried regardless, to
tolerate either caller convention.
The installer's shim is a small bash script that execs
``<checkout>/venv/bin/python <checkout>/hermes``; a venv console
script carries the venv interpreter in its shebang. Either way, a
text launcher belonging to this installation references the
checkout path (or its venv) somewhere in its first few KB. A
binary launcher (PyInstaller & friends) cannot be inspected that
way — accept it, since binary installs are self-contained and the
external-primary-first rule has already had its say.
"""
try:
head = wrapper.read_bytes()[:4096]
except OSError:
return False
if b"\x7fELF" in head[:4] or head.startswith(b"MZ"):
# Native binary: cannot verify, and cannot be another checkout's
# bash shim either — accept.
return True
try:
text = head.decode("utf-8", errors="replace")
except Exception: # noqa: BLE001 - defensive decode
return False
# Boundary-aware matching: a bare substring test would also accept
# sibling paths that EXTEND this checkout's path (an old install
# renamed aside as `<checkout>-old` or `<checkout>.bak`), silently
# pointing the entry at that other installation. Require the
# reference to end the path (quote, whitespace, or end-of-line
# right after the root) or continue INTO it.
# Compare both the resolved root and its lexical form: the installer
# writes $INSTALL_DIR lexically, so with a symlinked home
# (/home/user -> /mnt/disk/home/user) the shim's text carries the
# lexical path while checkout_root arrives resolved.
roots = {str(checkout_root)}
lexical_root = os.path.abspath(str(checkout_root))
roots.add(lexical_root)
try:
resolved_lexical = str(Path(lexical_root).resolve())
roots.add(resolved_lexical)
except OSError:
pass
for root in roots:
for terminator in ('"', "'", " ", "\n", "\t", "\r", "$", "\x00"):
if root + terminator in text:
return True
if text.rstrip("\r\n").endswith(root):
return True
# The shim's exec line continues INTO the checkout (…/python
# <root>/hermes …): a path-continuation boundary is also a match.
if root + "/" in text:
return True
return False
def _known_wrapper_candidates():
"""Durable installed-launcher locations, most likely first.
Mirrors the installer's ``get_command_link_dir()`` layouts: user
(``~/.local/bin``), root FHS (``/usr/local/bin``), and Termux
(``$PREFIX/bin``). The wrapper is always named ``hermes``.
"""
candidates = []
home = Path.home()
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(home / ".local" / "bin" / "hermes")
return candidates
def _project_root() -> Path:
"""This file lives at ``<checkout>/hermes_cli/linux_desktop_entry.py``.
Lexical (no .resolve()): callers feed this into shim-text matching
where the installer's lexically-written $INSTALL_DIR must be able to
match; symlinked homes would break a resolved comparison.
"""
return Path(os.path.abspath(__file__)).parent.parent
def _needs_interpreter(bin_path: Path) -> bool:
"""Whether ``bin_path`` is a Python script that must run under
``sys.executable`` to see Hermes' venv (rather than its own shebang)."""
try:
with open(bin_path, "rb") as fh:
head = fh.readline(256)
except OSError:
return False
if not head.startswith(b"#!"):
# Native binary (uv tool shim, PyInstaller, distro package) — its own
# loader is self-sufficient.
return False
shebang = head.decode("utf-8", errors="replace").strip()
if "python" not in shebang.lower():
# A shell wrapper (e.g. the installer's bash launcher) execs the venv
# python itself — leave it alone.
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 env.
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).
Extensions over the parent-equality form:
* ``env`` shebangs (``#!/usr/bin/env python3``) ALWAYS escape: ``env``
resolves through PATH, which in the DE's cold environment is not the
interactive PATH that installed the venv — the parent-equality form
could be fooled when the resolved ``env`` binary happens to sit in
the same directory tree.
* Flags after the interpreter (``-S``, ``-E``...) are stripped before
comparing, so a legitimate ``#!<venv>/bin/python -S`` is not
misclassified by comparing against the flag token.
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[2:].strip().split()
if not tokens:
# Bare "#!python" with no path: resolves via PATH — escapes.
return True
interp = Path(tokens[0])
if interp.name in ("env", "env.exe"):
# PATH-resolved interpreter: the DE environment's PATH decides,
# not the installing shell's — treat as escaping. A real
# ``env -S`` venv-absolute form (`env -S <abs>`), rare but valid,
# still resolves the actual interpreter from the second token.
rest = [t for t in tokens[1:] if not t.startswith("-")]
if rest and Path(rest[0]).is_absolute():
interp = Path(rest[0])
else:
return True
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.
Reserved characters require double quotes. Inside the quotes, escape
a backslash and a double quote with a backslash.
"""
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"
"StartupWMClass=Hermes\n"
)
def refresh_desktop_databases(applications_dir: Path) -> "list[str]":
"""Reindex the menu caches. Run each tool only when it exists.
Return the names of the tools that ran (for logging and tests).
"""
ran: list[str] = []
update_db = shutil.which("update-desktop-database")
if update_db:
if _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 not resolved:
continue
if _run_quiet([resolved, "--noincremental"]):
ran.append(tool)
break
return ran
def _run_quiet(cmd: "list[str]") -> bool:
try:
result = subprocess.run(
cmd,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
check=False,
timeout=60,
)
except (OSError, subprocess.SubprocessError):
return False
return result.returncode == 0
# Sizes a typical hicolor ``index.theme`` actually lists. ``scalable`` is
# SVG-only — a raster PNG there is what Cinnamon's panel draws as a
# mangled low-res blob. The shipped desktop asset is 1024×1024, which is
# also 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 ~2432px. Write these so the theme loads an exact
# raster instead of downscaling a 1024px PNG at lookup time.
_HICOLOR_INSTALL_SIZES = (24, 32, 48, 256)
def _png_dimensions(raw: bytes) -> Optional[tuple[int, int]]:
"""Return ``(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 != height or width <= 0:
return "256x256"
if width in _HICOLOR_INDEXED_SIZES:
return f"{width}x{width}"
if 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).
Return True when a file was removed so the caller can refresh the
icon cache. A missing file is not an error.
"""
stale = _hicolor_icon_dest("scalable")
try:
if stale.is_file():
stale.unlink()
return True
except OSError:
return False
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.
Pillow is a core dep but this module stays import-light: the import is
local so the uninstaller does not pay it. A truncated/fake PNG (tests,
interrupted copy) returns None and the caller falls back to a copy.
"""
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:
resized = rgba.resize((size, size), Image.Resampling.LANCZOS)
buf = io.BytesIO()
resized.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 icon theme tree.
The freedesktop icon lookup finds an installed ``apps/hermes.png``
by the unqualified name ``hermes``, so the entry can reference the
icon without an absolute checkout path. Raster PNGs go to indexed
fixed-size dirs, never ``scalable`` (SVG-only). When the source
decodes, it is Lanczos-resized to 24/32/48/256 so Cinnamon's panel
does not nearest-neighbor a 1024px PNG. Undecodable bytes fall back
to a copy into one indexed dir. Idempotent via content-compare;
OSError caught internally (False) — the caller then falls back to
the absolute path.
"""
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 = True
if dest.is_file() and dest.read_bytes() == raw:
wrote = False
else:
dest.parent.mkdir(parents=True, exist_ok=True)
shutil.copyfile(icon, dest)
removed_stale = _remove_stale_scalable_icon()
if wrote or removed_stale:
_refresh_hicolor_cache()
return True
except OSError:
return False
def install_desktop_entry(project_root: Path) -> Optional[Path]:
"""Write (or refresh) the Hermes desktop entry. Return its path.
Return ``None`` on non-Linux platforms or when the write fails. This
is a convenience, never a reason to fail a launch.
"""
if not is_supported():
return None
entry_path = desktop_entry_path()
icon = icon_path(project_root)
# Prefer the themed name: the icon is COPIED into the user's hicolor
# tree, so the entry outlives the checkout (moving/archiving the
# checkout would break an absolute Icon= path — the same
# durability class the Exec line was fixed for). Fall back to the
# absolute path only when the copy is impossible (read-only tree),
# and to the 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") == contents:
return entry_path
# Atomic replace: an interrupted plain write can leave 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
refresh_desktop_databases(entry_path.parent)
return entry_path