On Linux a freshly-built desktop app had no presence in the application launcher: no Hermes in the KDE/GNOME menu, no icon, nothing to pin. Users had to hand-write ~/.local/share/applications/hermes.desktop and remember to reindex the menu caches themselves. `hermes desktop` now writes that entry itself (best-effort, idempotent, never blocking a launch), and `hermes uninstall --gui` removes it again. Both fields that matter are absolute: - Exec — the launcher runs with a minimal environment and no shell PATH customizations, so a bare `hermes desktop` silently fails for anyone whose hermes lives in ~/.local/bin or a venv. We resolve the real binary via relaunch.resolve_hermes_bin(), falling back to an absolute interpreter + `-m hermes_cli.main`. - Icon — an unqualified name only resolves against an indexed icon theme, which we are not in. The spec allows an absolute path, so we point at apps/desktop/assets/icon.png in the checkout. No copy is installed: Exec already depends on that same tree, so a second copy would add bytes and an uninstall step without surviving anything Exec wouldn't. Menu-cache refresh is tool-gated — update-desktop-database, then kbuildsycoca6 or kbuildsycoca5 — each only when the binary is actually on PATH, because most desktops ship none of them and a missing one is not an error. The entry is only rewritten when its contents change, so a launch doesn't churn the caches every run. Verified on NixOS: the generated entry passes desktop-file-validate, a real kbuildsycoca6 on PATH is invoked with --noincremental, a real update-desktop-database writes mimeinfo.cache, absent tools are skipped cleanly, and removal leaves the checkout's icon untouched.
174 lines
5.7 KiB
Python
174 lines
5.7 KiB
Python
"""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, 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 and the
|
|
Electron main process both use this without loading the full CLI.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import shutil
|
|
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 resolve_exec_command() -> 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.
|
|
"""
|
|
from hermes_cli.relaunch import resolve_hermes_bin
|
|
|
|
bin_path = resolve_hermes_bin()
|
|
if bin_path:
|
|
argv = [str(Path(bin_path).resolve()), "desktop"]
|
|
else:
|
|
argv = [str(Path(sys.executable).resolve()), "-m", "hermes_cli.main", "desktop"]
|
|
return " ".join(_quote_exec_arg(a) for a in argv)
|
|
|
|
|
|
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
|
|
|
|
|
|
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)
|
|
# Use the themed name when the checkout has no icon (a lite or
|
|
# packaged install). A broken absolute path renders as no icon.
|
|
icon_value = str(icon) if icon.is_file() else "hermes"
|
|
contents = render_desktop_entry(resolve_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.
|
|
if entry_path.is_file() and entry_path.read_text(encoding="utf-8") == contents:
|
|
return entry_path
|
|
entry_path.write_text(contents, encoding="utf-8")
|
|
# 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
|