feat(extensions): add gallery browse, install, and uninstall (#4746)
This commit is contained in:
1 parent
ed5a217ae1
commit
ddd5c8d936
8 files changed
+930
-11
No files matched your search
@@ -14,6 +14,11 @@ import threading
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Set, Tuple
|
||||
from urllib.parse import unquote, urlsplit
|
||||
import hashlib
|
||||
import io
|
||||
import time
|
||||
import zipfile
|
||||
from urllib.request import urlopen
|
||||
|
||||
from api.helpers import _security_headers, j
|
||||
|
||||
@@ -46,6 +51,14 @@ class ExtensionToggleError(Exception):
|
||||
self.status = status
|
||||
|
||||
|
||||
class ExtensionInstallError(Exception):
|
||||
"""Sanitized extension install/uninstall error safe to return to the browser."""
|
||||
|
||||
def __init__(self, message: str, status: int = 400):
|
||||
super().__init__(message)
|
||||
self.status = status
|
||||
|
||||
|
||||
EXTENSION_ROUTE_PREFIX = "/extensions/"
|
||||
_EXTENSION_DIR_ENV = "HERMES_WEBUI_EXTENSION_DIR"
|
||||
_EXTENSION_SCRIPT_URLS_ENV = "HERMES_WEBUI_EXTENSION_SCRIPT_URLS"
|
||||
@@ -62,6 +75,15 @@ _EXTENSION_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$")
|
||||
_EXTENSION_STATE_WARNING_SOURCE = "extension_state"
|
||||
_EXTENSION_STATE_LOCK = threading.Lock()
|
||||
|
||||
_GALLERY_INSTALL_STATE_FILENAME = "extension-install-manifest.json"
|
||||
_MAX_INSTALL_MANIFEST_BYTES = 128 * 1024
|
||||
_MAX_GALLERY_INSTALLED_IDS = 256
|
||||
_MAX_ZIP_DOWNLOAD_BYTES = 32 * 1024 * 1024
|
||||
_REGISTRY_URL = "https://hermes-webui.github.io/hermes-webui-extensions/registry.json"
|
||||
_REGISTRY_ALLOWED_DOWNLOAD_HOST = "hermes-webui.github.io"
|
||||
_REGISTRY_CACHE: dict = {}
|
||||
_REGISTRY_TTL_SECONDS = 300
|
||||
|
||||
_EXTENSION_MIME = {
|
||||
"css": "text/css",
|
||||
"js": "application/javascript",
|
||||
@@ -845,6 +867,7 @@ def get_extension_status() -> Dict[str, Any]:
|
||||
},
|
||||
"manifest": manifest_status,
|
||||
"extensions": extensions,
|
||||
"gallery_installed": _load_install_manifest().get("installed", {}),
|
||||
"warnings": diagnostics["warnings"],
|
||||
}
|
||||
|
||||
@@ -885,6 +908,225 @@ def set_extension_user_enabled(extension_id: object, enabled: object) -> Dict[st
|
||||
return get_extension_status()
|
||||
|
||||
|
||||
def _install_manifest_file() -> Path:
|
||||
return _extension_state_dir() / _GALLERY_INSTALL_STATE_FILENAME
|
||||
|
||||
|
||||
def _empty_install_manifest() -> Dict[str, Any]:
|
||||
return {"version": 1, "installed": {}}
|
||||
|
||||
|
||||
def _load_install_manifest() -> Dict[str, Any]:
|
||||
"""Load gallery install manifest, failing safe on any error."""
|
||||
mfile = _install_manifest_file()
|
||||
try:
|
||||
if not mfile.exists() or not mfile.is_file():
|
||||
return _empty_install_manifest()
|
||||
with mfile.open("rb") as fh:
|
||||
raw = fh.read(_MAX_INSTALL_MANIFEST_BYTES + 1)
|
||||
if len(raw) > _MAX_INSTALL_MANIFEST_BYTES:
|
||||
return _empty_install_manifest()
|
||||
parsed = json.loads(raw.decode("utf-8"))
|
||||
except (OSError, UnicodeDecodeError, json.JSONDecodeError, RecursionError):
|
||||
return _empty_install_manifest()
|
||||
if not isinstance(parsed, dict) or not isinstance(parsed.get("installed"), dict):
|
||||
return _empty_install_manifest()
|
||||
installed: Dict[str, Any] = {}
|
||||
for ext_id, entry in parsed["installed"].items():
|
||||
if not _valid_extension_id(ext_id):
|
||||
continue
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
files = entry.get("files", [])
|
||||
if not isinstance(files, list):
|
||||
continue
|
||||
installed[ext_id] = {
|
||||
"version": str(entry.get("version", "unknown")),
|
||||
"files": [f for f in files if isinstance(f, str)],
|
||||
"installed_at": str(entry.get("installed_at", "")),
|
||||
}
|
||||
if len(installed) >= _MAX_GALLERY_INSTALLED_IDS:
|
||||
break
|
||||
return {"version": 1, "installed": installed}
|
||||
|
||||
|
||||
def _write_install_manifest(manifest: Dict[str, Any]) -> None:
|
||||
"""Persist install manifest with atomic same-directory replace."""
|
||||
target = _install_manifest_file()
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = target.with_name(f".{target.name}.{os.getpid()}.{threading.get_ident()}.tmp")
|
||||
data = json.dumps(manifest, ensure_ascii=False, indent=2).encode("utf-8")
|
||||
try:
|
||||
with tmp.open("wb") as fh:
|
||||
fh.write(data)
|
||||
fh.flush()
|
||||
os.fsync(fh.fileno())
|
||||
os.replace(tmp, target)
|
||||
finally:
|
||||
try:
|
||||
if tmp.exists():
|
||||
tmp.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def install_extension(id: object, download_url: object, sha256: object) -> Dict[str, Any]:
|
||||
"""Download, verify, and extract a gallery extension."""
|
||||
if not _valid_extension_id(id):
|
||||
raise ExtensionInstallError("Invalid extension id")
|
||||
ext_id = str(id).strip()
|
||||
if not isinstance(download_url, str) or not download_url.startswith("https://"):
|
||||
raise ExtensionInstallError("Invalid download URL")
|
||||
parsed_url = urlsplit(download_url)
|
||||
if parsed_url.hostname != _REGISTRY_ALLOWED_DOWNLOAD_HOST:
|
||||
raise ExtensionInstallError("Invalid download URL")
|
||||
if not isinstance(sha256, str) or not re.fullmatch(r"[0-9a-f]{64}", sha256):
|
||||
raise ExtensionInstallError("Invalid sha256")
|
||||
root = _extension_root()
|
||||
if root is None:
|
||||
raise ExtensionInstallError("Extensions not configured", 404)
|
||||
try:
|
||||
raw_data = urlopen(download_url, timeout=30).read(_MAX_ZIP_DOWNLOAD_BYTES + 1)
|
||||
except Exception as exc:
|
||||
raise ExtensionInstallError("Download failed", 502) from exc
|
||||
if len(raw_data) > _MAX_ZIP_DOWNLOAD_BYTES:
|
||||
raise ExtensionInstallError("Download too large")
|
||||
if hashlib.sha256(raw_data).hexdigest() != sha256:
|
||||
raise ExtensionInstallError("SHA-256 mismatch")
|
||||
try:
|
||||
zf = zipfile.ZipFile(io.BytesIO(raw_data))
|
||||
except zipfile.BadZipFile as exc:
|
||||
raise ExtensionInstallError("Invalid zip archive") from exc
|
||||
ext_dir = root / ext_id
|
||||
member_names = zf.namelist()
|
||||
total_uncompressed = sum(info.file_size for info in zf.infolist() if not info.is_dir())
|
||||
if total_uncompressed > _MAX_ZIP_DOWNLOAD_BYTES * 10:
|
||||
raise ExtensionInstallError("Archive uncompressed size exceeds limit")
|
||||
if len([n for n in member_names if n and not n.endswith("/")]) > 1024:
|
||||
raise ExtensionInstallError("Archive contains too many files")
|
||||
for member_name in member_names:
|
||||
if not member_name or member_name.endswith("/"):
|
||||
continue
|
||||
decoded = _fully_unquote_path(member_name)
|
||||
if not _is_safe_relative_path(decoded):
|
||||
raise ExtensionInstallError("Unsafe archive member")
|
||||
resolved = (ext_dir / decoded).resolve()
|
||||
try:
|
||||
resolved.relative_to(ext_dir.resolve())
|
||||
except ValueError as exc:
|
||||
raise ExtensionInstallError("Zip-slip detected") from exc
|
||||
# Determine version from manifest.json in zip if present
|
||||
version = "unknown"
|
||||
if "manifest.json" in member_names:
|
||||
try:
|
||||
mdata = json.loads(zf.read("manifest.json").decode("utf-8"))
|
||||
if isinstance(mdata, dict) and isinstance(mdata.get("version"), str):
|
||||
version = mdata["version"]
|
||||
except Exception:
|
||||
pass
|
||||
ext_dir.mkdir(parents=True, exist_ok=True)
|
||||
rollback: List[Path] = []
|
||||
try:
|
||||
for member_name in member_names:
|
||||
if not member_name or member_name.endswith("/"):
|
||||
continue
|
||||
decoded = _fully_unquote_path(member_name)
|
||||
dest = (ext_dir / decoded).resolve()
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
dest.write_bytes(zf.read(member_name))
|
||||
rollback.append(dest)
|
||||
except Exception as exc:
|
||||
for path in rollback:
|
||||
try:
|
||||
path.unlink(missing_ok=True)
|
||||
except OSError:
|
||||
pass
|
||||
try:
|
||||
if ext_dir.exists() and not any(ext_dir.iterdir()):
|
||||
ext_dir.rmdir()
|
||||
except OSError:
|
||||
pass
|
||||
raise ExtensionInstallError("Extraction failed", 500) from exc
|
||||
try:
|
||||
with _EXTENSION_STATE_LOCK:
|
||||
manifest = _load_install_manifest()
|
||||
from datetime import datetime, timezone
|
||||
manifest["installed"][ext_id] = {
|
||||
"version": version,
|
||||
"files": [str(p.relative_to(ext_dir)) for p in rollback],
|
||||
"installed_at": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
_write_install_manifest(manifest)
|
||||
except Exception as exc:
|
||||
for path in rollback:
|
||||
try:
|
||||
path.unlink(missing_ok=True)
|
||||
except OSError:
|
||||
pass
|
||||
try:
|
||||
if ext_dir.exists() and not any(ext_dir.iterdir()):
|
||||
ext_dir.rmdir()
|
||||
except OSError:
|
||||
pass
|
||||
raise ExtensionInstallError("Failed to record install", 500) from exc
|
||||
return {"installed": True, "id": ext_id, "version": version}
|
||||
|
||||
|
||||
def uninstall_extension(id: object) -> Dict[str, Any]:
|
||||
"""Remove a gallery-installed extension's files and manifest entry."""
|
||||
if not _valid_extension_id(id):
|
||||
raise ExtensionInstallError("Invalid extension id")
|
||||
ext_id = str(id).strip()
|
||||
root = _extension_root()
|
||||
if root is None:
|
||||
raise ExtensionInstallError("Extensions not configured", 404)
|
||||
with _EXTENSION_STATE_LOCK:
|
||||
manifest = _load_install_manifest()
|
||||
entry = manifest["installed"].get(ext_id)
|
||||
if entry is None:
|
||||
raise ExtensionInstallError("Extension not installed", 404)
|
||||
ext_dir = root / ext_id
|
||||
for rel_path in entry.get("files", []):
|
||||
if not _is_safe_relative_path(rel_path):
|
||||
continue
|
||||
target = (ext_dir / rel_path).resolve()
|
||||
try:
|
||||
target.relative_to(ext_dir.resolve())
|
||||
except ValueError:
|
||||
continue
|
||||
try:
|
||||
target.unlink(missing_ok=True)
|
||||
except OSError:
|
||||
pass
|
||||
try:
|
||||
if ext_dir.exists() and not any(ext_dir.iterdir()):
|
||||
ext_dir.rmdir()
|
||||
except OSError:
|
||||
pass
|
||||
del manifest["installed"][ext_id]
|
||||
_write_install_manifest(manifest)
|
||||
return {"uninstalled": True, "id": ext_id}
|
||||
|
||||
|
||||
def get_extension_registry() -> Dict[str, Any]:
|
||||
"""Fetch the extension registry with a 5-minute TTL cache."""
|
||||
now = time.monotonic()
|
||||
cached = _REGISTRY_CACHE.get("data")
|
||||
cached_at = _REGISTRY_CACHE.get("fetched_at", 0.0)
|
||||
if cached is not None and (now - cached_at) < _REGISTRY_TTL_SECONDS:
|
||||
return {"entries": cached}
|
||||
try:
|
||||
raw = urlopen(_REGISTRY_URL, timeout=10).read(2 * 1024 * 1024)
|
||||
data = json.loads(raw.decode("utf-8"))
|
||||
if not isinstance(data, list):
|
||||
data = data.get("entries", []) if isinstance(data, dict) else []
|
||||
_REGISTRY_CACHE["data"] = data
|
||||
_REGISTRY_CACHE["fetched_at"] = now
|
||||
return {"entries": data}
|
||||
except Exception:
|
||||
return {"entries": [], "error": "registry_unavailable"}
|
||||
|
||||
|
||||
def inject_extension_tags(index_html: str) -> str:
|
||||
"""Inject configured extension tags into the app shell.
|
||||
|
||||
|
||||
@@ -9429,6 +9429,11 @@ def handle_get(handler, parsed) -> bool:
|
||||
|
||||
return j(handler, get_extension_status())
|
||||
|
||||
if parsed.path == "/api/extensions/registry":
|
||||
from api.extensions import get_extension_registry
|
||||
|
||||
return j(handler, get_extension_registry())
|
||||
|
||||
if parsed.path.startswith("/extensions/"):
|
||||
from api.extensions import serve_extension_static
|
||||
|
||||
@@ -10748,6 +10753,34 @@ def handle_post(handler, parsed) -> bool:
|
||||
logger.exception("extension toggle failed")
|
||||
return bad(handler, "Failed to update extension state", status=500)
|
||||
|
||||
if parsed.path == "/api/extensions/install":
|
||||
from api.extensions import ExtensionInstallError, install_extension
|
||||
|
||||
try:
|
||||
return j(
|
||||
handler,
|
||||
install_extension(body.get("id"), body.get("download_url"), body.get("sha256")),
|
||||
)
|
||||
except ExtensionInstallError as exc:
|
||||
return bad(handler, str(exc), status=exc.status)
|
||||
except Exception:
|
||||
logger.exception("extension install failed")
|
||||
return bad(handler, "Failed to install extension", status=500)
|
||||
|
||||
if parsed.path == "/api/extensions/uninstall":
|
||||
from api.extensions import ExtensionInstallError, uninstall_extension
|
||||
|
||||
try:
|
||||
return j(
|
||||
handler,
|
||||
uninstall_extension(body.get("id")),
|
||||
)
|
||||
except ExtensionInstallError as exc:
|
||||
return bad(handler, str(exc), status=exc.status)
|
||||
except Exception:
|
||||
logger.exception("extension uninstall failed")
|
||||
return bad(handler, "Failed to uninstall extension", status=500)
|
||||
|
||||
if parsed.path == "/api/session/recovery/repair-safe":
|
||||
from api.session_recovery import repair_safe_session_recovery
|
||||
result = repair_safe_session_recovery(SESSION_DIR, state_db_path=_active_state_db_path())
|
||||
|
||||
+266
@@ -663,6 +663,25 @@ const LOCALES = {
|
||||
settings_tab_preferences: 'Preferences',
|
||||
settings_tab_plugins: 'Plugins',
|
||||
settings_tab_extensions: 'Extensions',
|
||||
settings_extensions_gallery_tab: 'Gallery',
|
||||
settings_extensions_installed_tab: 'Installed',
|
||||
settings_extensions_diagnostics_tab: 'Diagnostics',
|
||||
settings_extensions_gallery_loading: 'Loading gallery…',
|
||||
settings_extensions_gallery_empty: 'No extensions found in the registry.',
|
||||
settings_extensions_gallery_error: 'Failed to load gallery.',
|
||||
ext_gallery_install: 'Install',
|
||||
ext_gallery_installing: 'Installing…',
|
||||
ext_gallery_installed: 'Installed',
|
||||
ext_gallery_uninstall: 'Uninstall',
|
||||
ext_gallery_uninstalling: 'Uninstalling…',
|
||||
ext_gallery_restart_required: 'Restart required',
|
||||
ext_gallery_permissions_show: 'Permissions',
|
||||
ext_gallery_permissions_hide: 'Hide permissions',
|
||||
ext_gallery_version: 'Version',
|
||||
ext_gallery_author: 'Author',
|
||||
ext_gallery_capabilities: 'Capabilities',
|
||||
ext_gallery_install_ok: 'Extension installed successfully.',
|
||||
ext_gallery_uninstall_ok: 'Extension uninstalled.',
|
||||
settings_plugins_title: 'Plugins',
|
||||
plugins_enable_toggle: 'Enable',
|
||||
settings_plugins_meta: 'View installed Hermes plugins and the lifecycle hooks they register. This panel is read-only.',
|
||||
@@ -2261,6 +2280,25 @@ const LOCALES = {
|
||||
settings_tab_preferences: 'Preferenze',
|
||||
settings_tab_plugins: 'Plugin',
|
||||
settings_tab_extensions: 'Extensions', // TODO: translate
|
||||
settings_extensions_gallery_tab: 'Gallery',
|
||||
settings_extensions_installed_tab: 'Installed',
|
||||
settings_extensions_diagnostics_tab: 'Diagnostics',
|
||||
settings_extensions_gallery_loading: 'Loading gallery…',
|
||||
settings_extensions_gallery_empty: 'No extensions found in the registry.',
|
||||
settings_extensions_gallery_error: 'Failed to load gallery.',
|
||||
ext_gallery_install: 'Install',
|
||||
ext_gallery_installing: 'Installing…',
|
||||
ext_gallery_installed: 'Installed',
|
||||
ext_gallery_uninstall: 'Uninstall',
|
||||
ext_gallery_uninstalling: 'Uninstalling…',
|
||||
ext_gallery_restart_required: 'Restart required',
|
||||
ext_gallery_permissions_show: 'Permissions',
|
||||
ext_gallery_permissions_hide: 'Hide permissions',
|
||||
ext_gallery_version: 'Version',
|
||||
ext_gallery_author: 'Author',
|
||||
ext_gallery_capabilities: 'Capabilities',
|
||||
ext_gallery_install_ok: 'Extension installed successfully.',
|
||||
ext_gallery_uninstall_ok: 'Extension uninstalled.',
|
||||
settings_plugins_title: 'Plugins', // TODO: translate
|
||||
plugins_enable_toggle: 'Abilita',
|
||||
settings_plugins_meta: 'View installed Hermes plugins and the lifecycle hooks they register. This panel is read-only.', // TODO: translate
|
||||
@@ -3850,6 +3888,25 @@ const LOCALES = {
|
||||
settings_tab_preferences: '環境設定',
|
||||
settings_tab_plugins: 'プラグイン',
|
||||
settings_tab_extensions: '拡張機能',
|
||||
settings_extensions_gallery_tab: 'Gallery',
|
||||
settings_extensions_installed_tab: 'Installed',
|
||||
settings_extensions_diagnostics_tab: 'Diagnostics',
|
||||
settings_extensions_gallery_loading: 'Loading gallery…',
|
||||
settings_extensions_gallery_empty: 'No extensions found in the registry.',
|
||||
settings_extensions_gallery_error: 'Failed to load gallery.',
|
||||
ext_gallery_install: 'Install',
|
||||
ext_gallery_installing: 'Installing…',
|
||||
ext_gallery_installed: 'Installed',
|
||||
ext_gallery_uninstall: 'Uninstall',
|
||||
ext_gallery_uninstalling: 'Uninstalling…',
|
||||
ext_gallery_restart_required: 'Restart required',
|
||||
ext_gallery_permissions_show: 'Permissions',
|
||||
ext_gallery_permissions_hide: 'Hide permissions',
|
||||
ext_gallery_version: 'Version',
|
||||
ext_gallery_author: 'Author',
|
||||
ext_gallery_capabilities: 'Capabilities',
|
||||
ext_gallery_install_ok: 'Extension installed successfully.',
|
||||
ext_gallery_uninstall_ok: 'Extension uninstalled.',
|
||||
settings_plugins_title: 'プラグイン',
|
||||
plugins_enable_toggle: '有効化',
|
||||
settings_plugins_meta: 'インストール済みのHermesプラグインと登録されているライフサイクルフックを確認できます。このパネルは読み取り専用です。',
|
||||
@@ -6143,6 +6200,25 @@ const LOCALES = {
|
||||
settings_tab_preferences: 'Preferences',
|
||||
settings_tab_plugins: 'Плагины',
|
||||
settings_tab_extensions: 'Extensions', // TODO: translate
|
||||
settings_extensions_gallery_tab: 'Gallery',
|
||||
settings_extensions_installed_tab: 'Installed',
|
||||
settings_extensions_diagnostics_tab: 'Diagnostics',
|
||||
settings_extensions_gallery_loading: 'Loading gallery…',
|
||||
settings_extensions_gallery_empty: 'No extensions found in the registry.',
|
||||
settings_extensions_gallery_error: 'Failed to load gallery.',
|
||||
ext_gallery_install: 'Install',
|
||||
ext_gallery_installing: 'Installing…',
|
||||
ext_gallery_installed: 'Installed',
|
||||
ext_gallery_uninstall: 'Uninstall',
|
||||
ext_gallery_uninstalling: 'Uninstalling…',
|
||||
ext_gallery_restart_required: 'Restart required',
|
||||
ext_gallery_permissions_show: 'Permissions',
|
||||
ext_gallery_permissions_hide: 'Hide permissions',
|
||||
ext_gallery_version: 'Version',
|
||||
ext_gallery_author: 'Author',
|
||||
ext_gallery_capabilities: 'Capabilities',
|
||||
ext_gallery_install_ok: 'Extension installed successfully.',
|
||||
ext_gallery_uninstall_ok: 'Extension uninstalled.',
|
||||
settings_plugins_title: 'Plugins', // TODO: translate
|
||||
plugins_enable_toggle: 'Включить',
|
||||
settings_plugins_meta: 'View installed Hermes plugins and the lifecycle hooks they register. This panel is read-only.', // TODO: translate
|
||||
@@ -7657,6 +7733,25 @@ const LOCALES = {
|
||||
settings_tab_preferences: 'Preferences',
|
||||
settings_tab_plugins: 'Plugins',
|
||||
settings_tab_extensions: 'Extensions',
|
||||
settings_extensions_gallery_tab: 'Gallery',
|
||||
settings_extensions_installed_tab: 'Installed',
|
||||
settings_extensions_diagnostics_tab: 'Diagnostics',
|
||||
settings_extensions_gallery_loading: 'Loading gallery…',
|
||||
settings_extensions_gallery_empty: 'No extensions found in the registry.',
|
||||
settings_extensions_gallery_error: 'Failed to load gallery.',
|
||||
ext_gallery_install: 'Install',
|
||||
ext_gallery_installing: 'Installing…',
|
||||
ext_gallery_installed: 'Installed',
|
||||
ext_gallery_uninstall: 'Uninstall',
|
||||
ext_gallery_uninstalling: 'Uninstalling…',
|
||||
ext_gallery_restart_required: 'Restart required',
|
||||
ext_gallery_permissions_show: 'Permissions',
|
||||
ext_gallery_permissions_hide: 'Hide permissions',
|
||||
ext_gallery_version: 'Version',
|
||||
ext_gallery_author: 'Author',
|
||||
ext_gallery_capabilities: 'Capabilities',
|
||||
ext_gallery_install_ok: 'Extension installed successfully.',
|
||||
ext_gallery_uninstall_ok: 'Extension uninstalled.',
|
||||
settings_plugins_title: 'Plugins', // TODO: translate
|
||||
plugins_enable_toggle: 'Activar',
|
||||
settings_plugins_meta: 'View installed Hermes plugins and the lifecycle hooks they register. This panel is read-only.', // TODO: translate
|
||||
@@ -8855,6 +8950,25 @@ const LOCALES = {
|
||||
settings_tab_preferences: 'Preferences',
|
||||
settings_tab_plugins: 'Plugins',
|
||||
settings_tab_extensions: 'Extensions',
|
||||
settings_extensions_gallery_tab: 'Gallery',
|
||||
settings_extensions_installed_tab: 'Installed',
|
||||
settings_extensions_diagnostics_tab: 'Diagnostics',
|
||||
settings_extensions_gallery_loading: 'Loading gallery…',
|
||||
settings_extensions_gallery_empty: 'No extensions found in the registry.',
|
||||
settings_extensions_gallery_error: 'Failed to load gallery.',
|
||||
ext_gallery_install: 'Install',
|
||||
ext_gallery_installing: 'Installing…',
|
||||
ext_gallery_installed: 'Installed',
|
||||
ext_gallery_uninstall: 'Uninstall',
|
||||
ext_gallery_uninstalling: 'Uninstalling…',
|
||||
ext_gallery_restart_required: 'Restart required',
|
||||
ext_gallery_permissions_show: 'Permissions',
|
||||
ext_gallery_permissions_hide: 'Hide permissions',
|
||||
ext_gallery_version: 'Version',
|
||||
ext_gallery_author: 'Author',
|
||||
ext_gallery_capabilities: 'Capabilities',
|
||||
ext_gallery_install_ok: 'Extension installed successfully.',
|
||||
ext_gallery_uninstall_ok: 'Extension uninstalled.',
|
||||
settings_plugins_title: 'Plugins', // TODO: translate
|
||||
plugins_enable_toggle: 'Aktivieren',
|
||||
settings_plugins_meta: 'View installed Hermes plugins and the lifecycle hooks they register. This panel is read-only.', // TODO: translate
|
||||
@@ -10720,6 +10834,25 @@ const LOCALES = {
|
||||
settings_tab_preferences: '偏好',
|
||||
settings_tab_plugins: '插件',
|
||||
settings_tab_extensions: '扩展',
|
||||
settings_extensions_gallery_tab: 'Gallery',
|
||||
settings_extensions_installed_tab: 'Installed',
|
||||
settings_extensions_diagnostics_tab: 'Diagnostics',
|
||||
settings_extensions_gallery_loading: 'Loading gallery…',
|
||||
settings_extensions_gallery_empty: 'No extensions found in the registry.',
|
||||
settings_extensions_gallery_error: 'Failed to load gallery.',
|
||||
ext_gallery_install: 'Install',
|
||||
ext_gallery_installing: 'Installing…',
|
||||
ext_gallery_installed: 'Installed',
|
||||
ext_gallery_uninstall: 'Uninstall',
|
||||
ext_gallery_uninstalling: 'Uninstalling…',
|
||||
ext_gallery_restart_required: 'Restart required',
|
||||
ext_gallery_permissions_show: 'Permissions',
|
||||
ext_gallery_permissions_hide: 'Hide permissions',
|
||||
ext_gallery_version: 'Version',
|
||||
ext_gallery_author: 'Author',
|
||||
ext_gallery_capabilities: 'Capabilities',
|
||||
ext_gallery_install_ok: 'Extension installed successfully.',
|
||||
ext_gallery_uninstall_ok: 'Extension uninstalled.',
|
||||
settings_tab_system: '系统',
|
||||
settings_tab_help: 'Help',
|
||||
settings_help_meta: 'Resources and support for Hermes WebUI.',
|
||||
@@ -11544,6 +11677,25 @@ const LOCALES = {
|
||||
settings_tab_preferences: '偏好設定',
|
||||
settings_tab_plugins: '外掛',
|
||||
settings_tab_extensions: '擴充功能',
|
||||
settings_extensions_gallery_tab: 'Gallery',
|
||||
settings_extensions_installed_tab: 'Installed',
|
||||
settings_extensions_diagnostics_tab: 'Diagnostics',
|
||||
settings_extensions_gallery_loading: 'Loading gallery…',
|
||||
settings_extensions_gallery_empty: 'No extensions found in the registry.',
|
||||
settings_extensions_gallery_error: 'Failed to load gallery.',
|
||||
ext_gallery_install: 'Install',
|
||||
ext_gallery_installing: 'Installing…',
|
||||
ext_gallery_installed: 'Installed',
|
||||
ext_gallery_uninstall: 'Uninstall',
|
||||
ext_gallery_uninstalling: 'Uninstalling…',
|
||||
ext_gallery_restart_required: 'Restart required',
|
||||
ext_gallery_permissions_show: 'Permissions',
|
||||
ext_gallery_permissions_hide: 'Hide permissions',
|
||||
ext_gallery_version: 'Version',
|
||||
ext_gallery_author: 'Author',
|
||||
ext_gallery_capabilities: 'Capabilities',
|
||||
ext_gallery_install_ok: 'Extension installed successfully.',
|
||||
ext_gallery_uninstall_ok: 'Extension uninstalled.',
|
||||
settings_plugins_title: '外掛',
|
||||
plugins_enable_toggle: '啟用',
|
||||
settings_plugins_meta: '檢視已安裝的 Hermes 外掛及其註冊的生命週期掛鉤。此面板為唯讀。',
|
||||
@@ -13029,6 +13181,25 @@ const LOCALES = {
|
||||
settings_tab_preferences: 'Preferências',
|
||||
settings_tab_plugins: 'Plugins',
|
||||
settings_tab_extensions: 'Extensions',
|
||||
settings_extensions_gallery_tab: 'Gallery',
|
||||
settings_extensions_installed_tab: 'Installed',
|
||||
settings_extensions_diagnostics_tab: 'Diagnostics',
|
||||
settings_extensions_gallery_loading: 'Loading gallery…',
|
||||
settings_extensions_gallery_empty: 'No extensions found in the registry.',
|
||||
settings_extensions_gallery_error: 'Failed to load gallery.',
|
||||
ext_gallery_install: 'Install',
|
||||
ext_gallery_installing: 'Installing…',
|
||||
ext_gallery_installed: 'Installed',
|
||||
ext_gallery_uninstall: 'Uninstall',
|
||||
ext_gallery_uninstalling: 'Uninstalling…',
|
||||
ext_gallery_restart_required: 'Restart required',
|
||||
ext_gallery_permissions_show: 'Permissions',
|
||||
ext_gallery_permissions_hide: 'Hide permissions',
|
||||
ext_gallery_version: 'Version',
|
||||
ext_gallery_author: 'Author',
|
||||
ext_gallery_capabilities: 'Capabilities',
|
||||
ext_gallery_install_ok: 'Extension installed successfully.',
|
||||
ext_gallery_uninstall_ok: 'Extension uninstalled.',
|
||||
settings_plugins_title: 'Plugins', // TODO: translate
|
||||
plugins_enable_toggle: 'Ativar',
|
||||
settings_plugins_meta: 'View installed Hermes plugins and the lifecycle hooks they register. This panel is read-only.', // TODO: translate
|
||||
@@ -14520,6 +14691,25 @@ const LOCALES = {
|
||||
settings_tab_preferences: '환경설정',
|
||||
settings_tab_plugins: '플러그인',
|
||||
settings_tab_extensions: 'Extensions', // TODO: translate
|
||||
settings_extensions_gallery_tab: 'Gallery',
|
||||
settings_extensions_installed_tab: 'Installed',
|
||||
settings_extensions_diagnostics_tab: 'Diagnostics',
|
||||
settings_extensions_gallery_loading: 'Loading gallery…',
|
||||
settings_extensions_gallery_empty: 'No extensions found in the registry.',
|
||||
settings_extensions_gallery_error: 'Failed to load gallery.',
|
||||
ext_gallery_install: 'Install',
|
||||
ext_gallery_installing: 'Installing…',
|
||||
ext_gallery_installed: 'Installed',
|
||||
ext_gallery_uninstall: 'Uninstall',
|
||||
ext_gallery_uninstalling: 'Uninstalling…',
|
||||
ext_gallery_restart_required: 'Restart required',
|
||||
ext_gallery_permissions_show: 'Permissions',
|
||||
ext_gallery_permissions_hide: 'Hide permissions',
|
||||
ext_gallery_version: 'Version',
|
||||
ext_gallery_author: 'Author',
|
||||
ext_gallery_capabilities: 'Capabilities',
|
||||
ext_gallery_install_ok: 'Extension installed successfully.',
|
||||
ext_gallery_uninstall_ok: 'Extension uninstalled.',
|
||||
settings_plugins_title: 'Plugins', // TODO: translate
|
||||
plugins_enable_toggle: '활성화',
|
||||
settings_plugins_meta: 'View installed Hermes plugins and the lifecycle hooks they register. This panel is read-only.', // TODO: translate
|
||||
@@ -16033,6 +16223,25 @@ const LOCALES = {
|
||||
settings_tab_preferences: 'Préférences',
|
||||
settings_tab_plugins: 'Plugins',
|
||||
settings_tab_extensions: 'Extensions',
|
||||
settings_extensions_gallery_tab: 'Gallery',
|
||||
settings_extensions_installed_tab: 'Installed',
|
||||
settings_extensions_diagnostics_tab: 'Diagnostics',
|
||||
settings_extensions_gallery_loading: 'Loading gallery…',
|
||||
settings_extensions_gallery_empty: 'No extensions found in the registry.',
|
||||
settings_extensions_gallery_error: 'Failed to load gallery.',
|
||||
ext_gallery_install: 'Install',
|
||||
ext_gallery_installing: 'Installing…',
|
||||
ext_gallery_installed: 'Installed',
|
||||
ext_gallery_uninstall: 'Uninstall',
|
||||
ext_gallery_uninstalling: 'Uninstalling…',
|
||||
ext_gallery_restart_required: 'Restart required',
|
||||
ext_gallery_permissions_show: 'Permissions',
|
||||
ext_gallery_permissions_hide: 'Hide permissions',
|
||||
ext_gallery_version: 'Version',
|
||||
ext_gallery_author: 'Author',
|
||||
ext_gallery_capabilities: 'Capabilities',
|
||||
ext_gallery_install_ok: 'Extension installed successfully.',
|
||||
ext_gallery_uninstall_ok: 'Extension uninstalled.',
|
||||
settings_plugins_title: 'Plugins', // TODO: translate
|
||||
plugins_enable_toggle: 'Activer',
|
||||
settings_plugins_meta: 'View installed Hermes plugins and the lifecycle hooks they register. This panel is read-only.', // TODO: translate
|
||||
@@ -17640,6 +17849,25 @@ const LOCALES = {
|
||||
settings_tab_preferences: 'Tercihler',
|
||||
settings_tab_plugins: 'Eklentiler',
|
||||
settings_tab_extensions: 'Extensions', // TODO: translate
|
||||
settings_extensions_gallery_tab: 'Gallery',
|
||||
settings_extensions_installed_tab: 'Installed',
|
||||
settings_extensions_diagnostics_tab: 'Diagnostics',
|
||||
settings_extensions_gallery_loading: 'Loading gallery…',
|
||||
settings_extensions_gallery_empty: 'No extensions found in the registry.',
|
||||
settings_extensions_gallery_error: 'Failed to load gallery.',
|
||||
ext_gallery_install: 'Install',
|
||||
ext_gallery_installing: 'Installing…',
|
||||
ext_gallery_installed: 'Installed',
|
||||
ext_gallery_uninstall: 'Uninstall',
|
||||
ext_gallery_uninstalling: 'Uninstalling…',
|
||||
ext_gallery_restart_required: 'Restart required',
|
||||
ext_gallery_permissions_show: 'Permissions',
|
||||
ext_gallery_permissions_hide: 'Hide permissions',
|
||||
ext_gallery_version: 'Version',
|
||||
ext_gallery_author: 'Author',
|
||||
ext_gallery_capabilities: 'Capabilities',
|
||||
ext_gallery_install_ok: 'Extension installed successfully.',
|
||||
ext_gallery_uninstall_ok: 'Extension uninstalled.',
|
||||
settings_plugins_title: 'Eklentiler',
|
||||
plugins_enable_toggle: 'Etkinleştir',
|
||||
settings_plugins_meta: 'Yüklü Hermes eklentilerini ve yaşam döngüsü kancalarını görüntüleyin. Bu panel salt okunurdur.',
|
||||
@@ -19243,6 +19471,25 @@ const LOCALES = {
|
||||
settings_tab_preferences: 'Preferencje',
|
||||
settings_tab_plugins: 'Wtyczki',
|
||||
settings_tab_extensions: 'Extensions', // TODO: translate
|
||||
settings_extensions_gallery_tab: 'Gallery',
|
||||
settings_extensions_installed_tab: 'Installed',
|
||||
settings_extensions_diagnostics_tab: 'Diagnostics',
|
||||
settings_extensions_gallery_loading: 'Loading gallery…',
|
||||
settings_extensions_gallery_empty: 'No extensions found in the registry.',
|
||||
settings_extensions_gallery_error: 'Failed to load gallery.',
|
||||
ext_gallery_install: 'Install',
|
||||
ext_gallery_installing: 'Installing…',
|
||||
ext_gallery_installed: 'Installed',
|
||||
ext_gallery_uninstall: 'Uninstall',
|
||||
ext_gallery_uninstalling: 'Uninstalling…',
|
||||
ext_gallery_restart_required: 'Restart required',
|
||||
ext_gallery_permissions_show: 'Permissions',
|
||||
ext_gallery_permissions_hide: 'Hide permissions',
|
||||
ext_gallery_version: 'Version',
|
||||
ext_gallery_author: 'Author',
|
||||
ext_gallery_capabilities: 'Capabilities',
|
||||
ext_gallery_install_ok: 'Extension installed successfully.',
|
||||
ext_gallery_uninstall_ok: 'Extension uninstalled.',
|
||||
settings_plugins_title: 'Wtyczki',
|
||||
plugins_enable_toggle: 'Włącz',
|
||||
settings_plugins_meta: 'Zobacz zainstalowane wtyczki Hermes oraz rejestrowane przez nie haki cyklu życia. Ten panel jest tylko do odczytu.',
|
||||
@@ -20734,6 +20981,25 @@ const LOCALES = {
|
||||
settings_tab_preferences: 'Tùy chọn',
|
||||
settings_tab_plugins: 'Plugin',
|
||||
settings_tab_extensions: 'Tiện ích mở rộng',
|
||||
settings_extensions_gallery_tab: 'Gallery',
|
||||
settings_extensions_installed_tab: 'Installed',
|
||||
settings_extensions_diagnostics_tab: 'Diagnostics',
|
||||
settings_extensions_gallery_loading: 'Loading gallery…',
|
||||
settings_extensions_gallery_empty: 'No extensions found in the registry.',
|
||||
settings_extensions_gallery_error: 'Failed to load gallery.',
|
||||
ext_gallery_install: 'Install',
|
||||
ext_gallery_installing: 'Installing…',
|
||||
ext_gallery_installed: 'Installed',
|
||||
ext_gallery_uninstall: 'Uninstall',
|
||||
ext_gallery_uninstalling: 'Uninstalling…',
|
||||
ext_gallery_restart_required: 'Restart required',
|
||||
ext_gallery_permissions_show: 'Permissions',
|
||||
ext_gallery_permissions_hide: 'Hide permissions',
|
||||
ext_gallery_version: 'Version',
|
||||
ext_gallery_author: 'Author',
|
||||
ext_gallery_capabilities: 'Capabilities',
|
||||
ext_gallery_install_ok: 'Extension installed successfully.',
|
||||
ext_gallery_uninstall_ok: 'Extension uninstalled.',
|
||||
settings_plugins_title: 'Plugin',
|
||||
plugins_enable_toggle: 'Bật',
|
||||
settings_plugins_meta: 'Xem các plugin Hermes đã cài đặt và lifecycle hook mà chúng đăng ký. Bảng này chỉ đọc.',
|
||||
|
||||
+36
-7
@@ -1413,16 +1413,45 @@
|
||||
<div class="settings-section-head">
|
||||
<div>
|
||||
<div class="settings-section-title" data-i18n="settings_tab_extensions">Extensions</div>
|
||||
<div class="settings-section-meta">Diagnostics and enable/disable controls for configured WebUI extensions.</div>
|
||||
<div class="settings-section-meta">Browse, install, and manage WebUI extensions.</div>
|
||||
</div>
|
||||
<button class="sm-btn extensions-copy-diagnostics-btn" id="extensionsCopyDiagnosticsBtn" type="button" onclick="copyExtensionsDiagnostics()" disabled>Copy diagnostics</button>
|
||||
</div>
|
||||
<div class="settings-field extensions-trust-note" id="extensionsTrustModel">
|
||||
<label>Trust model</label>
|
||||
<div>Extensions run in the WebUI browser origin and can access the same authenticated APIs as this session. Only load trusted local extension directories; disabling an extension takes effect after reload for already-injected assets.</div>
|
||||
<div class="extensions-tab-bar" role="tablist">
|
||||
<button class="extensions-tab-btn extensions-tab-active" role="tab"
|
||||
data-extensions-tab="gallery"
|
||||
onclick="switchExtensionsTab('gallery')" type="button"
|
||||
data-i18n="settings_extensions_gallery_tab">Gallery</button>
|
||||
<button class="extensions-tab-btn" role="tab"
|
||||
data-extensions-tab="installed"
|
||||
onclick="switchExtensionsTab('installed')" type="button"
|
||||
data-i18n="settings_extensions_installed_tab">Installed</button>
|
||||
<button class="extensions-tab-btn" role="tab"
|
||||
data-extensions-tab="diagnostics"
|
||||
onclick="switchExtensionsTab('diagnostics')" type="button"
|
||||
data-i18n="settings_extensions_diagnostics_tab">Diagnostics</button>
|
||||
</div>
|
||||
<div id="extensionsDiagnostics" class="extensions-diagnostics" aria-live="polite">
|
||||
<div class="extensions-loading">Loading extension diagnostics…</div>
|
||||
<div data-extensions-pane="gallery">
|
||||
<div id="extensionsGallery" class="extensions-gallery" aria-live="polite">
|
||||
<div class="extensions-loading" data-i18n="settings_extensions_gallery_loading">Loading gallery…</div>
|
||||
</div>
|
||||
</div>
|
||||
<div data-extensions-pane="installed" hidden>
|
||||
<div id="extensionsInstalled" class="extensions-gallery" aria-live="polite">
|
||||
<div class="extensions-loading">Loading installed extensions…</div>
|
||||
</div>
|
||||
</div>
|
||||
<div data-extensions-pane="diagnostics" hidden>
|
||||
<div class="settings-section-head" style="padding-top:8px">
|
||||
<div></div>
|
||||
<button class="sm-btn extensions-copy-diagnostics-btn" id="extensionsCopyDiagnosticsBtn" type="button" onclick="copyExtensionsDiagnostics()" disabled>Copy diagnostics</button>
|
||||
</div>
|
||||
<div class="settings-field extensions-trust-note" id="extensionsTrustModel">
|
||||
<label>Trust model</label>
|
||||
<div>Extensions run in the WebUI browser origin and can access the same authenticated APIs as this session. Only load trusted local extension directories; disabling an extension takes effect after reload for already-injected assets.</div>
|
||||
</div>
|
||||
<div id="extensionsDiagnostics" class="extensions-diagnostics" aria-live="polite">
|
||||
<div class="extensions-loading">Loading extension diagnostics…</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-pane" id="settingsPaneSystem">
|
||||
|
||||
+140
-1
@@ -6494,6 +6494,9 @@ let _settingsIndexPromise = null;
|
||||
let _settingsSearchSeq = 0;
|
||||
let _extensionsStatusData = null;
|
||||
let _extensionsSidecarMonitorSeq = 0;
|
||||
let _extensionsGalleryData = null;
|
||||
let _extensionsGalleryLoaded = false;
|
||||
let _extensionsActiveTab = 'gallery';
|
||||
let _settingsSearchDismissListenerRegistered = false;
|
||||
let _settingsAppearanceAutosaveTimer = null;
|
||||
let _settingsAppearanceAutosaveRetryPayload = null;
|
||||
@@ -8246,6 +8249,143 @@ async function loadExtensionsPanel(){
|
||||
if(copyBtn) copyBtn.disabled=true;
|
||||
target.innerHTML='<div class="extensions-error">Failed to load extension diagnostics: '+esc(e.message||String(e))+'</div>';
|
||||
}
|
||||
if(_extensionsActiveTab==='gallery'&&!_extensionsGalleryLoaded) loadExtensionsGallery();
|
||||
}
|
||||
|
||||
function switchExtensionsTab(tab){
|
||||
_extensionsActiveTab=tab;
|
||||
document.querySelectorAll('[data-extensions-tab]').forEach(btn=>{
|
||||
btn.classList.toggle('extensions-tab-active',btn.dataset.extensionsTab===tab);
|
||||
});
|
||||
document.querySelectorAll('[data-extensions-pane]').forEach(pane=>{
|
||||
pane.hidden=pane.dataset.extensionsPane!==tab;
|
||||
});
|
||||
if(tab==='gallery'&&!_extensionsGalleryLoaded) loadExtensionsGallery();
|
||||
}
|
||||
|
||||
async function loadExtensionsGallery(){
|
||||
_extensionsGalleryLoaded=true;
|
||||
const galleryEl=$('extensionsGallery');
|
||||
const installedEl=$('extensionsInstalled');
|
||||
if(galleryEl) galleryEl.innerHTML='<div class="extensions-loading">Loading gallery…</div>';
|
||||
if(installedEl) installedEl.innerHTML='<div class="extensions-loading">Loading installed extensions…</div>';
|
||||
try{
|
||||
const [regData,statusData]=await Promise.all([
|
||||
api('/api/extensions/registry'),
|
||||
api('/api/extensions/status'),
|
||||
]);
|
||||
_extensionsGalleryData={regData,statusData};
|
||||
_renderExtensionsGallery(regData.entries||[],statusData);
|
||||
}catch(e){
|
||||
_extensionsGalleryLoaded=false;
|
||||
const msg=esc(e&&e.message?e.message:String(e));
|
||||
if(galleryEl) galleryEl.innerHTML='<div class="extensions-error">Failed to load gallery: '+msg+'</div>';
|
||||
if(installedEl) installedEl.innerHTML='<div class="extensions-error">Failed to load extension status.</div>';
|
||||
}
|
||||
}
|
||||
|
||||
function _renderExtensionsGallery(entries,statusData){
|
||||
const galleryEl=$('extensionsGallery');
|
||||
const installedEl=$('extensionsInstalled');
|
||||
const installedIds=new Set();
|
||||
if(statusData&&statusData.gallery_installed){
|
||||
Object.keys(statusData.gallery_installed).forEach(id=>installedIds.add(id));
|
||||
}
|
||||
if(statusData&&Array.isArray(statusData.extensions)){
|
||||
statusData.extensions.forEach(e=>{ if(e&&e.id) installedIds.add(e.id); });
|
||||
}
|
||||
if(!Array.isArray(entries)||entries.length===0){
|
||||
if(galleryEl) galleryEl.innerHTML='<div class="extensions-empty">No extensions found in the registry.</div>';
|
||||
if(installedEl) installedEl.innerHTML='<div class="extensions-empty">No extensions installed from the gallery.</div>';
|
||||
return;
|
||||
}
|
||||
const galleryCards=[];
|
||||
const installedCards=[];
|
||||
for(const entry of entries){
|
||||
const id=esc(String(entry.id||''));
|
||||
const name=esc(String(entry.name||entry.id||''));
|
||||
const author=esc(String(entry.author||''));
|
||||
const version=esc(String(entry.version||''));
|
||||
const desc=esc(String(entry.description||''));
|
||||
const caps=Array.isArray(entry.capabilities)?entry.capabilities:[];
|
||||
const perms=entry.permissions||null;
|
||||
const isInstalled=installedIds.has(String(entry.id||''));
|
||||
const restartRequired=!!(entry.lifecycle&&entry.lifecycle.restart_required);
|
||||
const badgesHtml=caps.map(c=>`<span class="extension-gallery-badge">${esc(String(c))}</span>`).join('');
|
||||
const permsHtml=perms?`<details class="extension-gallery-perms"><summary data-i18n="ext_gallery_permissions_show">Permissions</summary><pre>${esc(JSON.stringify(perms,null,2))}</pre></details>`:'';
|
||||
const actionBtn=isInstalled
|
||||
?`<button class="extension-gallery-uninstall-btn" data-ext-uninstall-id="${id}" type="button" data-i18n="ext_gallery_uninstall">Uninstall</button>`
|
||||
:`<button class="extension-gallery-install-btn" data-ext-install-id="${id}" type="button" data-i18n="ext_gallery_install">Install</button>`;
|
||||
const installedBadge=isInstalled?'<span class="extension-gallery-installed-badge">Installed</span>':'';
|
||||
const card=`<div class="extension-gallery-card">
|
||||
<div class="extension-gallery-head">
|
||||
<div class="extension-gallery-info">
|
||||
<div class="extension-gallery-name">${name}${installedBadge}</div>
|
||||
<div class="extension-gallery-meta">${author?'by '+author:''}${version?' · v'+version:''}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="extension-gallery-desc">${desc}</div>
|
||||
${badgesHtml?'<div class="extension-gallery-badge-row">'+badgesHtml+'</div>':''}
|
||||
${permsHtml}
|
||||
<div class="extension-gallery-actions">${actionBtn}</div>
|
||||
</div>`;
|
||||
galleryCards.push(card);
|
||||
if(isInstalled) installedCards.push(card);
|
||||
}
|
||||
if(galleryEl) galleryEl.innerHTML=galleryCards.length?galleryCards.join(''):'<div class="extensions-empty">No extensions found.</div>';
|
||||
if(installedEl) installedEl.innerHTML=installedCards.length?installedCards.join(''):'<div class="extensions-empty">No extensions installed from the gallery.</div>';
|
||||
_bindExtensionGalleryButtons(entries);
|
||||
}
|
||||
|
||||
function _bindExtensionGalleryButtons(entries){
|
||||
const entryMap=new Map();
|
||||
if(Array.isArray(entries)) entries.forEach(e=>{if(e&&e.id)entryMap.set(String(e.id),e);});
|
||||
document.querySelectorAll('[data-ext-install-id]').forEach(btn=>{
|
||||
const entry=entryMap.get(btn.dataset.extInstallId);
|
||||
if(entry) btn.addEventListener('click',()=>handleExtensionInstall(btn,entry));
|
||||
});
|
||||
document.querySelectorAll('[data-ext-uninstall-id]').forEach(btn=>{
|
||||
btn.addEventListener('click',()=>handleExtensionUninstall(btn,btn.dataset.extUninstallId));
|
||||
});
|
||||
}
|
||||
|
||||
async function handleExtensionInstall(btn,entry){
|
||||
if(!btn||btn.disabled) return;
|
||||
const previousText=btn.textContent;
|
||||
btn.disabled=true;
|
||||
btn.textContent='Installing…';
|
||||
try{
|
||||
const result=await api('/api/extensions/install',{method:'POST',body:JSON.stringify({
|
||||
id:entry.id,
|
||||
download_url:entry.download_url,
|
||||
sha256:entry.sha256,
|
||||
})});
|
||||
const restart=!!(entry.lifecycle&&entry.lifecycle.restart_required);
|
||||
showToast(restart?'Extension installed. Restart required to apply.':'Extension installed successfully.');
|
||||
_extensionsGalleryLoaded=false;
|
||||
await loadExtensionsGallery();
|
||||
}catch(e){
|
||||
btn.disabled=false;
|
||||
btn.textContent=previousText;
|
||||
showToast('Install failed: '+(e&&e.message?e.message:String(e)));
|
||||
}
|
||||
}
|
||||
|
||||
async function handleExtensionUninstall(btn,id){
|
||||
if(!btn||btn.disabled) return;
|
||||
const previousText=btn.textContent;
|
||||
btn.disabled=true;
|
||||
btn.textContent='Uninstalling…';
|
||||
try{
|
||||
await api('/api/extensions/uninstall',{method:'POST',body:JSON.stringify({id})});
|
||||
showToast('Extension uninstalled.');
|
||||
_extensionsGalleryLoaded=false;
|
||||
await loadExtensionsGallery();
|
||||
}catch(e){
|
||||
btn.disabled=false;
|
||||
btn.textContent=previousText;
|
||||
showToast('Uninstall failed: '+(e&&e.message?e.message:String(e)));
|
||||
}
|
||||
}
|
||||
|
||||
async function copyExtensionsDiagnostics(){
|
||||
@@ -8264,7 +8404,6 @@ async function copyExtensionsDiagnostics(){
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ── Plugins panel (read-only plugin/hook visibility) ───────────────────────
|
||||
|
||||
async function handlePluginEnableToggle(pluginKey, checked){
|
||||
|
||||
@@ -4725,6 +4725,32 @@ main.main > #mainPlugin{display:none;}
|
||||
.extension-sidecar-status-healthy{background:rgba(34,197,94,.14);color:#22c55e;}
|
||||
.extension-sidecar-status-unhealthy{background:rgba(245,158,11,.14);color:#f59e0b;}
|
||||
.extension-sidecar-status-blocked{background:rgba(239,68,68,.14);color:var(--error);}
|
||||
.extensions-tab-bar{display:flex;gap:4px;margin:0 0 12px;border-bottom:1px solid var(--border);padding-bottom:0;}
|
||||
.extensions-tab-btn{background:none;border:none;border-bottom:2px solid transparent;padding:6px 14px;font-size:13px;color:var(--muted);cursor:pointer;transition:color .15s,border-color .15s;margin-bottom:-1px;}
|
||||
.extensions-tab-btn:hover{color:var(--text);}
|
||||
.extensions-tab-active{color:var(--text);border-bottom-color:var(--accent,#7c3aed);font-weight:600;}
|
||||
.extensions-gallery{display:flex;flex-direction:column;gap:10px;margin-top:4px;}
|
||||
.extension-gallery-card{background:var(--card-bg,var(--sidebar-bg));border:1px solid var(--border);border-radius:8px;padding:12px 14px;}
|
||||
.extension-gallery-head{display:flex;align-items:flex-start;justify-content:space-between;gap:8px;}
|
||||
.extension-gallery-info{flex:1;min-width:0;}
|
||||
.extension-gallery-name{font-size:13px;font-weight:600;color:var(--text);display:flex;align-items:center;gap:6px;flex-wrap:wrap;}
|
||||
.extension-gallery-meta{font-size:11px;color:var(--muted);margin-top:2px;}
|
||||
.extension-gallery-desc{font-size:12px;color:var(--muted);margin-top:6px;line-height:1.5;}
|
||||
.extension-gallery-badge-row{display:flex;flex-wrap:wrap;gap:4px;margin-top:8px;}
|
||||
.extension-gallery-badge{display:inline-flex;align-items:center;background:var(--code-bg);color:var(--muted);border-radius:999px;padding:2px 8px;font-size:10.5px;font-weight:600;font-family:var(--font-mono);}
|
||||
.extension-gallery-perms{margin-top:8px;font-size:12px;}
|
||||
.extension-gallery-perms summary{cursor:pointer;color:var(--muted);user-select:none;}
|
||||
.extension-gallery-perms pre{margin:6px 0 0;padding:8px;background:var(--code-bg);border-radius:4px;font-size:11px;overflow-x:auto;white-space:pre-wrap;word-break:break-all;}
|
||||
.extension-gallery-actions{display:flex;gap:8px;margin-top:10px;align-items:center;flex-wrap:wrap;}
|
||||
.extension-gallery-install-btn,.extension-gallery-uninstall-btn{padding:4px 12px;border-radius:6px;font-size:12px;font-weight:600;cursor:pointer;border:1px solid transparent;transition:background .15s,color .15s;}
|
||||
.extension-gallery-install-btn{background:var(--accent,#7c3aed);color:#fff;border-color:var(--accent,#7c3aed);}
|
||||
.extension-gallery-install-btn:hover:not(:disabled){filter:brightness(1.1);}
|
||||
.extension-gallery-uninstall-btn{background:var(--card-bg,var(--sidebar-bg));color:var(--error,#ef4444);border-color:var(--error,#ef4444);}
|
||||
.extension-gallery-uninstall-btn:hover:not(:disabled){background:rgba(239,68,68,.08);}
|
||||
.extension-gallery-install-btn:disabled,.extension-gallery-uninstall-btn:disabled{opacity:.5;cursor:not-allowed;}
|
||||
.extension-gallery-installed-badge{display:inline-flex;align-items:center;background:rgba(34,197,94,.14);color:#22c55e;border-radius:999px;padding:2px 8px;font-size:10.5px;font-weight:700;font-family:var(--font-mono);}
|
||||
.extension-gallery-restart-note{font-size:11px;color:var(--muted);font-style:italic;}
|
||||
.extensions-empty{padding:24px 0;text-align:center;color:var(--muted);font-size:13px;}
|
||||
@media (max-width: 768px){
|
||||
.extensions-copy-diagnostics-btn{width:100%;}
|
||||
.extension-summary-grid{grid-template-columns:1fr;}
|
||||
|
||||
@@ -57,7 +57,6 @@ def test_extensions_panel_warns_about_trust_model_and_stays_install_free():
|
||||
pane_end = INDEX_HTML.index('id="settingsPaneSystem"', pane_start)
|
||||
pane = INDEX_HTML[pane_start:pane_end]
|
||||
|
||||
assert "Diagnostics and enable/disable controls" in pane
|
||||
assert "Extensions run in the WebUI browser origin" in pane
|
||||
assert "Only load trusted local extension directories" in pane
|
||||
assert "takes effect after reload" in pane
|
||||
@@ -65,8 +64,6 @@ def test_extensions_panel_warns_about_trust_model_and_stays_install_free():
|
||||
assert "saveSettings(" not in pane
|
||||
assert "api('/api/settings'" not in pane
|
||||
assert "type=\"checkbox\"" not in pane
|
||||
assert "Install" not in pane
|
||||
assert "Uninstall" not in pane
|
||||
assert "marketplace" not in pane.lower()
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
"""Behavioral tests for the extension gallery install/uninstall/registry feature."""
|
||||
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def _make_zip(files: dict) -> bytes:
|
||||
"""Build an in-memory zip containing the given {name: content} mapping."""
|
||||
buf = io.BytesIO()
|
||||
with zipfile.ZipFile(buf, "w") as zf:
|
||||
for name, content in files.items():
|
||||
zf.writestr(name, content)
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def _setup_ext_env(monkeypatch, tmp_path):
|
||||
"""Point extension root and state dir at tmp_path subdirectories."""
|
||||
ext_dir = tmp_path / "extensions"
|
||||
ext_dir.mkdir()
|
||||
state_dir = tmp_path / "state"
|
||||
state_dir.mkdir()
|
||||
monkeypatch.setenv("HERMES_WEBUI_EXTENSION_DIR", str(ext_dir))
|
||||
monkeypatch.setenv("HERMES_WEBUI_STATE_DIR", str(state_dir))
|
||||
import api.extensions as ext_mod
|
||||
monkeypatch.setattr(ext_mod, "_extension_state_dir", lambda: state_dir)
|
||||
return ext_dir, state_dir
|
||||
|
||||
|
||||
def test_install_valid(monkeypatch, tmp_path):
|
||||
ext_dir, state_dir = _setup_ext_env(monkeypatch, tmp_path)
|
||||
import api.extensions as ext_mod
|
||||
|
||||
files = {
|
||||
"manifest.json": json.dumps({"version": "1.2.3"}),
|
||||
"index.js": "console.log('hello');",
|
||||
}
|
||||
zip_bytes = _make_zip(files)
|
||||
sha = hashlib.sha256(zip_bytes).hexdigest()
|
||||
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.read.return_value = zip_bytes
|
||||
monkeypatch.setattr(ext_mod, "urlopen", lambda *a, **kw: mock_resp)
|
||||
|
||||
result = ext_mod.install_extension(
|
||||
"my-ext",
|
||||
"https://hermes-webui.github.io/exts/my-ext.zip",
|
||||
sha,
|
||||
)
|
||||
assert result["installed"] is True
|
||||
assert result["id"] == "my-ext"
|
||||
assert result["version"] == "1.2.3"
|
||||
assert (ext_dir / "my-ext" / "index.js").exists()
|
||||
manifest = ext_mod._load_install_manifest()
|
||||
assert "my-ext" in manifest["installed"]
|
||||
assert "index.js" in manifest["installed"]["my-ext"]["files"]
|
||||
|
||||
|
||||
def test_install_bad_hash(monkeypatch, tmp_path):
|
||||
ext_dir, state_dir = _setup_ext_env(monkeypatch, tmp_path)
|
||||
import api.extensions as ext_mod
|
||||
|
||||
zip_bytes = _make_zip({"index.js": "code"})
|
||||
wrong_sha = "a" * 64
|
||||
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.read.return_value = zip_bytes
|
||||
monkeypatch.setattr(ext_mod, "urlopen", lambda *a, **kw: mock_resp)
|
||||
|
||||
with pytest.raises(ext_mod.ExtensionInstallError, match="SHA-256"):
|
||||
ext_mod.install_extension(
|
||||
"bad-ext",
|
||||
"https://hermes-webui.github.io/exts/bad-ext.zip",
|
||||
wrong_sha,
|
||||
)
|
||||
assert not (ext_dir / "bad-ext").exists()
|
||||
|
||||
|
||||
def test_install_zipslip(monkeypatch, tmp_path):
|
||||
ext_dir, state_dir = _setup_ext_env(monkeypatch, tmp_path)
|
||||
import api.extensions as ext_mod
|
||||
|
||||
# Build a zip with a path-traversal member
|
||||
buf = io.BytesIO()
|
||||
with zipfile.ZipFile(buf, "w") as zf:
|
||||
zf.writestr("../../evil.txt", "pwned")
|
||||
zip_bytes = buf.getvalue()
|
||||
sha = hashlib.sha256(zip_bytes).hexdigest()
|
||||
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.read.return_value = zip_bytes
|
||||
monkeypatch.setattr(ext_mod, "urlopen", lambda *a, **kw: mock_resp)
|
||||
|
||||
with pytest.raises(ext_mod.ExtensionInstallError):
|
||||
ext_mod.install_extension(
|
||||
"slip-ext",
|
||||
"https://hermes-webui.github.io/exts/slip-ext.zip",
|
||||
sha,
|
||||
)
|
||||
assert not (tmp_path / "evil.txt").exists()
|
||||
|
||||
|
||||
def test_uninstall(monkeypatch, tmp_path):
|
||||
ext_dir, state_dir = _setup_ext_env(monkeypatch, tmp_path)
|
||||
import api.extensions as ext_mod
|
||||
|
||||
files = {"index.js": "code", "style.css": "body{}"}
|
||||
zip_bytes = _make_zip(files)
|
||||
sha = hashlib.sha256(zip_bytes).hexdigest()
|
||||
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.read.return_value = zip_bytes
|
||||
monkeypatch.setattr(ext_mod, "urlopen", lambda *a, **kw: mock_resp)
|
||||
|
||||
ext_mod.install_extension(
|
||||
"rm-ext",
|
||||
"https://hermes-webui.github.io/exts/rm-ext.zip",
|
||||
sha,
|
||||
)
|
||||
assert (ext_dir / "rm-ext" / "index.js").exists()
|
||||
|
||||
result = ext_mod.uninstall_extension("rm-ext")
|
||||
assert result["uninstalled"] is True
|
||||
assert not (ext_dir / "rm-ext" / "index.js").exists()
|
||||
assert not (ext_dir / "rm-ext" / "style.css").exists()
|
||||
manifest = ext_mod._load_install_manifest()
|
||||
assert "rm-ext" not in manifest["installed"]
|
||||
|
||||
|
||||
def test_install_rollback(monkeypatch, tmp_path):
|
||||
ext_dir, state_dir = _setup_ext_env(monkeypatch, tmp_path)
|
||||
import api.extensions as ext_mod
|
||||
|
||||
files = {"first.js": "a", "second.js": "b"}
|
||||
zip_bytes = _make_zip(files)
|
||||
sha = hashlib.sha256(zip_bytes).hexdigest()
|
||||
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.read.return_value = zip_bytes
|
||||
monkeypatch.setattr(ext_mod, "urlopen", lambda *a, **kw: mock_resp)
|
||||
|
||||
write_count = [0]
|
||||
original_write_bytes = Path.write_bytes
|
||||
|
||||
def patched_write_bytes(self, data):
|
||||
write_count[0] += 1
|
||||
if write_count[0] >= 2:
|
||||
raise OSError("simulated write failure")
|
||||
return original_write_bytes(self, data)
|
||||
|
||||
monkeypatch.setattr(Path, "write_bytes", patched_write_bytes)
|
||||
|
||||
with pytest.raises(ext_mod.ExtensionInstallError, match="Extraction failed"):
|
||||
ext_mod.install_extension(
|
||||
"roll-ext",
|
||||
"https://hermes-webui.github.io/exts/roll-ext.zip",
|
||||
sha,
|
||||
)
|
||||
remaining = list((ext_dir / "roll-ext").glob("**/*")) if (ext_dir / "roll-ext").exists() else []
|
||||
assert remaining == []
|
||||
|
||||
|
||||
def test_gallery_render(monkeypatch, tmp_path):
|
||||
import api.extensions as ext_mod
|
||||
|
||||
registry_data = [
|
||||
{"id": "ext-one", "name": "Extension One", "version": "0.1.0", "description": "First"},
|
||||
{"id": "ext-two", "name": "Extension Two", "version": "0.2.0", "description": "Second"},
|
||||
]
|
||||
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.read.return_value = json.dumps(registry_data).encode("utf-8")
|
||||
|
||||
# Clear cache so we always do a fresh fetch
|
||||
ext_mod._REGISTRY_CACHE.clear()
|
||||
monkeypatch.setattr(ext_mod, "urlopen", lambda *a, **kw: mock_resp)
|
||||
|
||||
result = ext_mod.get_extension_registry()
|
||||
assert "entries" in result
|
||||
assert len(result["entries"]) == 2
|
||||
assert result["entries"][0]["id"] == "ext-one"
|
||||
assert result["entries"][1]["id"] == "ext-two"
|
||||
Reference in new issue
Block a user