diff --git a/api/extensions.py b/api/extensions.py index ffa6ddfa..85c40d32 100644 --- a/api/extensions.py +++ b/api/extensions.py @@ -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. diff --git a/api/routes.py b/api/routes.py index 400c20e3..f7860ca6 100644 --- a/api/routes.py +++ b/api/routes.py @@ -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()) diff --git a/static/i18n.js b/static/i18n.js index 8608bef7..18ae9103 100644 --- a/static/i18n.js +++ b/static/i18n.js @@ -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.', diff --git a/static/index.html b/static/index.html index 19fb5843..3f142fc5 100644 --- a/static/index.html +++ b/static/index.html @@ -1413,16 +1413,45 @@
${esc(JSON.stringify(perms,null,2))}