diff --git a/api/extensions.py b/api/extensions.py index 06ac5d91..d1f3d688 100644 --- a/api/extensions.py +++ b/api/extensions.py @@ -857,7 +857,10 @@ def _gallery_installed_runtime_manifest( for key in ("name", "enabled", "scripts", "stylesheets", "sidecar", "permissions", "settings_schema"): if key in manifest: top_entry[key] = manifest[key] - if any(key in top_entry for key in ("scripts", "stylesheets", "sidecar")): + if any( + key in top_entry + for key in ("scripts", "stylesheets", "sidecar", "permissions", "settings_schema") + ): 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) diff --git a/static/extension_settings.js b/static/extension_settings.js index 05c638ee..5299f66d 100644 --- a/static/extension_settings.js +++ b/static/extension_settings.js @@ -210,6 +210,14 @@ return overrides; } + function readSettingsState(schema,key){ + const stored=safeRead(key); + const checked=validate(schema,stored); + const overrides=overridesFromValues(schema,checked.values); + if(JSON.stringify(stored)!==JSON.stringify(overrides)) safeWrite(key,overrides); + return {values:checked.values,overrides}; + } + function supportsSettings(meta){ return !!(meta&&meta.storage_owned&&Array.isArray(meta.settings_schema)&&meta.settings_schema.length); } @@ -220,7 +228,10 @@ const schema=supportsSettings(meta)?meta.settings_schema:[]; const key=settingsKey(clean); function current(){ - return validate(schema,safeRead(key)).values; + return supportsSettings(meta)?readSettingsState(schema,key).values:validate(schema,safeRead(key)).values; + } + function currentOverrides(){ + return supportsSettings(meta)?readSettingsState(schema,key).overrides:safeRead(key); } function setAll(values){ if(!supportsSettings(meta)) return {ok:false,values:current(),errors:{extension:'unsupported'}}; @@ -237,7 +248,7 @@ schema, defaults:defaultsFor(schema), get values(){return current();}, - get overrides(){return safeRead(key);}, + get overrides(){return currentOverrides();}, get(name){return current()[name];}, validate(values){return validate(schema,values);}, set(name,value){ diff --git a/tests/test_extension_settings_runtime.py b/tests/test_extension_settings_runtime.py index 2502bd7c..bae6086c 100644 --- a/tests/test_extension_settings_runtime.py +++ b/tests/test_extension_settings_runtime.py @@ -68,6 +68,10 @@ def test_extension_settings_runtime_normalizes_persists_resets_and_clears(): assert.strictEqual(window.hermesExt.settings.forExtension('demo.ext').get('mode'), 'compact'); assert.deepStrictEqual(settings.setAll({{flag: true, mode: 'compact', count: 2}}).values, {{flag: true, mode: 'compact', count: 2}}); assert.deepStrictEqual(JSON.parse(store.get('hermes.ext.settings.demo.ext')), {{flag: true}}); + store.set('hermes.ext.settings.demo.ext', JSON.stringify({{flag: false, unknown: 'kept', bad: 'x'}})); + assert.deepStrictEqual(settings.values, {{flag: false, mode: 'compact', count: 2}}); + assert.deepStrictEqual(settings.overrides, {{}}); + assert.strictEqual(store.has('hermes.ext.settings.demo.ext'), false); assert.strictEqual(settings.set('mode', 'invalid').ok, false); assert.deepStrictEqual(settings.reset(), {{flag: false, mode: 'compact', count: 2}}); assert.strictEqual(store.has('hermes.ext.settings.demo.ext'), false); diff --git a/tests/test_issue4746_extension_gallery.py b/tests/test_issue4746_extension_gallery.py index ce028983..2fe5cf78 100644 --- a/tests/test_issue4746_extension_gallery.py +++ b/tests/test_issue4746_extension_gallery.py @@ -148,6 +148,62 @@ def test_gallery_installed_extension_becomes_runtime_manifest(monkeypatch, tmp_p assert status["extensions"][0]["id"] == "my-ext" +def test_gallery_installed_settings_only_manifest_becomes_runtime_entry(monkeypatch, tmp_path): + """Settings-only gallery installs still surface in status and runtime config.""" + 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( + { + "name": "Settings Only", + "version": "1.0.0", + "permissions": {"storage": {"owned": True}}, + "settings_schema": [ + {"key": "flag", "type": "boolean", "default": True}, + ], + } + ), + } + 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": [], + "stylesheet_urls": [], + "extensions": [ + { + "id": "my-ext", + "name": "Settings Only", + "storage_owned": True, + "settings_schema": [ + {"key": "flag", "type": "boolean", "label": "flag", "description": "", "default": True}, + ], + } + ], + } + 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" + assert status["extensions"][0]["storage_owned"] is True + assert status["extensions"][0]["settings_schema"] == [ + {"key": "flag", "type": "boolean", "label": "flag", "description": "", "default": True}, + ] + + def test_install_bootstraps_managed_default_root_without_env(monkeypatch, tmp_path): """Plug-and-play (#4933): one-click install works with NO env configured.