Skip to content
Open
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
51 changes: 48 additions & 3 deletions hermes_cli/linux_desktop_entry.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,20 +57,65 @@ def icon_path(project_root: Path) -> Path:
return project_root / "apps" / "desktop" / "assets" / "icon.png"


def _is_env_python_source_wrapper(path: Path) -> bool:
"""Whether ``path`` is a script that looks up ``python`` via ``PATH``.

A ``#!/usr/bin/env python3`` wrapper (e.g. a repo-root ``hermes``
launcher) resolves whatever interpreter is first on ``PATH`` at run
time. A desktop session's ``PATH`` can differ from wherever ``hermes
desktop`` was invoked from, so persisting this wrapper into ``Exec=``
risks a launcher that only works in one environment. An installed
console-script wrapper's shebang instead hardcodes its own venv
interpreter's absolute path, which has no such PATH dependency.

Requires the shebang's interpreter *basename* to be exactly ``env``
(not merely contain ``/env`` -- a real, hardcoded interpreter path
under a directory literally named ``envs`` must not match) and the
command it invokes to be a ``python*`` binary, tolerating ``env``'s
``-S`` flag.
"""
try:
with path.open("rb") as fh:
head = fh.read(256)
except OSError:
return False
if not head.startswith(b"#!"):
return False
tokens = head.split(b"\n", 1)[0][2:].split()
if not tokens:
return False
if os.path.basename(tokens[0].decode("utf-8", "replace")) != "env":
return False
args = tokens[1:]
if args[:1] == [b"-S"]:
args = args[1:]
if not args:
return False
command = os.path.basename(args[0].decode("utf-8", "replace"))
return command.startswith("python")


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
runs as a module with no launcher installed, or the resolved binary is
a raw ``env python`` source wrapper unsafe to persist, use the current
interpreter, also absolute.
"""
from hermes_cli.relaunch import resolve_hermes_bin

bin_path = resolve_hermes_bin()
if bin_path:
if bin_path and not _is_env_python_source_wrapper(Path(bin_path)):
argv = [str(Path(bin_path).resolve()), "desktop"]
else:
argv = [str(Path(sys.executable).resolve()), "-m", "hermes_cli.main", "desktop"]
# Absolute WITHOUT resolving a symlink: a venv's `bin/python` is
# typically a symlink to the base interpreter, and Python's venv
# detection keys off the invocation path (it looks for a
# `pyvenv.cfg` beside it), not the symlink target. Resolving it
# away would lose the venv's site-packages -- and hermes_cli with
# it -- the moment this Exec line runs from an unrelated cwd.
argv = [os.path.abspath(sys.executable), "-m", "hermes_cli.main", "desktop"]
return " ".join(_quote_exec_arg(a) for a in argv)


Expand Down
17 changes: 12 additions & 5 deletions hermes_cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -7245,17 +7245,19 @@ 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
# happen here — it would block the installer and, on Windows, the old exe
# is still being replaced. Verify the expected artifact exists so a silent
# "built nothing" can't slip past, then return success.
#
# Also skip launcher registration here: this is a headless updater rebuild
# (Desktop self-update → `hermes update` → `hermes desktop --build-only`),
# not an interactive launch, and its environment can differ from a real
# desktop session's. Registering here risks persisting an Exec= command
# that only works in the updater's environment into a launcher a desktop
# session later double-clicks.
if getattr(args, "build_only", False):
if source_mode:
if not _desktop_dist_exists(desktop_dir):
Expand All @@ -7270,6 +7272,11 @@ def cmd_gui(args: argparse.Namespace):
print(f"✓ Desktop packaged app ready: {packaged_executable} (not launching; --build-only)")
return

# 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()

if source_mode:
print("→ Launching Hermes Desktop from source build...")
launch_result = subprocess.run([npm, "exec", "--", "electron", "."], cwd=desktop_dir, env=env, check=False)
Expand Down
27 changes: 27 additions & 0 deletions tests/hermes_cli/test_gui_command.py
Original file line number Diff line number Diff line change
Expand Up @@ -437,6 +437,33 @@ def boom(_project_root):
assert mock_run.call_args.args[0] == [str(packaged_exe)]


def test_gui_build_only_does_not_register_linux_desktop_entry(tmp_path, monkeypatch):
"""`hermes desktop --build-only` is the updater's headless rebuild step
(Desktop self-update → `hermes update` → `hermes desktop --build-only`).
It must never create or rewrite the launcher entry: the updater's
environment can differ from the desktop session's, so anything written
here risks persisting a broken `Exec=` into a launcher a real desktop
session later double-clicks.
"""
root = _make_desktop_tree(tmp_path)
monkeypatch.setattr(cli_main, "PROJECT_ROOT", root)
_make_packaged_executable(root, monkeypatch, platform="linux")

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

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

with patch("hermes_cli.main._desktop_build_needed", return_value=False), \
patch("hermes_cli.main._resolve_node_runtime_npm", return_value="/usr/bin/npm"):
cli_main.cmd_gui(_ns(build_only=True))

assert registered == []


def test_gui_skips_desktop_entry_off_linux(tmp_path, monkeypatch):
root = _make_desktop_tree(tmp_path)
monkeypatch.setattr(cli_main, "PROJECT_ROOT", root)
Expand Down
96 changes: 96 additions & 0 deletions tests/hermes_cli/test_linux_desktop_entry.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from __future__ import annotations

import os
import stat
from pathlib import Path

Expand Down Expand Up @@ -88,6 +89,101 @@ def test_exec_falls_back_to_interpreter_module(tmp_path, xdg_home, monkeypatch):
assert Path(exec_line.split(" ")[0]).is_absolute()


@pytest.mark.skipif(os.name == "nt", reason="symlink creation needs privileges on Windows")
def test_exec_falls_back_to_unresolved_venv_interpreter_path(tmp_path, xdg_home, monkeypatch):
"""The interpreter fallback must keep the invocation path Python was
actually started with, not resolve through a ``venv/bin/python ->
base-interpreter`` symlink.

Python's venv detection keys off the *invocation* path (it looks for a
``pyvenv.cfg`` beside it), not the symlink target. Resolving the
symlink away therefore loses the venv's site-packages entirely from an
unrelated cwd -- exactly what ``hermes desktop`` (launched from a
packaged app in another directory) needs to survive.
"""
root = _make_project(tmp_path)
base_interpreter = tmp_path / "base-python"
base_interpreter.write_bytes(b"")
venv_interpreter = tmp_path / "venv with spaces" / "bin" / "python"
venv_interpreter.parent.mkdir(parents=True)
venv_interpreter.symlink_to(base_interpreter)
monkeypatch.setattr(lde.sys, "executable", str(venv_interpreter))
monkeypatch.setattr("hermes_cli.relaunch.resolve_hermes_bin", lambda: None)
monkeypatch.setattr(lde, "refresh_desktop_databases", lambda _dir: [])

entry = lde.install_desktop_entry(root)
exec_line = _parse(entry.read_text(encoding="utf-8"))["Exec"]
expected_interpreter = os.path.abspath(lde.sys.executable)

# Compare the full, correctly quoted Exec command. Whitespace splitting
# would misparse supported interpreter paths containing spaces.
assert exec_line == (
f'{lde._quote_exec_arg(expected_interpreter)} '
"-m hermes_cli.main desktop"
)
assert Path(expected_interpreter).is_symlink()
assert os.path.realpath(expected_interpreter) != expected_interpreter


def test_exec_does_not_persist_env_python_source_wrapper(tmp_path, xdg_home, monkeypatch):
"""A raw ``#!/usr/bin/env python3`` source-tree wrapper (e.g. repo-root
``hermes``) resolves whatever ``python3`` is first on PATH. A desktop
session's PATH can differ from wherever ``hermes desktop`` was invoked
from, so persisting that wrapper into ``Exec=`` risks a launcher that
only works in one environment. Fall back to the current interpreter,
already resolved absolute, instead.
"""
root = _make_project(tmp_path)
wrapper = tmp_path / "repo" / "hermes"
wrapper.parent.mkdir()
wrapper.write_text(
"#!/usr/bin/env python3\nfrom hermes_cli.main import main\nmain()\n",
encoding="utf-8",
)
wrapper.chmod(0o755)
monkeypatch.setattr("hermes_cli.relaunch.resolve_hermes_bin", lambda: str(wrapper))
monkeypatch.setattr(lde, "refresh_desktop_databases", lambda _dir: [])

entry = lde.install_desktop_entry(root)
exec_line = _parse(entry.read_text(encoding="utf-8"))["Exec"]

interpreter = lde._quote_exec_arg(os.path.abspath(lde.sys.executable))
assert exec_line == f"{interpreter} -m hermes_cli.main desktop"


def _write_shebang(path: Path, shebang: str) -> Path:
path.write_text(f"{shebang}\n", encoding="utf-8")
path.chmod(0o755)
return path


def test_env_python_wrapper_detects_usr_bin_env_python3(tmp_path):
wrapper = _write_shebang(tmp_path / "hermes", "#!/usr/bin/env python3")
assert lde._is_env_python_source_wrapper(wrapper) is True


def test_env_python_wrapper_detects_env_dash_s_python3(tmp_path):
wrapper = _write_shebang(tmp_path / "hermes", "#!/usr/bin/env -S python3 -I")
assert lde._is_env_python_source_wrapper(wrapper) is True


def test_env_python_wrapper_ignores_installed_python_under_envs_dir(tmp_path):
"""``/env`` appearing as a substring of a directory name (e.g. a real,
hardcoded interpreter path under a conda/venv tree literally named
``envs``) is not the ``/usr/bin/env`` PATH-lookup shim. Must not be
misclassified as an unsafe wrapper.
"""
venv_bin = tmp_path / "home" / "user" / "envs" / "hermes" / "bin"
venv_bin.mkdir(parents=True)
wrapper = _write_shebang(tmp_path / "hermes", f"#!{venv_bin / 'python3'}")
assert lde._is_env_python_source_wrapper(wrapper) is False


def test_env_python_wrapper_ignores_non_python_env_shebang(tmp_path):
wrapper = _write_shebang(tmp_path / "hermes", "#!/usr/bin/env bash")
assert lde._is_env_python_source_wrapper(wrapper) is False


def test_install_is_idempotent_and_skips_cache_refresh(tmp_path, xdg_home, monkeypatch):
root = _make_project(tmp_path)
monkeypatch.setattr("hermes_cli.relaunch.resolve_hermes_bin", lambda: "/usr/bin/hermes")
Expand Down