Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 22 additions & 1 deletion hermes_cli/gui_uninstall.py
Original file line number Diff line number Diff line change
Expand Up @@ -142,10 +142,15 @@ def packaged_gui_app_paths() -> "list[Path]":
# hint rather than guessing. deb/rpm installs are owned by the system
# package manager and must be removed via apt/dnf — see the message in
# ``uninstall_gui``.
from hermes_cli.linux_desktop_entry import desktop_entry_path

data = os.environ.get("XDG_DATA_HOME")
data_base = Path(data) if data else (home / ".local" / "share")
paths += [
data_base / "applications" / "hermes.desktop",
# The launcher entry `hermes desktop` installs. Its icon lives
# in the checkout, not in the installed app.
desktop_entry_path(),
# Some packaged builds emit this casing.
data_base / "applications" / "Hermes.desktop",
]
return paths
Expand Down Expand Up @@ -275,6 +280,22 @@ def uninstall_gui(hermes_home: "Path | None" = None, *, remove_userdata: bool =
# shouldn't) rmtree files under /usr. Surface the hint so the user can
# finish the job. AppImages live wherever the user dropped them.
if sys.platform.startswith("linux"):
# The desktop entry was removed above (it is in
# ``packaged_gui_app_paths``), but the menu caches still list it.
# Reindex so Hermes disappears from the launcher.
try:
from hermes_cli.linux_desktop_entry import (
desktop_entry_path,
refresh_desktop_databases,
)

entry = desktop_entry_path()
if entry in removed:
for tool in refresh_desktop_databases(entry.parent):
log_success(f"Refreshed the application menu cache ({tool})")
except Exception as e:
log_warn(f"Could not refresh the application menu cache: {e}")

log_info(
"If you installed the desktop via a .deb / .rpm package, remove it "
"with your package manager (e.g. 'sudo apt remove hermes' or "
Expand Down
173 changes: 173 additions & 0 deletions hermes_cli/linux_desktop_entry.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
"""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
24 changes: 24 additions & 0 deletions hermes_cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -6979,6 +6979,25 @@ def _desktop_launch_options() -> tuple[list[str], str]:
return flags, disable_gpu


def _register_linux_desktop_entry() -> None:
"""Install the XDG desktop entry for Hermes Desktop (Linux only, best-effort).

Gives the Electron app a launcher presence: a menu item and an icon.
``Exec`` and ``Icon`` are absolute, so the entry works outside a login
shell. ``hermes uninstall --gui`` removes it.
"""
try:
from hermes_cli.linux_desktop_entry import install_desktop_entry, is_supported

if not is_supported():
return
entry = install_desktop_entry(PROJECT_ROOT)
if entry:
print(f"✓ Desktop launcher entry installed: {entry}")
except Exception as exc: # never block a launch on launcher plumbing
print(f"⚠ Could not install the desktop launcher entry: {exc}")


def cmd_gui(args: argparse.Namespace):
"""Build and launch the native Electron desktop GUI."""
desktop_dir = PROJECT_ROOT / "apps" / "desktop"
Expand Down Expand Up @@ -7180,6 +7199,11 @@ def cmd_gui(args: argparse.Namespace):
# Build succeeded — write the stamp so next run can skip
_write_desktop_build_stamp(PROJECT_ROOT, source_mode=source_mode)

# Linux: register the app in the desktop launcher, so Hermes shows up
# in the application menu with its icon. Best-effort and idempotent.
# A failure must never stop the app from launching.
_register_linux_desktop_entry()

# --build-only: produce the artifact but do NOT launch. The installer's
# --update flow drives the rebuild headlessly and then launches the desktop
# itself (detached, after the old exe has exited), so the launch must NOT
Expand Down
17 changes: 15 additions & 2 deletions nix/desktop.nix
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,13 @@
hermesNpmLib,
electron,
hermesAgent,
python3,
...
}:
let
electronHeaders = pkgs.fetchurl {
url = "https://artifacts.electronjs.org/headers/dist/v${electron.version}/node-v${electron.version}-headers.tar.gz";
sha256 = "sha256-f8bSbLRmtbP93CJAvEBs+sHWDZ1xP2bcpLhC1EnOmZU=";
sha256 = "sha256-0nUJBQDEikyYntZwq+ycH32mzEQtQmz3ICz9eeTMpJk=";
};

# node-pty ships no Electron-tagged prebuild we can trust to match this
Expand Down Expand Up @@ -141,7 +142,10 @@ stdenv.mkDerivation {
dontUnpack = true;
dontBuild = true;

nativeBuildInputs = [ makeWrapper ];
nativeBuildInputs = [
makeWrapper
python3
];

installPhase = ''
runHook preInstall
Expand All @@ -166,6 +170,15 @@ stdenv.mkDerivation {
--set HERMES_DESKTOP_HERMES "${lib.getExe hermesAgent}" \
--set ELECTRON_IS_DEV 0

# XDG launcher entry
mkdir -p $out/share/applications $out/share/icons/hicolor/1024x1024/apps
install -m 0644 ${../apps/desktop/assets/icon.png} \
$out/share/icons/hicolor/1024x1024/apps/hermes.png
export PYTHONPATH=$(mktemp -d)
cp ${../hermes_cli/linux_desktop_entry.py} "$PYTHONPATH/linux_desktop_entry.py"
export DESKTOP_EXEC="$out/bin/hermes-desktop"
export DESKTOP_ICON="$out/share/icons/hicolor/1024x1024/apps/hermes.png"
python3 -c 'import os; from linux_desktop_entry import render_desktop_entry; print(render_desktop_entry(os.environ["DESKTOP_EXEC"], os.environ["DESKTOP_ICON"]))' > $out/share/applications/hermes.desktop
runHook postInstall
'';

Expand Down
77 changes: 77 additions & 0 deletions tests/hermes_cli/test_gui_command.py
Original file line number Diff line number Diff line change
Expand Up @@ -382,3 +382,80 @@ def boom(*a, **kw):
# --- desktop.* launch options (config.yaml) -------------------------------




# --- Linux launcher entry registration ------------------------------------


def test_gui_registers_linux_desktop_entry_before_launch(tmp_path, monkeypatch):
"""`hermes desktop` gives the app a launcher presence on Linux."""
root = _make_desktop_tree(tmp_path)
monkeypatch.setattr(cli_main, "PROJECT_ROOT", root)
packaged_exe = _make_packaged_executable(root, monkeypatch, platform="linux")

registered: list[Path] = []
monkeypatch.setattr("hermes_cli.linux_desktop_entry.is_supported", lambda: True)
monkeypatch.setattr(
"hermes_cli.linux_desktop_entry.install_desktop_entry",
lambda project_root: registered.append(project_root) or (tmp_path / "hermes.desktop"),
)

launch_ok = subprocess.CompletedProcess([str(packaged_exe)], 0)

with patch("hermes_cli.main._desktop_build_needed", return_value=False), \
patch("hermes_cli.main._resolve_node_runtime_npm", return_value="/usr/bin/npm"), \
patch("hermes_cli.main._desktop_linux_sandbox_fixup", return_value=True), \
patch("hermes_cli.main.subprocess.run", return_value=launch_ok), \
pytest.raises(SystemExit):
cli_main.cmd_gui(_ns())

assert registered == [root]


def test_gui_launches_even_when_desktop_entry_install_fails(tmp_path, monkeypatch):
"""Launcher plumbing is a convenience — it must never block the app."""
root = _make_desktop_tree(tmp_path)
monkeypatch.setattr(cli_main, "PROJECT_ROOT", root)
packaged_exe = _make_packaged_executable(root, monkeypatch, platform="linux")

def boom(_project_root):
raise OSError("read-only /home")

monkeypatch.setattr("hermes_cli.linux_desktop_entry.is_supported", lambda: True)
monkeypatch.setattr("hermes_cli.linux_desktop_entry.install_desktop_entry", boom)

launch_ok = subprocess.CompletedProcess([str(packaged_exe)], 0)

with patch("hermes_cli.main._desktop_build_needed", return_value=False), \
patch("hermes_cli.main._resolve_node_runtime_npm", return_value="/usr/bin/npm"), \
patch("hermes_cli.main._desktop_linux_sandbox_fixup", return_value=True), \
patch("hermes_cli.main.subprocess.run", return_value=launch_ok) as mock_run, \
pytest.raises(SystemExit) as exc:
cli_main.cmd_gui(_ns())

assert exc.value.code == 0
assert mock_run.call_args.args[0] == [str(packaged_exe)]


def test_gui_skips_desktop_entry_off_linux(tmp_path, monkeypatch):
root = _make_desktop_tree(tmp_path)
monkeypatch.setattr(cli_main, "PROJECT_ROOT", root)
packaged_exe = _make_packaged_executable(root, monkeypatch, platform="darwin")

monkeypatch.setattr("hermes_cli.linux_desktop_entry.is_supported", lambda: False)

def fail(_project_root):
raise AssertionError("must not install a desktop entry off Linux")

monkeypatch.setattr("hermes_cli.linux_desktop_entry.install_desktop_entry", fail)

launch_ok = subprocess.CompletedProcess([str(packaged_exe)], 0)

with patch("hermes_cli.main._desktop_build_needed", return_value=False), \
patch("hermes_cli.main._resolve_node_runtime_npm", return_value="/usr/bin/npm"), \
patch("hermes_cli.main._desktop_macos_relaunchable_fixup"), \
patch("hermes_cli.main.subprocess.run", return_value=launch_ok), \
pytest.raises(SystemExit) as exc:
cli_main.cmd_gui(_ns())

assert exc.value.code == 0
Loading
Loading