"""Dashboard UI assets: SPA mount, theme normalisation/bootstrap CSS, dashboard-plugin discovery and the plugins-hub merge. Split out of ``hermes_cli.web_server``; every externally used name is re-imported there, so ``web_server.`` keeps resolving (and monkeypatching) as before. Helpers that tests patch on ``web_server`` are reached lazily through it. """ import logging import importlib.util import json import os import sys import threading import time import yaml from fastapi import FastAPI, Request from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, Response from fastapi.staticfiles import StaticFiles from pathlib import Path from typing import Any, Dict, List, Optional from hermes_cli.config import cfg_get, get_process_hermes_home from utils import env_var_enabled # Same logger the code used before extraction (record parity). _log = logging.getLogger("hermes_cli.web_server") def _normalise_prefix(raw: Optional[str]) -> str: """Normalise an X-Forwarded-Prefix header value. Thin re-export of :func:`hermes_cli.dashboard_auth.prefix.normalise_prefix` — the single source of truth lives in the dashboard_auth package so the gate middleware, the OAuth routes, the cookie helpers, and the SPA mount all agree on validation rules. """ from hermes_cli.dashboard_auth.prefix import normalise_prefix return normalise_prefix(raw) def _render_active_theme_bootstrap_css() -> str: """Critical-CSS shim for the active user theme. Returns a ```` escape — current values are well-known # hex/font strings, but this keeps the helper safe if it is # later extended to ship user-authored CSS literals. def _esc(s: str) -> str: return str(s).replace("' ":root{" f"--background-base:{_esc(bg_hex)};" f"--midground-base:{_esc(mg_hex)};" f"--theme-font-sans:{_esc(font_sans)};" f"--theme-base-size:{_esc(base_size)};" "}" "html,body{background-color:var(--background-base);" "color:var(--midground-base);" "font-family:var(--theme-font-sans);" "font-size:var(--theme-base-size);}" "" ) return "" except Exception: _log.debug("theme bootstrap render failed", exc_info=True) return "" # Hashed bundle assets (``/assets/-.``) are immutable # by construction: any content change produces a new filename, and the entry # point (index.html) is served ``no-store`` so it always references the # current hashes. A year-long immutable cache lets browsers skip even the # revalidation round-trip on every dashboard load. _IMMUTABLE_ASSET_CACHE_CONTROL = "public, max-age=31536000, immutable" def mount_spa(application: FastAPI): """Mount the built SPA. Falls back to index.html for client-side routing. The session token is injected into index.html via a ``" "Headless backend (hermes serve): web UI disabled — use " "`hermes dashboard` for the browser UI." "", headers={ "Cache-Control": "no-store, no-cache, must-revalidate" }, ) return JSONResponse({"error": _msg}, status_code=404) return # A missing WEB_DIST is deliberately NOT a mount-time terminal state # (#82614): a long-lived `hermes dashboard --skip-build` process that # survives a `git pull` (or starts before the first build) used to # install a permanent no_frontend catch-all here and could never # recover — every route answered 404 "Frontend not built" until the # process was restarted, even after `npm run build` completed. The SPA # routes below all cope with a missing dist per-request (`_serve_index` # returns the same 404 JSON when index.html is unreadable; the asset # mounts use check_dir=False and 404 on missing files), so mounting # them unconditionally makes the dashboard recover the moment a build # appears on disk — no restart needed. _index_path = WEB_DIST / "index.html" def _serve_index(prefix: str = ""): """Return index.html with the session token + base-path injected. ``prefix`` is the normalised ``X-Forwarded-Prefix`` (e.g. ``/hermes``) or empty string when served at root. When the OAuth auth gate is active (``app.state.auth_required``), the legacy ``_SESSION_TOKEN`` is NOT injected — the SPA reads identity from ``/api/auth/me`` over cookie auth instead. The ``__HERMES_AUTH_REQUIRED__`` flag lets the SPA pick the right auth scheme for /api/pty and /api/ws (ticket vs token). """ try: html = _index_path.read_text(encoding="utf-8") except OSError: # The dist dir existed at mount time but index.html is missing or # unreadable now (partial build, wiped dist, permissions). Without # this guard every request raises FileNotFoundError (500). Return # the same JSON 404 payload mount_spa uses for a fully-missing # dist so clients get a clear, consistent signal. return JSONResponse( {"error": "Frontend not built. Run: cd web && npm run build"}, status_code=404, ) chat_js = "true" if _DASHBOARD_EMBEDDED_CHAT_ENABLED else "false" gated = bool(getattr(app.state, "auth_required", False)) gated_js = "true" if gated else "false" if gated: bootstrap_script = ( f"" ) else: bootstrap_script = ( f'" ) if prefix: # Rewrite absolute asset URLs baked into the Vite build so the # browser fetches them through the same proxy prefix. html = html.replace('href="/assets/', f'href="{prefix}/assets/') html = html.replace('src="/assets/', f'src="{prefix}/assets/') html = html.replace('href="/favicon.ico"', f'href="{prefix}/favicon.ico"') html = html.replace('href="/fonts/', f'href="{prefix}/fonts/') html = html.replace('href="/ds-assets/', f'href="{prefix}/ds-assets/') html = html.replace('src="/ds-assets/', f'src="{prefix}/ds-assets/') # Theme flash mitigation: when the active theme is a user theme # (``HERMES_HOME/dashboard-themes/.yaml``), inject a minimal # critical-CSS block so the first paint uses the target palette. # Without this the SPA paints the default Hermes Teal canvas, then # ``ThemeProvider`` flips the CSS variables once # ``/api/dashboard/themes`` resolves. Built-in themes are already # in the bundle's ``presets.ts`` so no shim is needed for them. theme_bootstrap = _render_active_theme_bootstrap_css() if theme_bootstrap: html = html.replace("", f"{theme_bootstrap}", 1) html = html.replace("", f"{bootstrap_script}", 1) return HTMLResponse( html, headers={"Cache-Control": "no-store, no-cache, must-revalidate"}, ) # When served behind a path-prefix proxy, the built CSS contains # absolute ``url(/fonts/...)`` and ``url(/ds-assets/...)`` references. # Browsers resolve those against the document origin, which means # under ``/hermes`` they'd hit ``mission-control.tilos.com/fonts/...`` # (the MC Pages app), not the Hermes backend. Intercept CSS asset # requests BEFORE the StaticFiles mount and rewrite the absolute paths # when a prefix is in play. @application.get("/assets/{filename}.css") async def serve_css(filename: str, request: Request): css_path = WEB_DIST / "assets" / f"{filename}.css" if not css_path.is_file() or not css_path.resolve().is_relative_to( WEB_DIST.resolve() ): return JSONResponse({"error": "not found"}, status_code=404) prefix = _normalise_prefix(request.headers.get("x-forwarded-prefix")) css = css_path.read_text(encoding="utf-8") if prefix: for asset_dir in ("/fonts/", "/fonts-terminal/", "/ds-assets/", "/assets/"): css = css.replace(f"url({asset_dir}", f"url({prefix}{asset_dir}") css = css.replace(f"url(\"{asset_dir}", f"url(\"{prefix}{asset_dir}") css = css.replace(f"url('{asset_dir}", f"url('{prefix}{asset_dir}") return Response( content=css, media_type="text/css", headers={"Cache-Control": _IMMUTABLE_ASSET_CACHE_CONTROL}, ) class _ImmutableAssetFiles(StaticFiles): """StaticFiles that marks hashed bundle assets immutable. Everything under ``/assets/`` carries a Vite content hash in its filename, so a given URL's bytes can never change — a rebuild produces a NEW filename referenced by a fresh (``no-store``) index.html. Without this header every dashboard load re-validated each chunk; with it the browser serves reloads straight from its HTTP cache. """ async def get_response(self, path: str, scope): response = await super().get_response(path, scope) if response.status_code == 200: response.headers["Cache-Control"] = _IMMUTABLE_ASSET_CACHE_CONTROL return response application.mount( "/assets", # check_dir=False: the dist (and its assets/ dir) may not exist yet — # the whole point of the dynamic recheck (#82614). StaticFiles then # 404s per-request until a build appears instead of raising at mount. _ImmutableAssetFiles(directory=WEB_DIST / "assets", check_dir=False), name="assets", ) @application.get("/{full_path:path}") async def serve_spa(full_path: str, request: Request): prefix = _normalise_prefix(request.headers.get("x-forwarded-prefix")) # An unmatched /api/* path is a missing/renamed endpoint, NOT a # client-side route. Falling through to index.html here returns # `` with status 200, which makes JSON clients (the # desktop app's fetchJson, dashboard fetch wrappers) blow up with an # opaque `SyntaxError: Unexpected token '<'`. Return a real 404 JSON # so the caller sees a clear "no such endpoint" instead. if full_path == "api" or full_path.startswith("api/"): return JSONResponse( {"detail": f"No such API endpoint: /{full_path}"}, status_code=404, ) file_path = WEB_DIST / full_path # Prevent path traversal via url-encoded sequences (%2e%2e/) if ( full_path and file_path.resolve().is_relative_to(WEB_DIST.resolve()) and file_path.exists() and file_path.is_file() ): return FileResponse(file_path) return _serve_index(prefix) # --------------------------------------------------------------------------- # Dashboard theme endpoints # --------------------------------------------------------------------------- # Built-in dashboard themes — label + description only. The actual color # definitions live in the frontend (web/src/themes/presets.ts). _BUILTIN_DASHBOARD_THEMES = [ {"name": "default", "label": "Hermes Teal", "description": "Classic dark teal — the canonical Hermes look"}, {"name": "default-large", "label": "Hermes Teal (Large)", "description": "Hermes Teal with bigger fonts and roomier spacing"}, {"name": "nous-blue", "label": "Nous Blue", "description": "Light mode — vivid Nous-blue accents on cream canvas"}, {"name": "midnight", "label": "Midnight", "description": "Deep blue-violet with cool accents"}, {"name": "ember", "label": "Ember", "description": "Warm crimson and bronze — forge vibes"}, {"name": "mono", "label": "Mono", "description": "Clean grayscale — minimal and focused"}, {"name": "cyberpunk", "label": "Cyberpunk", "description": "Neon green on black — matrix terminal"}, {"name": "rose", "label": "Rosé", "description": "Soft pink and warm ivory — easy on the eyes"}, ] def _parse_theme_layer(value: Any, default_hex: str, default_alpha: float = 1.0) -> Optional[Dict[str, Any]]: """Normalise a theme layer spec from YAML into `{hex, alpha}` form. Accepts shorthand (a bare hex string) or full dict form. Returns ``None`` on garbage input so the caller can fall back to a built-in default rather than blowing up. """ if value is None: return {"hex": default_hex, "alpha": default_alpha} if isinstance(value, str): return {"hex": value, "alpha": default_alpha} if isinstance(value, dict): hex_val = value.get("hex", default_hex) alpha_val = value.get("alpha", default_alpha) if not isinstance(hex_val, str): return None try: alpha_f = float(alpha_val) except (TypeError, ValueError): alpha_f = default_alpha return {"hex": hex_val, "alpha": max(0.0, min(1.0, alpha_f))} return None _THEME_DEFAULT_TYPOGRAPHY: Dict[str, str] = { "fontSans": 'system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif', "fontMono": 'ui-monospace, "SF Mono", "Cascadia Mono", Menlo, Consolas, monospace', "baseSize": "15px", "lineHeight": "1.55", "letterSpacing": "0", } _THEME_DEFAULT_LAYOUT: Dict[str, str] = { "radius": "0.5rem", "density": "comfortable", } _THEME_OVERRIDE_KEYS = { "card", "cardForeground", "popover", "popoverForeground", "primary", "primaryForeground", "secondary", "secondaryForeground", "muted", "mutedForeground", "accent", "accentForeground", "destructive", "destructiveForeground", "success", "warning", "border", "input", "ring", } # Well-known named asset slots themes can populate. Any other keys under # ``assets.custom`` are exposed as ``--theme-asset-custom-`` CSS vars # for plugin/shell use. _THEME_NAMED_ASSET_KEYS = {"bg", "hero", "logo", "crest", "sidebar", "header"} # Component-style buckets themes can override. The value under each bucket # is a mapping from camelCase property name to CSS string; each pair emits # ``--component--`` on :root. The frontend's shell # components (Card, App header, Backdrop, etc.) consume these vars so themes # can restyle chrome (clip-path, border-image, segmented progress, etc.) # without shipping their own CSS. _THEME_COMPONENT_BUCKETS = { "card", "header", "footer", "sidebar", "tab", "progress", "badge", "backdrop", "page", } _THEME_LAYOUT_VARIANTS = {"standard", "cockpit", "tiled"} # Cap on customCSS length so a malformed/oversized theme YAML can't blow up # the response payload or the