diff --git a/api/extensions.py b/api/extensions.py index d3d3678df..d1f3d6882 100644 --- a/api/extensions.py +++ b/api/extensions.py @@ -7,6 +7,7 @@ It is disabled by default and never executes or fetches third-party URLs. import html import json +import math import logging import os import re @@ -72,8 +73,10 @@ _EXTENSION_STATE_FILENAME = "extension-overrides.json" _MAX_EXTENSION_STATE_BYTES = 32 * 1024 _MAX_DISABLED_EXTENSION_IDS = 512 _EXTENSION_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$") +_EXTENSION_SETTINGS_KEY_RE = re.compile(r"^[A-Za-z][A-Za-z0-9._-]{0,63}$") _EXTENSION_STATE_WARNING_SOURCE = "extension_state" _EXTENSION_STATE_LOCK = threading.Lock() +_EXTENSION_SETTING_TYPES = {"boolean", "string", "number", "integer", "enum"} _GALLERY_INSTALL_STATE_FILENAME = "extension-install-manifest.json" _MAX_INSTALL_MANIFEST_BYTES = 128 * 1024 @@ -521,6 +524,122 @@ def _manifest_entry_text(entry: Dict[str, object], key: str) -> str: return "" return value.strip() +def _manifest_entry_storage_owned(entry: Dict[str, object]) -> bool: + permissions = entry.get("permissions") + if not isinstance(permissions, dict): + return False + storage = permissions.get("storage") + return isinstance(storage, dict) and storage.get("owned") is True + +def _settings_text(value: object, *, max_len: int = 160) -> str: + if not isinstance(value, str): + return "" + text = value.strip() + return text[:max_len] + +def _normalize_enum_options(options: object) -> Optional[List[Dict[str, str]]]: + if not isinstance(options, list) or not options: + return None + normalized: List[Dict[str, str]] = [] + seen: Set[str] = set() + for option in options: + if isinstance(option, str): + value = option.strip() + label = value + elif isinstance(option, dict): + raw_value = option.get("value") + if not isinstance(raw_value, str): + return None + value = raw_value.strip() + label = _settings_text(option.get("label")) or value + else: + return None + if not value or value in seen: + return None + seen.add(value) + normalized.append({"value": value, "label": label}) + return normalized + +_SETTINGS_DEFAULT_MISSING = object() + +def _normalize_settings_default(field_type: str, raw_default: object, options: Optional[List[Dict[str, str]]] = None) -> Tuple[bool, object]: + if field_type == "boolean": + if raw_default is _SETTINGS_DEFAULT_MISSING: + return True, False + return (True, raw_default) if isinstance(raw_default, bool) else (False, None) + if field_type == "string": + if raw_default is _SETTINGS_DEFAULT_MISSING: + return True, "" + return (True, raw_default) if isinstance(raw_default, str) else (False, None) + if field_type == "number": + if raw_default is _SETTINGS_DEFAULT_MISSING: + return True, 0 + return ( + (True, raw_default) + if isinstance(raw_default, (int, float)) and not isinstance(raw_default, bool) and math.isfinite(raw_default) + else (False, None) + ) + if field_type == "integer": + if raw_default is _SETTINGS_DEFAULT_MISSING: + return True, 0 + return (True, raw_default) if isinstance(raw_default, int) and not isinstance(raw_default, bool) else (False, None) + if field_type == "enum" and options: + values = [option["value"] for option in options] + if raw_default is _SETTINGS_DEFAULT_MISSING: + return True, values[0] + return (True, raw_default) if isinstance(raw_default, str) and raw_default in values else (False, None) + return False, None + +def _settings_schema_values(raw_schema: object) -> List[object]: + if isinstance(raw_schema, list): + return raw_schema + if isinstance(raw_schema, dict) and isinstance(raw_schema.get("fields"), list): + return raw_schema["fields"] + return [] + +def _sanitize_settings_schema(entry: Dict[str, object]) -> List[Dict[str, object]]: + if not _manifest_entry_storage_owned(entry): + return [] + fields: List[Dict[str, object]] = [] + seen_keys: Set[str] = set() + for raw_field in _settings_schema_values(entry.get("settings_schema")): + if not isinstance(raw_field, dict): + continue + if raw_field.get("sensitive") is True: + continue + key = raw_field.get("key") + if not isinstance(key, str): + continue + key = key.strip() + if not _EXTENSION_SETTINGS_KEY_RE.fullmatch(key): + continue + field_type = raw_field.get("type") + if not isinstance(field_type, str): + continue + field_type = field_type.strip().lower() + if field_type not in _EXTENSION_SETTING_TYPES: + continue + options = _normalize_enum_options(raw_field.get("options")) if field_type == "enum" else None + if field_type == "enum" and options is None: + continue + ok, default = _normalize_settings_default(field_type, raw_field.get("default", _SETTINGS_DEFAULT_MISSING), options) + if not ok: + continue + if key in seen_keys: + continue + seen_keys.add(key) + field: Dict[str, object] = { + "key": key, + "type": field_type, + "label": _settings_text(raw_field.get("label")) or key, + "description": _settings_text(raw_field.get("description"), max_len=300), + "default": default, + } + if options is not None: + field["options"] = options + fields.append(field) + return fields + def _normalize_loopback_sidecar_origin(value: object) -> Optional[str]: """Return a canonical loopback origin or None when unsafe. @@ -735,10 +854,13 @@ def _gallery_installed_runtime_manifest( asset_base = ext_id if isinstance(manifest, dict): top_entry: Dict[str, object] = {"id": ext_id} - for key in ("name", "enabled", "scripts", "stylesheets", "sidecar"): + 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) @@ -836,6 +958,7 @@ def _manifest_extension_state( effective_enabled = manifest_enabled and not user_disabled if not manifest_enabled: manifest_disabled_ids.add(ext_id) + settings_schema = _sanitize_settings_schema(entry) extension_entries.append( { "id": ext_id, @@ -846,6 +969,8 @@ def _manifest_extension_state( "effective_enabled": effective_enabled, "can_toggle": can_toggle, "reload_required": True, + "storage_owned": _manifest_entry_storage_owned(entry), + "settings_schema": settings_schema, "status": ( "manifest_disabled" if not manifest_enabled @@ -866,6 +991,33 @@ def _manifest_extension_state( "manifest_disabled_ids": manifest_disabled_ids, } +def _extension_runtime_entries( + manifest: object, disabled_ids: Optional[Set[str]] = None +) -> List[Dict[str, object]]: + """Return enabled extension metadata injected before extension scripts run.""" + disabled_ids = disabled_ids or set() + extensions: List[Dict[str, object]] = [] + seen_ids: Set[str] = set() + for _source, _index, entry in _manifest_extension_entries(manifest): + raw_id = _manifest_entry_text(entry, "id") + if not _valid_extension_id(raw_id): + continue + ext_id = raw_id.strip() + if ext_id in seen_ids: + continue + seen_ids.add(ext_id) + if entry.get("enabled", True) is False or ext_id in disabled_ids: + continue + extensions.append( + { + "id": ext_id, + "name": _manifest_entry_text(entry, "name") or ext_id, + "storage_owned": _manifest_entry_storage_owned(entry), + "settings_schema": _sanitize_settings_schema(entry), + } + ) + return extensions + def _read_manifest_urls_with_diagnostics( root: Path, @@ -950,8 +1102,14 @@ def get_extension_config() -> Dict[str, Any]: return {"enabled": False, "script_urls": [], "stylesheet_urls": []} state = _load_extension_state() disabled_ids = set(state.get("disabled_extensions") or []) - manifest_scripts, manifest_stylesheets = _read_manifest_urls(root, disabled_ids=disabled_ids) - return { + manifest, manifest_status = _load_manifest_with_status(root) + manifest_scripts, manifest_stylesheets, _, _ = _read_manifest_urls_with_diagnostics( + root, + disabled_ids=disabled_ids, + manifest=manifest, + manifest_status=manifest_status, + ) + config = { "enabled": True, "script_urls": _read_url_list( _EXTENSION_SCRIPT_URLS_ENV, manifest_scripts or None @@ -960,6 +1118,10 @@ def get_extension_config() -> Dict[str, Any]: _EXTENSION_STYLESHEET_URLS_ENV, manifest_stylesheets or None ), } + runtime_entries = _extension_runtime_entries(manifest, disabled_ids) if manifest is not None else [] + if runtime_entries: + config["extensions"] = runtime_entries + return config @@ -1387,6 +1549,15 @@ def inject_extension_tags(index_html: str) -> str: ''.format(html.escape(url, quote=True)) for url in config["script_urls"] ] + runtime_config = { + "extensions": config.get("extensions", []), + } + runtime_json = json.dumps(runtime_config, ensure_ascii=False, separators=(",", ":")).replace("<", "\\u003c") + runtime_tag = ( + "" + ).format(runtime_json) if stylesheet_tags: head_marker = "" @@ -1396,9 +1567,11 @@ def inject_extension_tags(index_html: str) -> str: else: result = block + result - if script_tags: + if runtime_config["extensions"] or script_tags: body_marker = "
" - block = "\n".join(script_tags) + "\n" + block = runtime_tag + "\n" + if script_tags: + block += "\n".join(script_tags) + "\n" if body_marker in result: result = result.replace(body_marker, block + body_marker, 1) else: diff --git a/docs/EXTENSIONS.md b/docs/EXTENSIONS.md index 787f622fb..55543f39c 100644 --- a/docs/EXTENSIONS.md +++ b/docs/EXTENSIONS.md @@ -165,6 +165,65 @@ Loopback sidecars do **not** change asset injection behavior. They are only reported by diagnostics so an operator can see that a local companion service was declared and optionally check its health from the browser. +Extension entries may declare browser-local settings when they also request +extension-owned storage: + +```json +{ + "id": "desktop-companion", + "permissions": { + "storage": { + "owned": true + } + }, + "settings_schema": [ + { + "key": "show_badge", + "type": "boolean", + "label": "Show badge", + "default": true + }, + { + "key": "mode", + "type": "enum", + "label": "Mode", + "options": [ + {"value": "compact", "label": "Compact"}, + {"value": "full", "label": "Full"} + ], + "default": "compact" + } + ] +} +``` + +Settings are a first-pass browser feature. WebUI sanitizes the manifest schema, injects the accepted schema before extension scripts, and leaves persistence to the browser. The backend does not store extension settings or expose a generic settings write route, and it does not treat these values as secrets. + +The sanitizer accepts only `boolean`, `string`, `number`, `integer`, and `enum` +fields. It drops `sensitive: true` fields, unsupported types, malformed enum +options, duplicate keys after the first valid field, and defaults that do not +match the declared type. `settings_schema` is honored only when +`permissions.storage.owned` is exactly `true`. + +Extension scripts can use the sanctioned browser accessors: + +```js +const settings = window.HermesExtensionSettings.settingsForExtension("desktop-companion"); +const value = settings.get("show_badge"); +settings.set("show_badge", false); + +const storage = window.HermesExtensionSettings.storageForExtension("desktop-companion"); +storage.set("lastPanel", "settings"); + +const sameSettings = window.hermesExt.settings.forExtension("desktop-companion"); +const sameStorage = window.hermesExt.storage.forExtension("desktop-companion"); +``` + +Settings persist only non-default overrides. Resetting settings removes those +overrides and returns schema defaults. Extension-owned storage uses a separate +browser-local namespace, and clearing storage removes that namespace without +changing settings. + ## URL rules Injected asset URLs are deliberately restricted: @@ -552,3 +611,8 @@ allowlisted scalar fields such as `sidecar`, `native_host`, `bridge`, `last_seen_at`, and `webui_origin`. This keeps sidecar-specific diagnostics machine-readable without making WebUI depend on any one extension's private payload shape. + +When sanitized settings are present, Settings -> Extensions renders +browser-local controls for installed manifest entries. Save, reset, and clear +storage actions call `window.HermesExtensionSettings`; they do not call backend +storage routes and do not write WebUI settings. diff --git a/static/extension_settings.js b/static/extension_settings.js new file mode 100644 index 000000000..4918cf72f --- /dev/null +++ b/static/extension_settings.js @@ -0,0 +1,329 @@ +(function(){ + 'use strict'; + + const SETTINGS_PREFIX='hermes.ext.settings.'; + const STORAGE_PREFIX='hermes.ext.storage.'; + const FIELD_TYPES=new Set(['boolean','string','number','integer','enum']); + const schemas=new Map(); + const trustedExtensions=new Map(); + let trustedSeeded=false; + + function extensionId(value){ + return String(value||'').trim(); + } + + function namespaceForExtension(id){ + const clean=extensionId(id); + if(!clean) throw new Error('extensionId is required'); + return encodeURIComponent(clean); + } + + function settingsKey(id){ + return SETTINGS_PREFIX+namespaceForExtension(id); + } + + function storageKey(id){ + return STORAGE_PREFIX+namespaceForExtension(id); + } + + function text(value,fallback){ + const raw=typeof value==='string'?value.trim():''; + return raw||fallback||''; + } + + function enumOptions(options){ + if(!Array.isArray(options)||!options.length) return null; + const out=[]; + const seen=new Set(); + for(const option of options){ + let value=''; + let label=''; + if(typeof option==='string'){ + value=option.trim(); + label=value; + }else if(option&&typeof option==='object'&&typeof option.value==='string'){ + value=option.value.trim(); + label=text(option.label,value); + }else{ + return null; + } + if(!value||seen.has(value)) return null; + seen.add(value); + out.push({value,label}); + } + return out; + } + + function defaultFor(type,rawDefault,options){ + if(type==='boolean'){ + if(rawDefault===undefined) return {ok:true,value:false}; + return typeof rawDefault==='boolean'?{ok:true,value:rawDefault}:{ok:false}; + } + if(type==='string'){ + if(rawDefault===undefined) return {ok:true,value:''}; + return typeof rawDefault==='string'?{ok:true,value:rawDefault}:{ok:false}; + } + if(type==='number'){ + if(rawDefault===undefined) return {ok:true,value:0}; + return typeof rawDefault==='number'&&Number.isFinite(rawDefault)?{ok:true,value:rawDefault}:{ok:false}; + } + if(type==='integer'){ + if(rawDefault===undefined) return {ok:true,value:0}; + return Number.isInteger(rawDefault)?{ok:true,value:rawDefault}:{ok:false}; + } + if(type==='enum'&&options){ + if(rawDefault===undefined) return {ok:true,value:options[0].value}; + return typeof rawDefault==='string'&&options.some(option=>option.value===rawDefault)?{ok:true,value:rawDefault}:{ok:false}; + } + return {ok:false}; + } + + function normalizeSchema(rawSchema){ + const rawFields=Array.isArray(rawSchema)?rawSchema:(rawSchema&&Array.isArray(rawSchema.fields)?rawSchema.fields:[]); + const fields=[]; + const seen=new Set(); + for(const raw of rawFields){ + if(!raw||typeof raw!=='object'||raw.sensitive===true) continue; + const key=typeof raw.key==='string'?raw.key.trim():''; + const type=typeof raw.type==='string'?raw.type.trim().toLowerCase():''; + if(!/^[A-Za-z][A-Za-z0-9._-]{0,63}$/.test(key)||!FIELD_TYPES.has(type)||seen.has(key)) continue; + const options=type==='enum'?enumOptions(raw.options):null; + if(type==='enum'&&!options) continue; + const normalizedDefault=defaultFor(type,raw.default,options); + if(!normalizedDefault.ok) continue; + seen.add(key); + const field={key,type,label:text(raw.label,key),description:text(raw.description,''),default:normalizedDefault.value}; + if(options) field.options=options; + fields.push(field); + } + return fields; + } + + function normalizeSchemas(rawRows){ + const list=Array.isArray(rawRows)?rawRows:[]; + const entries=[]; + for(const entry of list){ + const id=extensionId(entry&&entry.id); + if(!id) continue; + const storageOwned=!!(entry&&entry.storage_owned); + entries.push({ + id, + name:text(entry&&entry.name,id), + storage_owned:storageOwned, + settings_schema:storageOwned?normalizeSchema(entry&&entry.settings_schema):[], + }); + } + return entries; + } + + function primeFromStatus(statusPayload){ + const entries=normalizeSchemas(statusPayload&&statusPayload.extensions); + if(!trustedSeeded){ + trustedExtensions.clear(); + for(const entry of entries){ + trustedExtensions.set(entry.id,{ + id:entry.id, + name:entry.name, + storage_owned:entry.storage_owned, + settings_schema:entry.settings_schema, + }); + } + trustedSeeded=true; + } + schemas.clear(); + for(const entry of entries){ + const trusted=trustedExtensions.get(entry.id); + if(!trusted) continue; + schemas.set(entry.id,{ + id:entry.id, + name:entry.name, + storage_owned:trusted.storage_owned===true, + settings_schema:trusted.storage_owned===true?trusted.settings_schema:[], + }); + } + } + + function safeReadState(key){ + try{ + const raw=window.localStorage.getItem(key); + if(!raw) return {value:{},malformed:false}; + const parsed=JSON.parse(raw); + return parsed&&typeof parsed==='object'&&!Array.isArray(parsed) + ?{value:parsed,malformed:false} + :{value:{},malformed:true}; + }catch(_e){ + return {value:{},malformed:true}; + } + } + + function safeRead(key){ + return safeReadState(key).value; + } + + function safeWrite(key,value){ + const keys=Object.keys(value||{}); + try{ + if(!keys.length) window.localStorage.removeItem(key); + else window.localStorage.setItem(key,JSON.stringify(value)); + return true; + }catch(_e){ + return false; + } + } + + function fieldMap(schema){ + const map=new Map(); + for(const field of schema) map.set(field.key,field); + return map; + } + + function validateValue(field,value){ + if(field.type==='boolean') return typeof value==='boolean'?{ok:true,value}:{ok:false}; + if(field.type==='string') return typeof value==='string'?{ok:true,value}:{ok:false}; + if(field.type==='number') return typeof value==='number'&&Number.isFinite(value)?{ok:true,value}:{ok:false}; + if(field.type==='integer') return Number.isInteger(value)?{ok:true,value}:{ok:false}; + if(field.type==='enum') return typeof value==='string'&&field.options.some(option=>option.value===value)?{ok:true,value}:{ok:false}; + return {ok:false}; + } + + function validate(schema,values){ + const input=values&&typeof values==='object'&&!Array.isArray(values)?values:{}; + const map=fieldMap(schema); + const normalized={}; + const errors={}; + for(const field of schema) normalized[field.key]=field.default; + for(const [key,value] of Object.entries(input)){ + const field=map.get(key); + if(!field) continue; + const checked=validateValue(field,value); + if(checked.ok) normalized[key]=checked.value; + else errors[key]='invalid'; + } + return {ok:Object.keys(errors).length===0,values:normalized,errors}; + } + + function defaultsFor(schema){ + const defaults={}; + for(const field of schema) defaults[field.key]=field.default; + return defaults; + } + + function overridesFromValues(schema,values){ + const overrides={}; + for(const field of schema){ + if(values[field.key]!==field.default) overrides[field.key]=values[field.key]; + } + return overrides; + } + + function readSettingsState(schema,key){ + const stored=safeReadState(key); + const checked=validate(schema,stored.value); + const overrides=overridesFromValues(schema,checked.values); + if(stored.malformed||JSON.stringify(stored.value)!==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); + } + + function settingsForExtension(id){ + const clean=extensionId(id); + const meta=schemas.get(clean)||{id:clean,name:clean,storage_owned:false,settings_schema:[]}; + const schema=supportsSettings(meta)?meta.settings_schema:[]; + const key=settingsKey(clean); + function current(){ + 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'}}; + const checked=validate(schema,values); + if(!checked.ok) return checked; + const saved=safeWrite(key,overridesFromValues(schema,checked.values)); + return {ok:saved,values:checked.values,errors:saved?{}:{storage:'unavailable'}}; + } + return { + extensionId:clean, + trusted:schemas.has(clean), + storageOwned:!!meta.storage_owned, + supported:supportsSettings(meta), + schema, + defaults:defaultsFor(schema), + get values(){return current();}, + get overrides(){return currentOverrides();}, + get(name){return current()[name];}, + validate(values){return validate(schema,values);}, + set(name,value){ + if(name&&typeof name==='object') return setAll(name); + const next=current(); + next[name]=value; + return setAll(next); + }, + setAll, + reset(){ + if(!supportsSettings(meta)) return current(); + safeWrite(key,{}); + return current(); + }, + clear(){ + if(!supportsSettings(meta)) return false; + safeWrite(key,{}); + return true; + }, + }; + } + + function storageForExtension(id){ + const clean=extensionId(id); + const meta=schemas.get(clean)||{id:clean,name:clean,storage_owned:false,settings_schema:[]}; + const allowed=!!meta.storage_owned; + const key=storageKey(clean); + return { + getAll(){return allowed?safeRead(key):{};}, + get(name,defaultValue){ + if(!allowed) return defaultValue; + const data=safeRead(key); + return Object.prototype.hasOwnProperty.call(data,name)?data[name]:defaultValue; + }, + set(name,value){ + if(!allowed) return false; + const data=safeRead(key); + data[name]=value; + return safeWrite(key,data); + }, + remove(name){ + if(!allowed) return false; + const data=safeRead(key); + delete data[name]; + return safeWrite(key,data); + }, + clear(){ + if(!allowed) return false; + safeWrite(key,{}); + return true; + }, + }; + } + + const api={ + normalizeSchemas, + primeFromStatus, + namespaceForExtension, + settingsForExtension, + storageForExtension, + resetSettingsForExtension(id){return settingsForExtension(id).reset();}, + clearStorageForExtension(id){return storageForExtension(id).clear();}, + }; + + window.HermesExtensionSettings=api; + window.hermesExt=window.hermesExt||{}; + window.hermesExt.settings=window.hermesExt.settings||{}; + window.hermesExt.storage=window.hermesExt.storage||{}; + window.hermesExt.settings.forExtension=settingsForExtension; + window.hermesExt.storage.forExtension=storageForExtension; + primeFromStatus(window.__HERMES_EXTENSION_CONFIG__||{}); +})(); diff --git a/static/index.html b/static/index.html index 13e92827e..39e5fa6a7 100644 --- a/static/index.html +++ b/static/index.html @@ -1425,7 +1425,7 @@
+ ${_extensionSettingsControls(entry)} `; @@ -8374,6 +8436,7 @@ function _renderExtensionsPanel(data,seq){ const copyBtn=$('extensionsCopyDiagnosticsBtn'); if(!target) return; _extensionsStatusData=data||null; + _configureExtensionSettingsFromStatus(data); if(copyBtn) copyBtn.disabled=!data; const manifest=(data&&data.manifest)||{}; const counts=(data&&data.counts)||{}; @@ -8454,6 +8517,7 @@ function _renderExtensionsPanel(data,seq){ `; _bindExtensionToggleButtons(target); + _bindExtensionSettingsButtons(target); _monitorExtensionSidecars(sidecars,seq); } @@ -8483,6 +8547,75 @@ async function handleExtensionToggle(btn){ } } +function _readExtensionSettingsForm(row){ + const values={}; + row.querySelectorAll('[data-extension-setting-input]').forEach(input=>{ + const key=input.dataset.extensionSettingInput||''; + const type=input.dataset.extensionSettingType||''; + if(!key) return; + if(type==='boolean') values[key]=!!input.checked; + else if(type==='integer') values[key]=Number.parseInt(input.value,10); + else if(type==='number') values[key]=Number.parseFloat(input.value); + else values[key]=input.value; + }); + return values; +} + +function _fillExtensionSettingsForm(row,id){ + if(!window.HermesExtensionSettings) return; + const values=window.HermesExtensionSettings.settingsForExtension(id).values; + row.querySelectorAll('[data-extension-setting-input]').forEach(input=>{ + const key=input.dataset.extensionSettingInput||''; + const type=input.dataset.extensionSettingType||''; + const value=values[key]; + if(type==='boolean') input.checked=!!value; + else input.value=value??''; + }); +} + +function _bindExtensionSettingsButtons(root){ + if(!root) return; + root.querySelectorAll('[data-extension-settings-save]').forEach(btn=>{ + btn.addEventListener('click',()=>handleExtensionSettingsSave(btn)); + }); + root.querySelectorAll('[data-extension-settings-reset]').forEach(btn=>{ + btn.addEventListener('click',()=>handleExtensionSettingsReset(btn)); + }); + root.querySelectorAll('[data-extension-storage-clear]').forEach(btn=>{ + btn.addEventListener('click',()=>handleExtensionStorageClear(btn)); + }); +} + +function handleExtensionSettingsSave(btn){ + const id=btn&&btn.dataset.extensionSettingsSave; + const row=btn&&btn.closest('[data-extension-id]'); + if(!id||!row||!window.HermesExtensionSettings) return; + const api=window.HermesExtensionSettings.settingsForExtension(id); + const result=api.setAll(_readExtensionSettingsForm(row)); + if(!result.ok){ + showToast('Extension settings contain invalid values.'); + return; + } + _fillExtensionSettingsForm(row,id); + showToast('Extension settings saved in this browser.'); +} + +function handleExtensionSettingsReset(btn){ + const id=btn&&btn.dataset.extensionSettingsReset; + const row=btn&&btn.closest('[data-extension-id]'); + if(!id||!row||!window.HermesExtensionSettings) return; + window.HermesExtensionSettings.settingsForExtension(id).reset(); + _fillExtensionSettingsForm(row,id); + showToast('Extension settings reset in this browser.'); +} + +function handleExtensionStorageClear(btn){ + const id=btn&&btn.dataset.extensionStorageClear; + if(!id||!window.HermesExtensionSettings) return; + window.HermesExtensionSettings.storageForExtension(id).clear(); + showToast('Extension storage cleared in this browser.'); +} + async function loadExtensionsPanel(opts){ const target=$('extensionsDiagnostics'); const copyBtn=$('extensionsCopyDiagnosticsBtn'); @@ -8702,6 +8835,7 @@ async function loadExtensionsGallery(){ function _renderExtensionsGallery(entries,statusData){ const galleryEl=$('extensionsGallery'); const installedEl=$('extensionsInstalled'); + _configureExtensionSettingsFromStatus(statusData); const installedIds=new Set(); if(statusData&&statusData.gallery_installed){ Object.keys(statusData.gallery_installed).forEach(id=>installedIds.add(id)); @@ -8711,11 +8845,14 @@ function _renderExtensionsGallery(entries,statusData){ } if(!Array.isArray(entries)||entries.length===0){ if(galleryEl) galleryEl.innerHTML='
'; - if(installedEl) installedEl.innerHTML='
'; + if(installedEl){ + installedEl.innerHTML=_extensionInstalledList(statusData&&statusData.extensions,!!(statusData&&statusData.extension_dir_configured)); + _bindExtensionToggleButtons(installedEl); + _bindExtensionSettingsButtons(installedEl); + } return; } const galleryCards=[]; - const installedCards=[]; for(const entry of entries){ const id=esc(String(entry.id||'')); const name=esc(String(entry.name||entry.id||'')); @@ -8754,10 +8891,13 @@ function _renderExtensionsGallery(entries,statusData){
`; galleryCards.push(card); - if(isInstalled) installedCards.push(card); } if(galleryEl) galleryEl.innerHTML=galleryCards.length?galleryCards.join(''):'
'; - if(installedEl) installedEl.innerHTML=installedCards.length?installedCards.join(''):'
'; + if(installedEl){ + installedEl.innerHTML=_extensionInstalledList(statusData&&statusData.extensions,!!(statusData&&statusData.extension_dir_configured)); + _bindExtensionToggleButtons(installedEl); + _bindExtensionSettingsButtons(installedEl); + } _bindExtensionGalleryButtons(entries); } diff --git a/static/style.css b/static/style.css index 377a41657..a3e7ac646 100644 --- a/static/style.css +++ b/static/style.css @@ -5109,6 +5109,16 @@ main.main > #mainPlugin{display:none;} .extension-installed-meta code{font-family:var(--font-mono);font-size:11px;color:var(--text);overflow-wrap:anywhere;word-break:break-word;} .extension-toggle-btn{white-space:nowrap;flex:0 0 auto;} .extension-toggle-btn:disabled{opacity:.55;cursor:not-allowed;} +.extension-settings-box{margin-top:8px;padding:9px 10px;border:1px solid var(--border);border-radius:8px;background:var(--card-bg,var(--sidebar-bg));} +.extension-settings-head{display:flex;align-items:flex-start;justify-content:space-between;gap:10px;margin-bottom:8px;} +.extension-settings-title{font-size:12px;font-weight:700;color:var(--text);} +.extension-settings-note,.extension-settings-empty,.extension-setting-desc{font-size:11px;color:var(--muted);line-height:1.4;} +.extension-settings-fields{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:8px;} +.extension-setting-field label{display:flex;flex-direction:column;gap:4px;font-size:11px;font-weight:600;color:var(--muted);} +.extension-setting-field input,.extension-setting-field select{min-height:30px;border:1px solid var(--border2);border-radius:7px;background:var(--bg);color:var(--text);padding:4px 8px;font-size:12px;} +.extension-setting-check{flex-direction:row!important;align-items:center;font-size:12px!important;color:var(--text)!important;} +.extension-setting-check input{min-height:auto;padding:0;} +.extension-settings-actions{display:flex;gap:6px;flex-wrap:wrap;margin-top:9px;} .extension-sidecar-list{display:flex;flex-direction:column;gap:8px;} .extension-sidecar-row{padding:9px 10px;border:1px solid var(--border);border-radius:8px;background:var(--surface);min-width:0;} .extension-sidecar-row-head{display:flex;align-items:center;justify-content:space-between;gap:10px;min-width:0;} diff --git a/tests/test_extension_hooks.py b/tests/test_extension_hooks.py index 5edd2de45..515b5442a 100644 --- a/tests/test_extension_hooks.py +++ b/tests/test_extension_hooks.py @@ -126,6 +126,71 @@ def test_index_html_injection_escapes_urls_and_preserves_disabled_default(tmp_pa assert injected.index("/extensions/app.js") < injected.index("") +def test_extension_settings_runtime_config_injects_before_extension_scripts(tmp_path, monkeypatch): + root = tmp_path / "extensions" + root.mkdir() + (root / "manifest.json").write_text( + """ + { + "extensions": [ + { + "id": "settings-ok", + "scripts": ["settings-ok.js"], + "permissions": {"storage": {"owned": true}}, + "settings_schema": [ + {"key": "flag", "type": "boolean", "default": true} + ] + } + ] + } + """, + encoding="utf-8", + ) + monkeypatch.setenv("HERMES_WEBUI_EXTENSION_DIR", str(root)) + monkeypatch.setenv("HERMES_WEBUI_EXTENSION_MANIFEST", "manifest.json") + + from api.extensions import inject_extension_tags + + injected = inject_extension_tags("