Fix gallery-installed extension activation
This commit is contained in:
1 parent
cea9244460
commit
5f17ba55ed
5 files changed
+210
-7
No files matched your search
@@ -3,6 +3,10 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Gallery-installed extensions are now activated after install instead of only being marked installed.** When an extension was installed from Settings -> Extensions without also preconfiguring `HERMES_WEBUI_EXTENSION_MANIFEST`, the gallery wrote files and showed the Installed state but the app shell did not inject the extension scripts. A second failure hit subdirectory manifests such as `desktop-companion/manifest.json`: relative assets like `assets/companion-adapter.js` were resolved as `/extensions/assets/...` instead of `/extensions/desktop-companion/assets/...`, producing a 404 and MIME-type console error. Gallery installs now build a runtime manifest from each installed extension's `manifest.json`, and explicit subdirectory manifests resolve relative assets from their manifest directory.
|
||||
|
||||
## [v0.51.648] — 2026-06-25 — Release XD (settled tool cards keep their full output)
|
||||
|
||||
### Fixed
|
||||
|
||||
+110
-7
@@ -417,7 +417,7 @@ def _manifest_path(root: Path) -> Optional[Path]:
|
||||
return manifest
|
||||
|
||||
|
||||
def _manifest_asset_url(value: object) -> str:
|
||||
def _manifest_asset_url(value: object, asset_base: str = "") -> str:
|
||||
"""Normalize a manifest asset entry to the existing same-origin URL format."""
|
||||
if not isinstance(value, str):
|
||||
return ""
|
||||
@@ -432,7 +432,32 @@ def _manifest_asset_url(value: object) -> str:
|
||||
# are still allowed and go through the same validator as env-configured URLs.
|
||||
if item.startswith("/"):
|
||||
return item
|
||||
return EXTENSION_ROUTE_PREFIX + item
|
||||
base = asset_base.strip("/")
|
||||
rel = f"{base}/{item}" if base else item
|
||||
return EXTENSION_ROUTE_PREFIX + rel
|
||||
|
||||
|
||||
def _manifest_asset_value_with_base(value: object, asset_base: str) -> object:
|
||||
"""Rewrite a manifest asset value so it remains relative to its manifest file."""
|
||||
if not isinstance(value, str):
|
||||
return value
|
||||
item = value.strip()
|
||||
if not item:
|
||||
return item
|
||||
parsed = urlsplit(item)
|
||||
if parsed.scheme or parsed.netloc or item.startswith("//") or item.startswith("/"):
|
||||
return item
|
||||
base = asset_base.strip("/")
|
||||
return f"{base}/{item}" if base else item
|
||||
|
||||
|
||||
def _copy_manifest_entry_with_asset_base(entry: Dict[str, object], asset_base: str) -> Dict[str, object]:
|
||||
copied = dict(entry)
|
||||
for key in ("scripts", "stylesheets"):
|
||||
values = copied.get(key)
|
||||
if isinstance(values, list):
|
||||
copied[key] = [_manifest_asset_value_with_base(value, asset_base) for value in values]
|
||||
return copied
|
||||
|
||||
|
||||
def _manifest_entry_text(entry: Dict[str, object], key: str) -> str:
|
||||
@@ -606,6 +631,7 @@ def _empty_manifest_status(path_status: str) -> Dict[str, Any]:
|
||||
"configured": path_status != "not_configured",
|
||||
"loaded": False,
|
||||
"status": path_status,
|
||||
"_asset_base": "",
|
||||
"entry_count": 0,
|
||||
"script_count": 0,
|
||||
"stylesheet_count": 0,
|
||||
@@ -613,6 +639,62 @@ def _empty_manifest_status(path_status: str) -> Dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
def _manifest_asset_base(root: Path, manifest_file: Path) -> str:
|
||||
try:
|
||||
rel_parent = manifest_file.parent.relative_to(root).as_posix()
|
||||
except ValueError:
|
||||
return ""
|
||||
return "" if rel_parent == "." else rel_parent
|
||||
|
||||
|
||||
def _gallery_installed_runtime_manifest(
|
||||
root: Path, diagnostics: Optional[Dict[str, Any]] = None
|
||||
) -> Optional[Dict[str, object]]:
|
||||
"""Build a runtime manifest from gallery-installed extension manifests."""
|
||||
install_manifest = _load_install_manifest()
|
||||
installed = install_manifest.get("installed", {})
|
||||
if not isinstance(installed, dict):
|
||||
return None
|
||||
entries: List[Dict[str, object]] = []
|
||||
for ext_id in sorted(installed):
|
||||
if not _valid_extension_id(ext_id):
|
||||
continue
|
||||
manifest_file = root / ext_id / "manifest.json"
|
||||
try:
|
||||
if not manifest_file.exists() or not manifest_file.is_file():
|
||||
_add_diagnostic_warning(diagnostics, "gallery_manifest_missing", "gallery")
|
||||
continue
|
||||
manifest = json.loads(_read_manifest_text(manifest_file))
|
||||
except _ManifestTooLarge:
|
||||
_add_diagnostic_warning(diagnostics, "gallery_manifest_oversized", "gallery")
|
||||
continue
|
||||
except json.JSONDecodeError:
|
||||
_add_diagnostic_warning(diagnostics, "gallery_manifest_malformed", "gallery")
|
||||
continue
|
||||
except RecursionError:
|
||||
_add_diagnostic_warning(diagnostics, "gallery_manifest_too_deeply_nested", "gallery")
|
||||
continue
|
||||
except (OSError, UnicodeDecodeError):
|
||||
_add_diagnostic_warning(diagnostics, "gallery_manifest_unreadable", "gallery")
|
||||
continue
|
||||
asset_base = ext_id
|
||||
if isinstance(manifest, dict):
|
||||
top_entry: Dict[str, object] = {"id": ext_id}
|
||||
for key in ("name", "enabled", "scripts", "stylesheets", "sidecar"):
|
||||
if key in manifest:
|
||||
top_entry[key] = manifest[key]
|
||||
if any(key in top_entry for key in ("scripts", "stylesheets", "sidecar")):
|
||||
entries.append(_copy_manifest_entry_with_asset_base(top_entry, asset_base))
|
||||
for _source, _index, entry in _manifest_extension_entries(manifest):
|
||||
copied = _copy_manifest_entry_with_asset_base(entry, asset_base)
|
||||
if not _valid_extension_id(copied.get("id")):
|
||||
copied["id"] = ext_id
|
||||
entries.append(copied)
|
||||
if not entries:
|
||||
return None
|
||||
return {"extensions": entries}
|
||||
|
||||
|
||||
def _load_manifest_with_status(
|
||||
root: Path, diagnostics: Optional[Dict[str, Any]] = None
|
||||
) -> Tuple[Optional[object], Dict[str, Any]]:
|
||||
@@ -622,6 +704,17 @@ def _load_manifest_with_status(
|
||||
if manifest_file is None:
|
||||
if path_status == "invalid_path":
|
||||
_add_diagnostic_warning(diagnostics, "manifest_invalid_path", "manifest")
|
||||
elif path_status == "not_configured":
|
||||
manifest = _gallery_installed_runtime_manifest(root, diagnostics)
|
||||
if manifest is not None:
|
||||
manifest_status.update(
|
||||
{
|
||||
"loaded": True,
|
||||
"status": "gallery_installed",
|
||||
"_asset_base": "",
|
||||
}
|
||||
)
|
||||
return manifest, manifest_status
|
||||
return None, manifest_status
|
||||
try:
|
||||
if not manifest_file.exists() or not manifest_file.is_file():
|
||||
@@ -630,7 +723,13 @@ def _load_manifest_with_status(
|
||||
_add_diagnostic_warning(diagnostics, "manifest_missing", "manifest")
|
||||
return None, manifest_status
|
||||
manifest = json.loads(_read_manifest_text(manifest_file))
|
||||
manifest_status.update({"loaded": True, "status": "loaded"})
|
||||
manifest_status.update(
|
||||
{
|
||||
"loaded": True,
|
||||
"status": "loaded",
|
||||
"_asset_base": _manifest_asset_base(root, manifest_file),
|
||||
}
|
||||
)
|
||||
return manifest, manifest_status
|
||||
except _ManifestTooLarge:
|
||||
_log.warning("Configured extension manifest exceeds %d bytes", _MAX_MANIFEST_BYTES)
|
||||
@@ -729,6 +828,7 @@ def _read_manifest_urls_with_diagnostics(
|
||||
scripts: List[str] = []
|
||||
stylesheets: List[str] = []
|
||||
sidecars: List[Dict[str, str]] = []
|
||||
asset_base = str(manifest_status.get("_asset_base", "") or "")
|
||||
entries = _iter_manifest_entries(manifest, disabled_ids=disabled_ids)
|
||||
manifest_status["entry_count"] = len(entries)
|
||||
scripts_full = False
|
||||
@@ -751,7 +851,7 @@ def _read_manifest_urls_with_diagnostics(
|
||||
for value in _entry_asset_values(entry, "scripts"):
|
||||
if not _append_safe_asset_url(
|
||||
scripts,
|
||||
_manifest_asset_url(value),
|
||||
_manifest_asset_url(value, asset_base),
|
||||
script_source,
|
||||
diagnostics=diagnostics,
|
||||
):
|
||||
@@ -761,7 +861,7 @@ def _read_manifest_urls_with_diagnostics(
|
||||
for value in _entry_asset_values(entry, "stylesheets"):
|
||||
if not _append_safe_asset_url(
|
||||
stylesheets,
|
||||
_manifest_asset_url(value),
|
||||
_manifest_asset_url(value, asset_base),
|
||||
stylesheet_source,
|
||||
diagnostics=diagnostics,
|
||||
):
|
||||
@@ -770,7 +870,7 @@ def _read_manifest_urls_with_diagnostics(
|
||||
manifest_status.update(
|
||||
{
|
||||
"loaded": True,
|
||||
"status": "loaded",
|
||||
"status": manifest_status.get("status") or "loaded",
|
||||
"script_count": len(scripts),
|
||||
"stylesheet_count": len(stylesheets),
|
||||
"sidecar_count": len(sidecars),
|
||||
@@ -873,6 +973,9 @@ def get_extension_status() -> Dict[str, Any]:
|
||||
manifest_stylesheets or None,
|
||||
diagnostics=diagnostics,
|
||||
)
|
||||
public_manifest_status = {
|
||||
key: value for key, value in manifest_status.items() if not key.startswith("_")
|
||||
}
|
||||
return {
|
||||
"enabled": True,
|
||||
"extension_dir_configured": True,
|
||||
@@ -887,7 +990,7 @@ def get_extension_status() -> Dict[str, Any]:
|
||||
"manifest_extensions": len(extensions),
|
||||
"user_disabled": user_disabled_count,
|
||||
},
|
||||
"manifest": manifest_status,
|
||||
"manifest": public_manifest_status,
|
||||
"extensions": extensions,
|
||||
"gallery_installed": _load_install_manifest().get("installed", {}),
|
||||
"warnings": diagnostics["warnings"],
|
||||
|
||||
@@ -95,6 +95,22 @@ may be kept in the manifest with the JSON boolean `"enabled": false`. Explicit
|
||||
`HERMES_WEBUI_EXTENSION_STYLESHEET_URLS` still work and are appended after
|
||||
manifest assets, with duplicates ignored.
|
||||
|
||||
When an extension is installed from Settings -> Extensions, WebUI records the
|
||||
installed package and loads that package's `manifest.json` automatically on the
|
||||
next app-shell render. In this gallery-installed mode, a manifest located at
|
||||
`HERMES_WEBUI_EXTENSION_DIR/<extension-id>/manifest.json` resolves bare relative
|
||||
assets relative to that package directory. For example,
|
||||
`"scripts": ["assets/companion-adapter.js"]` in
|
||||
`desktop-companion/manifest.json` injects
|
||||
`/extensions/desktop-companion/assets/companion-adapter.js`.
|
||||
|
||||
Manual manifests configured with `HERMES_WEBUI_EXTENSION_MANIFEST` follow the
|
||||
same rule: relative assets resolve from the manifest file's directory. A root
|
||||
manifest such as `extensions.json` keeps the existing
|
||||
`/extensions/<asset-path>` behavior, while a subdirectory manifest such as
|
||||
`desktop-companion/manifest.json` resolves relative assets under
|
||||
`/extensions/desktop-companion/`.
|
||||
|
||||
Extension entries may also declare a read-only loopback sidecar for diagnostics:
|
||||
|
||||
```json
|
||||
|
||||
@@ -196,6 +196,39 @@ def test_extension_manifest_adds_bundled_assets_before_env_urls(tmp_path, monkey
|
||||
}
|
||||
|
||||
|
||||
def test_extension_manifest_relative_assets_resolve_from_manifest_directory(tmp_path, monkeypatch):
|
||||
root = tmp_path / "extensions"
|
||||
root.mkdir()
|
||||
ext_dir = root / "desktop-companion"
|
||||
ext_dir.mkdir()
|
||||
(ext_dir / "manifest.json").write_text(
|
||||
"""
|
||||
{
|
||||
"extensions": [
|
||||
{
|
||||
"id": "desktop-companion",
|
||||
"scripts": ["assets/companion-adapter.js"],
|
||||
"stylesheets": ["assets/companion-adapter.css"]
|
||||
}
|
||||
]
|
||||
}
|
||||
""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
monkeypatch.setenv("HERMES_WEBUI_EXTENSION_DIR", str(root))
|
||||
monkeypatch.setenv("HERMES_WEBUI_EXTENSION_MANIFEST", "desktop-companion/manifest.json")
|
||||
monkeypatch.delenv("HERMES_WEBUI_EXTENSION_SCRIPT_URLS", raising=False)
|
||||
monkeypatch.delenv("HERMES_WEBUI_EXTENSION_STYLESHEET_URLS", raising=False)
|
||||
|
||||
from api.extensions import get_extension_config
|
||||
|
||||
assert get_extension_config() == {
|
||||
"enabled": True,
|
||||
"script_urls": ["/extensions/desktop-companion/assets/companion-adapter.js"],
|
||||
"stylesheet_urls": ["/extensions/desktop-companion/assets/companion-adapter.css"],
|
||||
}
|
||||
|
||||
|
||||
def test_extension_manifest_reuses_url_safety_rules(tmp_path, monkeypatch):
|
||||
monkeypatch.delenv("HERMES_WEBUI_EXTENSION_SCRIPT_URLS", raising=False)
|
||||
monkeypatch.delenv("HERMES_WEBUI_EXTENSION_STYLESHEET_URLS", raising=False)
|
||||
|
||||
@@ -93,6 +93,53 @@ def test_install_prefixed_zip(monkeypatch, tmp_path):
|
||||
assert "manifest.json" in manifest["installed"]["my-ext"]["files"]
|
||||
|
||||
|
||||
def test_gallery_installed_extension_becomes_runtime_manifest(monkeypatch, tmp_path):
|
||||
"""Installed gallery extensions are injected without a separate manifest env var."""
|
||||
ext_dir, state_dir = _setup_ext_env(monkeypatch, tmp_path)
|
||||
monkeypatch.delenv("HERMES_WEBUI_EXTENSION_MANIFEST", raising=False)
|
||||
monkeypatch.delenv("HERMES_WEBUI_EXTENSION_SCRIPT_URLS", raising=False)
|
||||
monkeypatch.delenv("HERMES_WEBUI_EXTENSION_STYLESHEET_URLS", raising=False)
|
||||
import api.extensions as ext_mod
|
||||
|
||||
files = {
|
||||
"my-ext/manifest.json": json.dumps(
|
||||
{
|
||||
"version": "1.0.0",
|
||||
"extensions": [
|
||||
{
|
||||
"id": "my-ext",
|
||||
"name": "My Extension",
|
||||
"scripts": ["assets/app.js"],
|
||||
"stylesheets": ["assets/app.css"],
|
||||
}
|
||||
],
|
||||
}
|
||||
),
|
||||
"my-ext/assets/app.js": "console.log('gallery runtime');",
|
||||
"my-ext/assets/app.css": "body{}",
|
||||
}
|
||||
zip_bytes = _make_zip(files)
|
||||
sha = hashlib.sha256(zip_bytes).hexdigest()
|
||||
|
||||
monkeypatch.setattr(ext_mod, "_safe_download", lambda *a, **kw: zip_bytes)
|
||||
|
||||
ext_mod.install_extension(
|
||||
"my-ext",
|
||||
"https://hermes-webui.github.io/exts/my-ext.zip",
|
||||
sha,
|
||||
)
|
||||
|
||||
assert ext_mod.get_extension_config() == {
|
||||
"enabled": True,
|
||||
"script_urls": ["/extensions/my-ext/assets/app.js"],
|
||||
"stylesheet_urls": ["/extensions/my-ext/assets/app.css"],
|
||||
}
|
||||
status = ext_mod.get_extension_status()
|
||||
assert status["manifest"]["status"] == "gallery_installed"
|
||||
assert status["counts"]["manifest_extensions"] == 1
|
||||
assert status["extensions"][0]["id"] == "my-ext"
|
||||
|
||||
|
||||
def test_install_bad_hash(monkeypatch, tmp_path):
|
||||
ext_dir, state_dir = _setup_ext_env(monkeypatch, tmp_path)
|
||||
import api.extensions as ext_mod
|
||||
|
||||
Reference in new issue
Block a user