"""Install and remove the Linux desktop entry (``.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 `.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 converts it into a hidden # alias (NoDisplay=true) so pre-rename taskbar pins keep resolving (see _alias_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 `` -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 # ` 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 /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 /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 (``-old``, ``.bak``), so the root must end at a quote / whitespace / EOL or continue INTO the tree (``/``). 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: """```` 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: ``/bin-extra/python`` is not inside ``/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 `` form, which is judged by its absolute target. Tokenizes the shebang (interpreter path plus any flags) and compares PATH COMPONENTS, never substrings: ``/bin-extra/python`` is not inside ``/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: """The app-id entry: identity lives in the file name and ``StartupWMClass``.""" 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 _render_legacy_alias_entry(exec_command: str, icon: str) -> str: """The app-id entry, hidden from the app grid; ``StartupWMClass`` still groups old pins.""" return render_desktop_entry(exec_command, icon) + "NoDisplay=true\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 _alias_legacy_desktop_entry(applications_dir: Path, exec_command: str, icon: str) -> bool: """Keep the pre-rename ``hermes.desktop`` as a hidden alias of the app-id entry. Shells resolve a taskbar pin by the entry file name it was pinned against: deleting the file makes GNOME drop the favourite and Plasma leave an inert item, and nothing can re-pin for the user. The alias stays launchable for old pins without listing Hermes twice. Only a file that still names this app is converted; anything else at that path is left alone. True when the legacy file was (re)written. """ legacy = applications_dir / LEGACY_DESKTOP_ENTRY_NAME try: text = legacy.read_text(encoding="utf-8-sig") except OSError: return False if not any(line.strip() == "Name=Hermes" for line in text.splitlines()): return False alias_contents = _render_legacy_alias_entry(exec_command, icon) if text == alias_contents: return False try: from utils import atomic_write_text atomic_write_text(legacy, alias_contents, create_mode=0o755) legacy.chmod(0o755) except OSError: return False return True 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 converted into a hidden alias once the new entry exists, and only while launcher management is enabled — deleting it instead would silently kill existing taskbar pins (#124492). ``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" exec_command = resolve_exec_command(project_root) contents = render_desktop_entry(exec_command, 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. unchanged = entry_path.is_file() and entry_path.read_text(encoding="utf-8-sig") == contents if not unchanged: # 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 # Converting the old entry is management too: with the opt-out set, an existing # launcher stays untouched even here, in the missing-entry path where the new # entry is still created. aliased = manage_enabled and _alias_legacy_desktop_entry(entry_path.parent, exec_command, icon_value) if aliased or not unchanged: 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