261 lines
10 KiB
Python
261 lines
10 KiB
Python
"""Hermes Desktop (Chat GUI) uninstaller.
|
|
|
|
This holds the desktop's own ``connection.json`` / ``updates.json`` and Chromium cache — pure GUI
|
|
state, safe to remove on a GUI uninstall.
|
|
"""
|
|
|
|
import os
|
|
import shutil
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
from hermes_constants import get_hermes_home
|
|
|
|
from hermes_cli.colors import Colors, color
|
|
|
|
|
|
def log_info(msg: str):
|
|
print(f"{color('→', Colors.CYAN)} {msg}")
|
|
|
|
|
|
def log_success(msg: str):
|
|
print(f"{color('✓', Colors.GREEN)} {msg}")
|
|
|
|
|
|
def log_warn(msg: str):
|
|
print(f"{color('⚠', Colors.YELLOW)} {msg}")
|
|
|
|
|
|
def _env_dir(var: str, fallback: Path) -> Path:
|
|
"""``Path($var)`` when the env var is set, else *fallback*."""
|
|
value = os.environ.get(var)
|
|
return Path(value) if value else fallback
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Discovery
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _agent_root(hermes_home: Path) -> Path:
|
|
"""The agent checkout root — same layout install.sh / install.ps1 use."""
|
|
return hermes_home / "hermes-agent"
|
|
|
|
|
|
def desktop_userdata_dir() -> Path:
|
|
"""Return the Electron ``userData`` directory for the desktop app.
|
|
|
|
Mirrors Electron's ``app.getPath('userData')`` for an app named "Hermes" on each platform. This
|
|
is GUI-only state (connection.json, updates.json, Chromium cache) and never holds agent config
|
|
or sessions.
|
|
"""
|
|
home = Path.home()
|
|
if sys.platform == "darwin":
|
|
return home / "Library" / "Application Support" / "Hermes"
|
|
if sys.platform == "win32":
|
|
return _env_dir("APPDATA", home / "AppData" / "Roaming") / "Hermes"
|
|
# Linux / other POSIX — XDG config home.
|
|
return _env_dir("XDG_CONFIG_HOME", home / ".config") / "Hermes"
|
|
|
|
|
|
def source_built_gui_artifacts(hermes_home: Path) -> "list[Path]":
|
|
"""GUI build artifacts produced by ``hermes desktop`` inside the checkout.
|
|
|
|
These are removable on a GUI uninstall without harming the agent: the Python agent runs from
|
|
``hermes-agent/`` source + ``venv/`` and never needs the Electron build output or node_modules.
|
|
"""
|
|
agent_root = _agent_root(hermes_home)
|
|
desktop_dir = agent_root / "apps" / "desktop"
|
|
return [
|
|
desktop_dir / "dist",
|
|
desktop_dir / "release",
|
|
desktop_dir / "node_modules",
|
|
# Workspace-root node_modules carries Electron (devDependency of the
|
|
# desktop workspace, ~200MB). The agent does not use any npm package,
|
|
# so this is GUI tooling — safe to drop on a GUI uninstall.
|
|
agent_root / "node_modules",
|
|
hermes_home / "desktop-build-stamp.json",
|
|
]
|
|
|
|
|
|
def packaged_gui_app_paths() -> "list[Path]":
|
|
"""Standard install locations of the packaged desktop distributable.
|
|
|
|
Returns every candidate for the current OS; the caller filters to those that actually exist. We
|
|
never glob system-wide — only the well-known electron-builder output locations for the "Hermes"
|
|
product.
|
|
"""
|
|
home = Path.home()
|
|
paths: list[Path] = []
|
|
if sys.platform == "darwin":
|
|
paths += [
|
|
Path("/Applications/Hermes.app"),
|
|
home / "Applications" / "Hermes.app",
|
|
]
|
|
elif sys.platform == "win32":
|
|
local_base = _env_dir("LOCALAPPDATA", home / "AppData" / "Local")
|
|
paths += [
|
|
# NSIS per-user install (perMachine=false → Programs\Hermes).
|
|
local_base / "Programs" / "Hermes",
|
|
# Older / alternate layout some builds used.
|
|
local_base / "hermes-desktop",
|
|
]
|
|
program_files = os.environ.get("ProgramFiles")
|
|
if program_files:
|
|
# NSIS per-machine fallback (needs admin to remove).
|
|
paths.append(Path(program_files) / "Hermes")
|
|
else:
|
|
# Linux: AppImage is a single file the user placed somewhere; we can
|
|
# only reliably clean the desktop entry + icon we know the name of.
|
|
# The AppImage itself lives wherever the user put it, so we surface a
|
|
# 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_base = _env_dir("XDG_DATA_HOME", home / ".local" / "share")
|
|
paths += [
|
|
# The launcher entry `hermes desktop` installs. Its icon is
|
|
# also copied into the hicolor tree (see
|
|
# linux_desktop_entry._install_icon_to_hicolor) — remove
|
|
# every size dir the installer could have written.
|
|
desktop_entry_path(),
|
|
# Some packaged builds emit this casing.
|
|
data_base / "applications" / "Hermes.desktop",
|
|
data_base / "icons" / "hicolor" / "scalable" / "apps" / "hermes.png",
|
|
]
|
|
# Fixed-size hicolor dirs the installer may have written (resized
|
|
# panel sizes plus leftover native-size copies from older builds).
|
|
for size in ("24x24", "32x32", "48x48", "256x256", "512x512", "1024x1024"):
|
|
paths.append(data_base / "icons" / "hicolor" / size / "apps" / "hermes.png")
|
|
return paths
|
|
|
|
|
|
def agent_is_installed(hermes_home: Path) -> bool:
|
|
"""Return True when a usable Python agent install exists under HERMES_HOME.
|
|
|
|
Used by the desktop UI to decide which uninstall options to offer: if the agent isn't present (a
|
|
future "lite" GUI-only client), the "remove agent" options are hidden.
|
|
"""
|
|
agent_root = _agent_root(hermes_home)
|
|
# A real install has the package source + a venv. Either signal alone is
|
|
# enough — a source checkout without a venv is still "the agent is here".
|
|
return any((agent_root / sub).is_dir() for sub in ("hermes_cli", "venv", ".venv"))
|
|
|
|
|
|
def gui_is_installed(hermes_home: Path) -> bool:
|
|
"""Return True when any desktop GUI artifact exists (built or packaged)."""
|
|
return any(
|
|
p.exists()
|
|
for p in (*source_built_gui_artifacts(hermes_home), *packaged_gui_app_paths(), desktop_userdata_dir())
|
|
)
|
|
|
|
|
|
def gui_install_summary(hermes_home: "Path | None" = None) -> dict:
|
|
"""Structured snapshot of what's installed, for the desktop UI to render.
|
|
|
|
Returns JSON-serializable primitives (paths as strings, booleans for the questions the UI gates
|
|
on) so the Electron main process can forward it to the renderer via IPC.
|
|
"""
|
|
home: Path = hermes_home if hermes_home is not None else get_hermes_home()
|
|
userdata = desktop_userdata_dir()
|
|
|
|
return {
|
|
"hermes_home": str(home),
|
|
"agent_installed": agent_is_installed(home),
|
|
"gui_installed": gui_is_installed(home),
|
|
"source_built_artifacts": [str(p) for p in source_built_gui_artifacts(home) if p.exists()],
|
|
"packaged_app_paths": [str(p) for p in packaged_gui_app_paths() if p.exists()],
|
|
"userdata_dir": str(userdata),
|
|
"userdata_exists": userdata.exists(),
|
|
"platform": sys.platform,
|
|
}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Removal
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _remove_path(path: Path) -> bool:
|
|
"""Remove a file or directory tree. Returns True when something was removed."""
|
|
try:
|
|
if path.is_symlink() or path.is_file():
|
|
path.unlink()
|
|
return True
|
|
if path.is_dir():
|
|
shutil.rmtree(path)
|
|
return True
|
|
except Exception as e:
|
|
log_warn(f"Could not remove {path}: {e}")
|
|
return False
|
|
|
|
|
|
def uninstall_gui(
|
|
hermes_home: "Path | None" = None, *, remove_userdata: bool = True
|
|
) -> "list[Path]":
|
|
"""Remove the desktop GUI's artifacts, leaving the agent + user data intact.
|
|
|
|
Never touches ``hermes-agent/hermes_cli`` (agent source), ``venv/``, or any config / sessions /
|
|
.env under ``$HERMES_HOME``.
|
|
"""
|
|
home: Path = hermes_home if hermes_home is not None else get_hermes_home()
|
|
|
|
removed: list[Path] = []
|
|
|
|
def _remove_existing(paths) -> bool:
|
|
"""Remove every existing path; True when at least one existed."""
|
|
found = False
|
|
for path in paths:
|
|
if path.exists():
|
|
found = True
|
|
if _remove_path(path):
|
|
log_success(f"Removed {path}")
|
|
removed.append(path)
|
|
return found
|
|
|
|
log_info("Removing built GUI artifacts (renderer, release, node_modules)...")
|
|
_remove_existing(source_built_gui_artifacts(home))
|
|
|
|
log_info("Removing installed desktop app...")
|
|
if not _remove_existing(packaged_gui_app_paths()):
|
|
log_info("No packaged desktop app found in standard locations")
|
|
|
|
if remove_userdata:
|
|
userdata = desktop_userdata_dir()
|
|
if userdata.exists():
|
|
log_info("Removing desktop app data (Electron userData)...")
|
|
_remove_existing([userdata])
|
|
|
|
if not removed:
|
|
log_info("No desktop GUI artifacts found to remove")
|
|
|
|
# Linux deb/rpm installs are owned by the package manager; we can't (and
|
|
# 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 "
|
|
"'sudo dnf remove hermes'). AppImage builds are a single file you "
|
|
"can delete from wherever you saved it."
|
|
)
|
|
|
|
return removed
|