diff --git a/CHANGELOG.md b/CHANGELOG.md index 909b171a..caf237ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -509,6 +509,8 @@ ### Added +- **Extensions can register a native "Configure" entry point under Settings → Extensions → Installed.** For complex editors that don't fit scalar `settings_schema` fields, a boot-trusted extension can call `ext.settings.registerConfigure(handler)` on the E0 handle to add one native Configure button for its current effective-enabled Installed row, while keeping the editor UI, validation, state, and persistence fully extension-owned. Registration is trust-gated and quarantine-aware, the first handler per extension wins (duplicates return `null`), and it returns an idempotent unregister; the button never appears in Diagnostics and is removed immediately on disable/uninstall. Thanks @franksong2702. (#7111) + - **Each settled assistant turn's footer now shows the model that actually served it.** In transparent-stream mode the turn footer gains a compact model chip (e.g. `8s · claude-opus-4-8 · TTFT 640ms · …`), read *after* the turn completes so a mid-turn fallback shows the real model that answered rather than the one originally requested. The label renders exactly once per turn — a gateway/failover turn keeps its existing routing chip and the additive chip is suppressed — and the footer wraps gracefully at narrow widths and on mobile instead of stretching. The used-model is persisted with the session so it survives a reload. Thanks @franksong2702. (#6113, #6068) ### Performance diff --git a/docs/EXTENSIONS.md b/docs/EXTENSIONS.md index 7d7c8f0b..3b74e954 100644 --- a/docs/EXTENSIONS.md +++ b/docs/EXTENSIONS.md @@ -300,6 +300,48 @@ sandbox, isolation mechanism, capability or permission system, or security boundary. Extensions still execute with the full WebUI session authority described above. +### Custom Configure editors + +Extensions with structured configuration that does not fit scalar +`settings_schema` fields can register one custom editor entry point on their +scoped E0 settings handle: + +```js +const ext = window.hermesExt?.register?.("dictionary-manager"); +const unregister = ext?.settings?.registerConfigure?.(({ opener, restoreFocus }) => { + openDictionaryManager({ opener, restoreFocus }); +}); +``` + +`registerConfigure(handler)` is available only through a valid boot-trusted E0 +handle. It does not require `settings_schema` or extension-owned storage. The +first handler registered for an extension wins; duplicates return `null` and do +not replace it. A successful registration returns an idempotent unregister +function. + +Core shows one **Configure** button only for the extension's current +effective-enabled row under **Settings → Extensions → Installed**. Diagnostics +never shows the button. Late registration updates an already-mounted Installed +row. Disable or uninstall status removes the entry point immediately; uninstall +followed by a same-ID reinstall cannot revive the old page-local handler until a +full WebUI reload injects and registers the new extension script. + +Core enters a pending state before invoking the handler and passes the activated +button as `opener` plus a one-shot `restoreFocus()` callback. The extension owns +its dialog, validation, persistence, and close lifecycle. It must either call +`restoreFocus()` when its UI closes or return a thenable whose settlement means +the Configure UI is closed. Both paths end pending and converge on Core's +one-shot focus restoration. A synchronous non-thenable handler remains disabled +while unsettled. If it never calls `restoreFocus()`, it remains disabled until reload. +Core does not guess dialog lifetime with a timeout. + +Synchronous throws, thenable-access failures, and asynchronous rejections are +isolated, logged with the extension ID, and surfaced as a generic failure. Focus +returns to the connected opener when possible, otherwise to the current visible +Configure button or the Installed tab without forcing navigation. The hook does +not let an extension return DOM for Core to render, add a backend settings route, +or change the trusted same-origin extension model. + ### Turn lifecycle events A registered extension can react when a session turn observed by the current page diff --git a/static/extension_settings.js b/static/extension_settings.js index cd49f3d5..0d9cced8 100644 --- a/static/extension_settings.js +++ b/static/extension_settings.js @@ -11,7 +11,12 @@ const registrations=new Map(); const turnLifecycleListeners=new Map(); const turnLifecycleStates=new Map(); + const configureRegistrations=new Map(); + const configureQuarantinedIds=new Set(); + const configureChangeListeners=new Set(); + const currentExtensionStatus=new Map(); let trustedSeeded=false; + let extensionStatusSeeded=false; function extensionId(value){ return String(value||'').trim(); @@ -116,6 +121,7 @@ name:text(entry&&entry.name,id), storage_owned:storageOwned, settings_schema:storageOwned?normalizeSchema(entry&&entry.settings_schema):[], + effective_enabled:!(entry&&entry.effective_enabled===false), }); } return entries; @@ -135,6 +141,20 @@ } trustedSeeded=true; } + const previousStatus=new Map(currentExtensionStatus); + const nextIds=new Set(entries.map(entry=>entry.id)); + if(extensionStatusSeeded){ + for(const id of previousStatus.keys()){ + if(nextIds.has(id)) continue; + configureQuarantinedIds.add(id); + notifyConfigureChange(id,'quarantine'); + } + } + currentExtensionStatus.clear(); + for(const entry of entries){ + currentExtensionStatus.set(entry.id,{effective_enabled:entry.effective_enabled===true}); + } + extensionStatusSeeded=true; schemas.clear(); for(const entry of entries){ const trusted=trustedExtensions.get(entry.id); @@ -146,6 +166,14 @@ settings_schema:trusted.storage_owned===true?trusted.settings_schema:[], }); } + for(const [id,record] of configureRegistrations){ + if(!record||!record.active) continue; + const before=previousStatus.get(id); + const after=currentExtensionStatus.get(id); + if(!before||!after||before.effective_enabled!==after.effective_enabled){ + notifyConfigureChange(id,'status'); + } + } } function safeReadState(key){ @@ -233,7 +261,7 @@ return !!(meta&&meta.storage_owned&&Array.isArray(meta.settings_schema)&&meta.settings_schema.length); } - function settingsAccessor(clean,meta,isTrusted){ + function settingsAccessor(clean,meta,isTrusted,allowConfigureRegistration){ const schema=supportsSettings(meta)?meta.settings_schema:[]; const key=settingsKey(clean); function current(){ @@ -249,7 +277,7 @@ const saved=safeWrite(key,overridesFromValues(schema,checked.values)); return {ok:saved,values:checked.values,errors:saved?{}:{storage:'unavailable'}}; } - return { + const accessor={ extensionId:clean, trusted:isTrusted, storageOwned:!!meta.storage_owned, @@ -278,6 +306,10 @@ return true; }, }; + if(allowConfigureRegistration===true){ + accessor.registerConfigure=handler=>registerConfigureHandler(clean,handler); + } + return accessor; } function settingsForExtension(id){ @@ -350,6 +382,165 @@ }); } + function notifyConfigureChange(id,reason){ + const change=Object.freeze({id,reason}); + for(const listener of [...configureChangeListeners]){ + try{ + listener(change); + }catch(error){ + if(typeof console!=='undefined'&&typeof console.error==='function'){ + try{console.error('[Hermes extensions] Configure change listener failed:',error);}catch(_loggingError){} + } + } + } + } + + function onConfigureChange(listener){ + if(typeof listener!=='function') return null; + configureChangeListeners.add(listener); + let active=true; + return function unsubscribe(){ + if(!active) return false; + active=false; + configureChangeListeners.delete(listener); + return true; + }; + } + + function registerConfigureHandler(clean,handler){ + if(typeof handler!=='function'||!trustedExtensions.has(clean)||configureQuarantinedIds.has(clean)) return null; + if(configureRegistrations.has(clean)) return null; + const record={handler,pending:false,active:true}; + configureRegistrations.set(clean,record); + notifyConfigureChange(clean,'registration'); + let active=true; + return function unregister(){ + if(!active) return false; + active=false; + record.active=false; + if(configureRegistrations.get(clean)===record) configureRegistrations.delete(clean); + notifyConfigureChange(clean,'registration'); + return true; + }; + } + + function configureStateForExtension(id){ + const clean=extensionId(id); + const record=configureRegistrations.get(clean); + const status=currentExtensionStatus.get(clean); + const available=!!( + clean&&record&&record.active&&!configureQuarantinedIds.has(clean) + &&status&&status.effective_enabled===true + ); + return {available,pending:available&&record.pending===true}; + } + + function focusableConfigureTarget(node){ + if(!node||typeof node.focus!=='function'||node.isConnected===false||node.hidden===true||node.disabled===true) return false; + if(typeof node.getAttribute==='function'&&node.getAttribute('aria-disabled')==='true') return false; + if(typeof node.closest==='function'&&node.closest('[hidden]')) return false; + return true; + } + + function focusConfigureTarget(clean,opener){ + const candidates=[]; + if(focusableConfigureTarget(opener)) candidates.push(opener); + if(typeof document!=='undefined'&&document){ + if(typeof document.querySelectorAll==='function'){ + const buttons=document.querySelectorAll('[data-extension-configure-id]'); + for(const button of buttons){ + if(button&&button.dataset&&button.dataset.extensionConfigureId===clean&&focusableConfigureTarget(button)){ + candidates.push(button); + break; + } + } + } + if(typeof document.querySelector==='function'){ + const installedTab=document.querySelector('[data-extensions-tab="installed"]'); + if(focusableConfigureTarget(installedTab)) candidates.push(installedTab); + } + } + for(const candidate of candidates){ + try{ + candidate.focus({preventScroll:true}); + return true; + }catch(_focusOptionsError){ + try{ + candidate.focus(); + return true; + }catch(_focusError){} + } + } + return false; + } + + function reportConfigureFailure(clean,error,onError){ + if(typeof console!=='undefined'&&typeof console.error==='function'){ + try{console.error(`[Hermes extensions] ${clean} Configure handler failed:`,error);}catch(_loggingError){} + } + if(typeof onError==='function'){ + try{onError(error);}catch(callbackError){ + if(typeof console!=='undefined'&&typeof console.error==='function'){ + try{console.error(`[Hermes extensions] ${clean} Configure failure reporter failed:`,callbackError);}catch(_loggingError){} + } + } + } + } + + function invokeConfigure(id,options){ + const clean=extensionId(id); + const state=configureStateForExtension(clean); + const record=configureRegistrations.get(clean); + if(!state.available||state.pending||!record) return false; + const opener=options&&options.opener; + const onError=options&&options.onError; + let settled=false; + let failureReported=false; + record.pending=true; + notifyConfigureChange(clean,'pending'); + + function settle(){ + if(settled) return false; + settled=true; + record.pending=false; + notifyConfigureChange(clean,'pending'); + focusConfigureTarget(clean,opener); + return true; + } + + function fail(error){ + if(!failureReported){ + failureReported=true; + reportConfigureFailure(clean,error,onError); + } + settle(); + } + + let result; + try{ + result=record.handler(Object.freeze({opener,restoreFocus:settle})); + }catch(error){ + fail(error); + return true; + } + + let then; + try{ + then=result!==null&&(typeof result==='object'||typeof result==='function')?result.then:null; + }catch(error){ + fail(error); + return true; + } + if(typeof then==='function'){ + try{ + then.call(result,settle,fail); + }catch(error){ + fail(error); + } + } + return true; + } + function turnLifecycleKey(sessionId,streamId){ return `${sessionId}\u0000${streamId}`; } @@ -416,7 +607,7 @@ if(!trusted) return null; const handle=Object.freeze({ id:clean, - settings:settingsAccessor(clean,trusted,true), + settings:settingsAccessor(clean,trusted,true,true), storage:storageAccessor(clean,trusted), events:eventAccessor(clean), }); @@ -431,6 +622,9 @@ settingsForExtension, storageForExtension, _dispatchTurnLifecycle:dispatchTurnLifecycle, + _configureStateForExtension:configureStateForExtension, + _invokeConfigure:invokeConfigure, + _onConfigureChange:onConfigureChange, resetSettingsForExtension(id){return settingsForExtension(id).reset();}, clearStorageForExtension(id){return storageForExtension(id).clear();}, }; diff --git a/static/panels.js b/static/panels.js index b00c8937..95b6ce7c 100644 --- a/static/panels.js +++ b/static/panels.js @@ -9881,7 +9881,18 @@ function _extensionSettingsControls(entry){ `; } -function _extensionInstalledList(extensions,extensionDirConfigured){ +function _extensionConfigureButton(entry,surface){ + if(surface!=='installed'||!(entry&&entry.effective_enabled)) return ''; + const id=(entry&&entry.id)||''; + const runtime=window.HermesExtensionSettings; + if(!id||!runtime||typeof runtime._configureStateForExtension!=='function') return ''; + const state=runtime._configureStateForExtension(id); + if(!state||!state.available) return ''; + const pending=state.pending===true; + return ``; +} + +function _extensionInstalledList(extensions,extensionDirConfigured,surface){ const list=Array.isArray(extensions)?extensions:[]; if(!list.length){ if(!extensionDirConfigured) return '
No extension directory is configured.
'; @@ -9898,6 +9909,7 @@ function _extensionInstalledList(extensions,extensionDirConfigured){ const note=canToggle ? 'Toggles the WebUI-managed override for the next app load.' : 'Manifest-disabled entries cannot be enabled from WebUI.'; + const configureButton=_extensionConfigureButton(entry,surface); return `
@@ -9907,7 +9919,10 @@ function _extensionInstalledList(extensions,extensionDirConfigured){
${esc(id)}${esc(note)}
${_extensionSettingsControls(entry)}
- +
+ ${configureButton} + +
`; }).join('')}
`; } @@ -10115,6 +10130,7 @@ function _renderExtensionsPanel(data,seq){ const copyBtn=$('extensionsCopyDiagnosticsBtn'); if(!target) return; _extensionsStatusData=data||null; + if(_extensionsGalleryData) _extensionsGalleryData.statusData=data||null; _configureExtensionSettingsFromStatus(data); if(copyBtn) copyBtn.disabled=!data; const manifest=(data&&data.manifest)||{}; @@ -10165,7 +10181,7 @@ function _renderExtensionsPanel(data,seq){
- ${_extensionInstalledList(extensions,!!(data&&data.extension_dir_configured))} + ${_extensionInstalledList(extensions,!!(data&&data.extension_dir_configured),'diagnostics')}
@@ -10201,6 +10217,37 @@ function _renderExtensionsPanel(data,seq){ _monitorExtensionSidecars(sidecars,seq); } +function _bindExtensionConfigureButtons(root){ + if(!root) return; + root.querySelectorAll('[data-extension-configure-id]').forEach(btn=>{ + btn.addEventListener('click',()=>handleExtensionConfigure(btn)); + }); +} + +function _syncExtensionConfigureButtonState(id){ + const runtime=window.HermesExtensionSettings; + if(!runtime||typeof runtime._configureStateForExtension!=='function') return; + const state=runtime._configureStateForExtension(id); + document.querySelectorAll('[data-extension-configure-id]').forEach(btn=>{ + if(!btn.dataset||btn.dataset.extensionConfigureId!==id) return; + const pending=!!(state&&state.available&&state.pending); + btn.disabled=pending; + btn.setAttribute('aria-busy',pending?'true':'false'); + btn.textContent=pending?'Opening…':'Configure'; + }); +} + +function handleExtensionConfigure(btn){ + if(!btn||btn.disabled) return; + const id=btn.dataset.extensionConfigureId||''; + const runtime=window.HermesExtensionSettings; + if(!id||!runtime||typeof runtime._invokeConfigure!=='function') return; + runtime._invokeConfigure(id,{ + opener:btn, + onError:()=>showToast('Extension configuration failed.',4200,'error'), + }); +} + function _bindExtensionToggleButtons(root){ if(!root) return; root.querySelectorAll('[data-extension-toggle-id]').forEach(btn=>{ @@ -10362,6 +10409,21 @@ function switchExtensionsTab(tab){ if(tab==='gallery'&&!_extensionsGalleryLoaded) loadExtensionsGallery(); } +function _handleExtensionConfigureChange(change){ + if(!change||!change.id) return; + if(change.reason==='pending'){ + _syncExtensionConfigureButtonState(change.id); + return; + } + if(_extensionsGalleryData&&_extensionsGalleryData.statusData){ + _renderInstalledExtensionsSurface(_extensionsGalleryData.statusData); + } +} + +if(window.HermesExtensionSettings&&typeof window.HermesExtensionSettings._onConfigureChange==='function'){ + window.HermesExtensionSettings._onConfigureChange(_handleExtensionConfigureChange); +} + function _extensionSafeHttpUrl(value){ if(!value) return ''; const raw=String(value).trim(); @@ -10517,6 +10579,19 @@ function _extensionPostInstallNote(entry,isInstalled){
`; } +function _renderInstalledExtensionsSurface(statusData){ + const installedEl=$('extensionsInstalled'); + if(!installedEl) return; + installedEl.innerHTML=_extensionInstalledList( + statusData&&statusData.extensions, + !!(statusData&&statusData.extension_dir_configured), + 'installed' + ); + _bindExtensionToggleButtons(installedEl); + _bindExtensionSettingsButtons(installedEl); + _bindExtensionConfigureButtons(installedEl); +} + async function loadExtensionsGallery(){ _extensionsGalleryLoaded=true; const galleryEl=$('extensionsGallery'); @@ -10540,7 +10615,6 @@ async function loadExtensionsGallery(){ function _renderExtensionsGallery(entries,statusData){ const galleryEl=$('extensionsGallery'); - const installedEl=$('extensionsInstalled'); _configureExtensionSettingsFromStatus(statusData); const installedIds=new Set(); if(statusData&&statusData.gallery_installed){ @@ -10551,11 +10625,7 @@ function _renderExtensionsGallery(entries,statusData){ } if(!Array.isArray(entries)||entries.length===0){ if(galleryEl) galleryEl.innerHTML='
No extensions found in the registry.
'; - if(installedEl){ - installedEl.innerHTML=_extensionInstalledList(statusData&&statusData.extensions,!!(statusData&&statusData.extension_dir_configured)); - _bindExtensionToggleButtons(installedEl); - _bindExtensionSettingsButtons(installedEl); - } + _renderInstalledExtensionsSurface(statusData); return; } const galleryCards=[]; @@ -10599,11 +10669,7 @@ function _renderExtensionsGallery(entries,statusData){ galleryCards.push(card); } if(galleryEl) galleryEl.innerHTML=galleryCards.length?galleryCards.join(''):'
No extensions found.
'; - if(installedEl){ - installedEl.innerHTML=_extensionInstalledList(statusData&&statusData.extensions,!!(statusData&&statusData.extension_dir_configured)); - _bindExtensionToggleButtons(installedEl); - _bindExtensionSettingsButtons(installedEl); - } + _renderInstalledExtensionsSurface(statusData); _bindExtensionGalleryButtons(entries); } diff --git a/static/style.css b/static/style.css index 86dbcfd2..98f6b7f0 100644 --- a/static/style.css +++ b/static/style.css @@ -5491,8 +5491,9 @@ main.main > #mainPlugin{display:none;} .extension-installed-title{font-size:13px;font-weight:600;color:var(--text);overflow-wrap:anywhere;min-width:0;} .extension-installed-meta{display:flex;align-items:center;gap:8px;flex-wrap:wrap;font-size:11px;color:var(--muted);} .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-installed-actions{display:flex;align-items:center;justify-content:flex-end;gap:6px;flex:0 0 auto;flex-wrap:wrap;} +.extension-toggle-btn,.extension-configure-btn{white-space:nowrap;flex:0 0 auto;} +.extension-toggle-btn:disabled,.extension-configure-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);} @@ -5565,7 +5566,8 @@ main.main > #mainPlugin{display:none;} .extensions-copy-diagnostics-btn{width:100%;} .extension-summary-grid{grid-template-columns:1fr;} .extension-installed-row{align-items:flex-start;flex-direction:column;} - .extension-toggle-btn{width:100%;} + .extension-installed-actions{width:100%;} + .extension-installed-actions .extension-toggle-btn,.extension-installed-actions .extension-configure-btn{width:auto;flex:1 1 140px;} .extension-sidecar-row-head{align-items:flex-start;flex-direction:column;} .extension-sidecar-fields>div{flex-direction:column;gap:2px;} .extension-sidecar-fields span{flex:0 0 auto;} diff --git a/tests/test_extension_settings_runtime.py b/tests/test_extension_settings_runtime.py index fac5fef0..74f54f8b 100644 --- a/tests/test_extension_settings_runtime.py +++ b/tests/test_extension_settings_runtime.py @@ -275,3 +275,255 @@ def test_hermes_ext_register_runtime_identity_and_reload_lifecycle(): """ ) _run_node(script) + + +def test_hermes_ext_configure_registration_invocation_and_quarantine(): + script = textwrap.dedent( + f""" + const fs = require('fs'); + const assert = require('assert'); + const store = new Map(); + const diagnostics = []; + const changes = []; + const replacementButton = {{ + dataset: {{extensionConfigureId: 'alpha.ext'}}, + isConnected: true, + disabled: false, + hidden: false, + focusCount: 0, + getAttribute(name) {{ return name === 'aria-disabled' ? 'false' : null; }}, + closest() {{ return null; }}, + focus() {{ this.focusCount += 1; }}, + }}; + const installedTab = {{ + isConnected: true, + disabled: false, + hidden: false, + focusCount: 0, + getAttribute() {{ return null; }}, + closest() {{ return null; }}, + focus() {{ this.focusCount += 1; }}, + }}; + global.document = {{ + querySelectorAll(selector) {{ + return selector === '[data-extension-configure-id]' ? [replacementButton] : []; + }}, + querySelector(selector) {{ + return selector === '[data-extensions-tab="installed"]' ? installedTab : null; + }}, + }}; + global.window = {{ + __HERMES_EXTENSION_CONFIG__: {{ + extensions: [{{id: 'alpha.ext', name: 'Alpha'}}, {{id: 'beta.ext', name: 'Beta'}}] + }}, + localStorage: {{ + getItem(key) {{ return store.has(key) ? store.get(key) : null; }}, + setItem(key, value) {{ store.set(key, String(value)); }}, + removeItem(key) {{ store.delete(key); }} + }} + }}; + const originalConsoleError = console.error; + console.error = (...args) => diagnostics.push(args); + + (async () => {{ + eval(fs.readFileSync({str(EXTENSION_SETTINGS_JS)!r}, 'utf8')); + const runtime = window.HermesExtensionSettings; + const alpha = window.hermesExt.register('alpha.ext'); + const beta = window.hermesExt.register('beta.ext'); + assert.ok(alpha); + assert.ok(beta); + assert.strictEqual(typeof alpha.settings.registerConfigure, 'function'); + assert.strictEqual( + window.HermesExtensionSettings.settingsForExtension('alpha.ext').registerConfigure, + undefined, + 'legacy global accessors must not gain registration authority' + ); + assert.strictEqual(window.hermesExt.register('unknown.ext'), null); + + const stopChanges = runtime._onConfigureChange(change => changes.push(change)); + let alphaCalls = 0; + let pendingInsideHandler = false; + const unregisterAlpha = alpha.settings.registerConfigure(({{opener, restoreFocus}}) => {{ + alphaCalls += 1; + pendingInsideHandler = runtime._configureStateForExtension('alpha.ext').pending; + assert.strictEqual(opener.extensionId, 'alpha.ext'); + restoreFocus(); + restoreFocus(); + }}); + assert.strictEqual(typeof unregisterAlpha, 'function'); + assert.strictEqual(alpha.settings.registerConfigure(() => {{}}), null); + assert.deepStrictEqual(runtime._configureStateForExtension('alpha.ext'), {{available: true, pending: false}}); + assert.ok(changes.some(change => change.id === 'alpha.ext' && change.reason === 'registration')); + + const connectedOpener = {{ + extensionId: 'alpha.ext', + isConnected: true, + disabled: false, + hidden: false, + focusCount: 0, + getAttribute() {{ return null; }}, + closest() {{ return null; }}, + focus() {{ this.focusCount += 1; }}, + }}; + assert.strictEqual(runtime._invokeConfigure('alpha.ext', {{opener: connectedOpener}}), true); + assert.strictEqual(alphaCalls, 1); + assert.strictEqual(pendingInsideHandler, true); + assert.strictEqual(connectedOpener.focusCount, 1); + assert.deepStrictEqual(runtime._configureStateForExtension('alpha.ext'), {{available: true, pending: false}}); + + assert.strictEqual(unregisterAlpha(), true); + assert.strictEqual(unregisterAlpha(), false); + assert.deepStrictEqual(runtime._configureStateForExtension('alpha.ext'), {{available: false, pending: false}}); + + let releasePending; + const unregisterPending = alpha.settings.registerConfigure(() => new Promise(resolve => {{ releasePending = resolve; }})); + assert.strictEqual(runtime._invokeConfigure('alpha.ext', {{opener: connectedOpener}}), true); + assert.strictEqual(runtime._invokeConfigure('alpha.ext', {{opener: connectedOpener}}), false); + assert.strictEqual(runtime._configureStateForExtension('alpha.ext').pending, true); + releasePending(); + await Promise.resolve(); + await Promise.resolve(); + assert.strictEqual(runtime._configureStateForExtension('alpha.ext').pending, false); + assert.strictEqual(connectedOpener.focusCount, 2); + assert.strictEqual(unregisterPending(), true); + + let restoreExplicit; + const unregisterExplicit = alpha.settings.registerConfigure(({{restoreFocus}}) => {{ + restoreExplicit = restoreFocus; + }}); + assert.strictEqual(runtime._invokeConfigure('alpha.ext', {{opener: connectedOpener}}), true); + assert.strictEqual(runtime._configureStateForExtension('alpha.ext').pending, true); + assert.strictEqual(restoreExplicit(), true); + assert.strictEqual(restoreExplicit(), false); + assert.strictEqual(runtime._configureStateForExtension('alpha.ext').pending, false); + assert.strictEqual(connectedOpener.focusCount, 3); + assert.strictEqual(unregisterExplicit(), true); + + const unregisterMissing = alpha.settings.registerConfigure(() => undefined); + assert.strictEqual(runtime._invokeConfigure('alpha.ext', {{opener: connectedOpener}}), true); + assert.strictEqual(runtime._configureStateForExtension('alpha.ext').pending, true); + assert.strictEqual(runtime._invokeConfigure('alpha.ext', {{opener: connectedOpener}}), false); + assert.strictEqual(connectedOpener.focusCount, 3); + assert.strictEqual(unregisterMissing(), true); + + const reported = []; + const unregisterThrowing = alpha.settings.registerConfigure(() => {{ throw new Error('sync boom'); }}); + assert.strictEqual(runtime._invokeConfigure('alpha.ext', {{opener: connectedOpener, onError: error => reported.push(error.message)}}), true); + assert.deepStrictEqual(reported, ['sync boom']); + assert.strictEqual(runtime._configureStateForExtension('alpha.ext').pending, false); + assert.ok(diagnostics.some(args => String(args[0]).includes('alpha.ext') && String(args[0]).includes('Configure handler failed'))); + assert.strictEqual(unregisterThrowing(), true); + + const rejected = []; + const unregisterRejected = alpha.settings.registerConfigure(() => Promise.reject(new Error('async boom'))); + assert.strictEqual(runtime._invokeConfigure('alpha.ext', {{opener: connectedOpener, onError: error => rejected.push(error.message)}}), true); + await Promise.resolve(); + await Promise.resolve(); + assert.deepStrictEqual(rejected, ['async boom']); + assert.strictEqual(runtime._configureStateForExtension('alpha.ext').pending, false); + assert.strictEqual(unregisterRejected(), true); + + const assimilated = []; + const unregisterBadThenable = alpha.settings.registerConfigure(() => Object.defineProperty({{}}, 'then', {{ + get() {{ throw new Error('then getter boom'); }} + }})); + assert.strictEqual(runtime._invokeConfigure('alpha.ext', {{opener: connectedOpener, onError: error => assimilated.push(error.message)}}), true); + assert.deepStrictEqual(assimilated, ['then getter boom']); + assert.strictEqual(runtime._configureStateForExtension('alpha.ext').pending, false); + assert.strictEqual(unregisterBadThenable(), true); + + const detachedOpener = {{ + extensionId: 'alpha.ext', + isConnected: false, + disabled: false, + hidden: false, + focusCount: 0, + getAttribute() {{ return null; }}, + closest() {{ return null; }}, + focus() {{ this.focusCount += 1; }}, + }}; + const unregisterDetached = alpha.settings.registerConfigure(({{restoreFocus}}) => restoreFocus()); + assert.strictEqual(runtime._invokeConfigure('alpha.ext', {{opener: detachedOpener}}), true); + assert.strictEqual(detachedOpener.focusCount, 0); + assert.strictEqual(replacementButton.focusCount, 1); + assert.strictEqual(installedTab.focusCount, 0); + + let betaCalls = 0; + const unregisterBeta = beta.settings.registerConfigure(({{restoreFocus}}) => {{ betaCalls += 1; restoreFocus(); }}); + runtime.primeFromStatus({{extensions: [ + {{id: 'alpha.ext', name: 'Alpha', effective_enabled: false}}, + {{id: 'beta.ext', name: 'Beta', effective_enabled: true}} + ]}}); + assert.deepStrictEqual(runtime._configureStateForExtension('alpha.ext'), {{available: false, pending: false}}); + assert.strictEqual(runtime._invokeConfigure('alpha.ext', {{opener: connectedOpener}}), false); + assert.strictEqual(runtime._invokeConfigure('beta.ext', {{opener: connectedOpener}}), true); + assert.strictEqual(betaCalls, 1, 'another extension remains usable after alpha failures'); + + runtime.primeFromStatus({{extensions: [{{id: 'beta.ext', name: 'Beta', effective_enabled: true}}]}}); + assert.deepStrictEqual(runtime._configureStateForExtension('alpha.ext'), {{available: false, pending: false}}); + assert.strictEqual(runtime._invokeConfigure('alpha.ext', {{opener: connectedOpener}}), false); + assert.strictEqual(unregisterDetached(), true); + assert.strictEqual(alpha.settings.registerConfigure(() => {{}}), null, 'uninstalled IDs stay quarantined'); + + runtime.primeFromStatus({{extensions: [ + {{id: 'alpha.ext', name: 'Alpha reinstalled', effective_enabled: true}}, + {{id: 'beta.ext', name: 'Beta', effective_enabled: true}} + ]}}); + assert.deepStrictEqual(runtime._configureStateForExtension('alpha.ext'), {{available: false, pending: false}}); + assert.strictEqual(runtime._invokeConfigure('alpha.ext', {{opener: connectedOpener}}), false); + assert.strictEqual(unregisterBeta(), true); + assert.strictEqual(stopChanges(), true); + assert.strictEqual(stopChanges(), false); + }})().then( + () => {{ console.error = originalConsoleError; }}, + error => {{ console.error = originalConsoleError; throw error; }} + ); + """ + ) + _run_node(script) + + +def test_documented_configure_consumer_is_safe_on_older_core_without_register_configure(): + """The EXTENSIONS.md consumer pattern must not throw on an OLDER E0 Core + build where ``register()`` and ``settings`` exist but ``registerConfigure`` + does not — the optional-chained form ``ext?.settings?.registerConfigure?.()`` + degrades to ``undefined`` instead of a TypeError, so extension init survives. + Regression for the doc example at docs/EXTENSIONS.md (#7111 gate).""" + script = textwrap.dedent( + """ + const assert = require('assert'); + + // Simulate an OLDER boot-trusted E0 handle: register() returns an ext + // whose settings object has NO registerConfigure (the capability the + // current Core adds). This is exactly what a pre-#7111 Core exposes. + global.window = { + hermesExt: { + register(id) { + return { id, settings: { get() { return null; }, set() { return false; } } }; + } + } + }; + + let initReached = false; + // The documented consumer pattern, verbatim from docs/EXTENSIONS.md. + const ext = window.hermesExt?.register?.("dictionary-manager"); + const unregister = ext?.settings?.registerConfigure?.(({ opener, restoreFocus }) => { + // never called on old Core + return true; + }); + initReached = true; // must be reached — no throw above + + assert.strictEqual(initReached, true, 'extension init must survive missing registerConfigure'); + assert.strictEqual(unregister, undefined, 'registerConfigure absent => optional call yields undefined'); + + // Sanity: the UNGUARDED form WOULD throw, proving the guard is load-bearing. + let threw = false; + try { + ext.settings.registerConfigure(() => {}); + } catch (e) { + threw = true; + } + assert.strictEqual(threw, true, 'unguarded call is expected to throw on old Core (guard is required)'); + """ + ) + _run_node(script) diff --git a/tests/test_extensions_settings_panel.py b/tests/test_extensions_settings_panel.py index d2578135..6e529489 100644 --- a/tests/test_extensions_settings_panel.py +++ b/tests/test_extensions_settings_panel.py @@ -1,6 +1,10 @@ """Regression tests for the Settings → Extensions diagnostics and toggles.""" from pathlib import Path import re +import shutil +import subprocess + +import pytest ROOT = Path(__file__).parent.parent @@ -50,6 +54,21 @@ def _contains_post_method(block: str) -> bool: return bool(re.search(r"\bmethod\s*:\s*([\"'`])POST\1", block)) +def _run_node(script: str): + node = shutil.which("node") + if not node: + pytest.skip("node is required for extension settings panel runtime tests") + result = subprocess.run( + [node, "-e", script], + cwd=ROOT, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + timeout=20, + ) + assert result.returncode == 0, result.stderr + result.stdout + + def test_settings_sidebar_has_extensions_section_and_pane(): assert 'data-settings-section="extensions"' in INDEX_HTML assert "switchSettingsSection('extensions',{fromSidebarItem:true})" in INDEX_HTML @@ -138,7 +157,7 @@ def test_extensions_panel_renders_sanitized_status_payload(): assert "data&&data.extensions" in render_block assert "counts,'manifest_extensions'" in render_block assert "counts,'user_disabled'" in render_block - assert "_extensionInstalledList(extensions,!!(data&&data.extension_dir_configured))" in render_block + assert "_extensionInstalledList(extensions,!!(data&&data.extension_dir_configured),'diagnostics')" in render_block assert "_extensionSidecarCard(sidecars)" in render_block assert "data&&data.warnings" in render_block assert "esc(url)" in asset_block @@ -251,6 +270,7 @@ def test_extensions_installed_settings_route_through_shared_accessor(): settings_block = _between("function _configureExtensionSettingsFromStatus", "function _extensionInstalledList") bind_block = _between("function _bindExtensionSettingsButtons", "async function loadExtensionsPanel") gallery_block = _between("function _renderExtensionsGallery", "function _bindExtensionGalleryButtons") + installed_surface_block = _between("function _renderInstalledExtensionsSurface", "async function loadExtensionsGallery") assert "entry&&entry.storage_owned" in settings_block assert "window.HermesExtensionSettings.settingsForExtension(id)" in settings_block @@ -268,8 +288,81 @@ def test_extensions_installed_settings_route_through_shared_accessor(): assert "api('/api/extensions/status')" not in bind_block assert "api('/api/settings'" not in bind_block assert "localStorage" not in bind_block - assert "_extensionInstalledList(statusData&&statusData.extensions" in gallery_block - assert "_bindExtensionSettingsButtons(installedEl)" in gallery_block + assert "_renderInstalledExtensionsSurface(statusData)" in gallery_block + assert "_bindExtensionSettingsButtons(installedEl)" in installed_surface_block + + +def test_extension_configure_hook_is_scoped_to_installed_rows_and_current_status(): + installed_block = _between("function _extensionInstalledList", "function _extensionSidecarHealthBadge") + configure_block = _between("function _extensionConfigureButton", "function _extensionInstalledList") + bind_block = _between("function _bindExtensionConfigureButtons", "async function handleExtensionToggle") + diagnostics_block = _between("function _renderExtensionsPanel", "function _bindExtensionToggleButtons") + gallery_block = _between("function _renderInstalledExtensionsSurface", "function _bindExtensionGalleryButtons") + + assert "surface!=='installed'" in configure_block + assert "entry&&entry.effective_enabled" in configure_block + assert "_configureStateForExtension(id)" in configure_block + assert "state.available" in configure_block + assert "data-extension-configure-id" in configure_block + assert "aria-busy" in configure_block + assert "_extensionConfigureButton(entry,surface)" in installed_block + assert "_extensionInstalledList(extensions,!!(data&&data.extension_dir_configured),'diagnostics')" in diagnostics_block + assert "statusData&&statusData.extensions" in gallery_block + assert "'installed'" in gallery_block + assert "_bindExtensionConfigureButtons(installedEl)" in gallery_block + assert "_invokeConfigure(id,{" in bind_block + assert "opener:btn" in bind_block + assert "Extension configuration failed." in bind_block + assert "showToast" in bind_block + assert "data-extension-configure-id" in bind_block + + +def test_extension_configure_rendered_surface_runtime(): + configure_block = _between("function _extensionConfigureButton", "function _extensionInstalledList") + installed_block = _between("function _extensionInstalledList", "function _extensionSidecarHealthBadge") + script = "\n".join( + [ + "const assert = require('assert');", + "const states = new Map([['alpha.ext', {available:true,pending:false}], ['beta.ext', {available:true,pending:false}], ['gamma.ext', {available:false,pending:false}]]);", + "const window = {HermesExtensionSettings:{_configureStateForExtension(id){return states.get(id)||{available:false,pending:false};}}};", + "function esc(value){return String(value??'');}", + "function _extensionEntryBadge(){return '';}", + "function _extensionSettingsControls(){return '';}", + configure_block, + installed_block, + "const entries=[", + " {id:'alpha.ext',name:'Alpha',effective_enabled:true,can_toggle:true,user_enabled:true},", + " {id:'beta.ext',name:'Beta',effective_enabled:false,can_toggle:true,user_enabled:false},", + " {id:'gamma.ext',name:'Gamma',effective_enabled:true,can_toggle:true,user_enabled:true},", + "];", + "const installed=_extensionInstalledList(entries,true,'installed');", + "const diagnostics=_extensionInstalledList(entries,true,'diagnostics');", + "assert.strictEqual((installed.match(/data-extension-configure-id=/g)||[]).length,1);", + "assert.ok(installed.includes('data-extension-configure-id=\"alpha.ext\"'));", + "assert.strictEqual((diagnostics.match(/data-extension-configure-id=/g)||[]).length,0);", + "states.set('alpha.ext',{available:true,pending:true});", + "const pending=_extensionInstalledList(entries,true,'installed');", + "assert.ok(pending.includes('aria-busy=\"true\" disabled'));", + "assert.ok(pending.includes('Opening…'));", + "states.set('alpha.ext',{available:false,pending:false});", + "assert.strictEqual((_extensionInstalledList(entries,true,'installed').match(/data-extension-configure-id=/g)||[]).length,0);", + "states.set('alpha.ext',{available:true,pending:false});", + "assert.strictEqual((_extensionInstalledList(entries,true,'installed').match(/data-extension-configure-id=/g)||[]).length,1);", + ] + ) + _run_node(script) + + +def test_extension_configure_late_registration_rerenders_only_installed_surface(): + listener_block = _between("function _handleExtensionConfigureChange", "function _extensionSafeHttpUrl") + diagnostics_block = _between("function _renderExtensionsPanel", "function _bindExtensionToggleButtons") + + assert "_onConfigureChange(_handleExtensionConfigureChange)" in listener_block + assert "change.reason==='pending'" in listener_block + assert "_syncExtensionConfigureButtonState(change.id)" in listener_block + assert "_renderInstalledExtensionsSurface" in listener_block + assert "extensionsDiagnostics" not in listener_block + assert "_extensionsGalleryData.statusData=data||null" in diagnostics_block def test_extensions_gallery_renders_post_install_guidance(): @@ -353,6 +446,8 @@ def test_extensions_styles_are_scoped_to_extensions_panel(): assert ".extension-toggle-btn" in STYLE_CSS assert ".extension-settings-box" in STYLE_CSS assert ".extension-settings-actions" in STYLE_CSS + assert ".extension-installed-actions" in STYLE_CSS + assert ".extension-configure-btn" in STYLE_CSS assert ".extension-sidecar-list" in STYLE_CSS assert ".extension-sidecar-runtime" in STYLE_CSS assert ".extension-sidecar-status-badge" in STYLE_CSS @@ -453,3 +548,22 @@ def test_extensions_docs_mentions_settings_panel_without_install_or_proxy_claims assert "do **not**" in diagnostics_section assert "return `HERMES_WEBUI_EXTENSION_DIR`" in diagnostics_section assert "override state-file path" in diagnostics_section + + +def test_extensions_docs_define_scoped_configure_editor_contract(): + configure_section = DOCS_EXTENSIONS[ + DOCS_EXTENSIONS.index("### Custom Configure editors"): + DOCS_EXTENSIONS.index("### Turn lifecycle events") + ] + + assert "ext?.settings?.registerConfigure?." in configure_section + assert "does not require `settings_schema` or extension-owned storage" in configure_section + assert "Settings → Extensions → Installed" in configure_section + assert "Diagnostics" in configure_section + assert "Late registration" in configure_section + assert "same-ID reinstall" in configure_section + assert "one-shot `restoreFocus()`" in configure_section + assert "return a thenable" in configure_section + assert "remains disabled until reload" in configure_section + assert "Synchronous throws" in configure_section + assert "generic failure" in configure_section