Merge pull request #5043 from nesquena/release/stage-4964-postinstall

Release: extension post-install next steps in gallery (#4964, fixes #4959)
This commit is contained in:
nesquena-hermes 2026-06-26 22:58:33 -07:00 committed by GitHub
commit 2d9422adf2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 309 additions and 7 deletions

View file

@ -5,6 +5,8 @@
### Fixed
- **Installing an extension now shows its post-install next steps.** Some extensions need a follow-up step after install (run a setup command, restart, configure a key). The Settings → Extensions gallery now renders that guidance from the extension's own registry metadata — generic `post_install` text and lifecycle requirements, with an optional docs link — right on the card, plus a "see the card for next steps" install toast. The guidance is registry-driven (no vendor special-casing), every value is HTML-escaped, any docs URL is validated to safe http(s) before it's linked, and the note degrades cleanly when an extension declares no post-install steps. Thanks @franksong2702. (#4964, fixes #4959)
- **The Settings → Extensions diagnostics panel now shows per-extension sidecar runtime status.** When an extension's loopback sidecar health response includes an optional top-level `runtime` object, the diagnostics panel parses it and renders only allowlisted scalar fields (sidecar/native-host/bridge status, last-seen time, and the loopback origin) so you can see at a glance whether an extension's helper process is running, waiting, or stale — without WebUI depending on any one extension's private payload shape. All values are status-enum-allowlisted, HTML-escaped, timestamp-validated, and origin-limited to `http://127.0.0.1`/`localhost`; a missing or malformed `runtime` object degrades cleanly, and a failed diagnostics refresh now renders the error instead of staying stuck on "Loading…". Thanks @franksong2702. (#4979)
- **The "Response complete" notification now previews the final answer, not the start of the stream.** The browser/PWA completion notification body was built from the live streaming accumulator sliced to 100 chars, so it showed the *beginning* of the response (often a preamble or empty) rather than what the user actually got. It now derives the preview from the settled final answer (anchor projection → message content → raw text), strips inline thinking, normalizes whitespace, and truncates at 100 chars — and works for completions that finish while the session isn't the active pane. Thanks @claw-io. (#5036, fixes #5035)

View file

@ -58,6 +58,13 @@ applies — installed extension code runs with full session authority — so onl
install extensions from the vetted gallery or sources you trust as much as the
WebUI source itself.
Some gallery entries need more than WebUI assets. If an extension declares
post-install guidance or lifecycle requirements such as a loopback sidecar or a
native host, Settings -> Extensions shows a **Next step** note on the card after
install. For example, Desktop Companion can install the WebUI bridge from the
gallery, but the desktop pet is only visible after the local Desktop Companion
app is started.
### Manual / advanced configuration (optional)
`HERMES_WEBUI_EXTENSION_DIR` is **optional** and overrides the managed default.

View file

@ -680,6 +680,16 @@ const LOCALES = {
ext_gallery_version: 'Version',
ext_gallery_author: 'Author',
ext_gallery_capabilities: 'Capabilities',
ext_gallery_next_step: 'Next step',
ext_gallery_after_install: 'After install',
ext_gallery_local_component_required: 'This extension has a local component. Start the required local app or sidecar after installing.',
ext_gallery_local_app_label: 'Local app',
ext_gallery_required_suffix: '{0} required',
ext_gallery_sidecar_required: 'Sidecar required',
ext_gallery_native_host_required: 'Native host required',
ext_gallery_open_setup_guide: 'Open setup guide',
ext_gallery_install_restart_required: 'Extension installed. Restart required to apply.',
ext_gallery_install_followup: 'Extension installed. See the card for next steps.',
ext_gallery_install_ok: 'Extension installed successfully.',
ext_gallery_uninstall_ok: 'Extension uninstalled.',
settings_plugins_title: 'Plugins',
@ -2301,6 +2311,16 @@ const LOCALES = {
ext_gallery_version: 'Version',
ext_gallery_author: 'Author',
ext_gallery_capabilities: 'Capabilities',
ext_gallery_next_step: 'Prossimo passo',
ext_gallery_after_install: "Dopo l'installazione",
ext_gallery_local_component_required: "Questa estensione ha un componente locale. Avvia l'app locale o il sidecar richiesto dopo l'installazione.",
ext_gallery_local_app_label: 'App locale',
ext_gallery_required_suffix: '{0} richiesto',
ext_gallery_sidecar_required: 'Sidecar richiesto',
ext_gallery_native_host_required: 'Host nativo richiesto',
ext_gallery_open_setup_guide: 'Apri guida di configurazione',
ext_gallery_install_restart_required: 'Estensione installata. Riavvio richiesto per applicare le modifiche.',
ext_gallery_install_followup: 'Estensione installata. Consulta la scheda per i prossimi passi.',
ext_gallery_install_ok: 'Extension installed successfully.',
ext_gallery_uninstall_ok: 'Extension uninstalled.',
settings_plugins_title: 'Plugins', // TODO: translate
@ -3913,6 +3933,16 @@ const LOCALES = {
ext_gallery_version: 'Version',
ext_gallery_author: 'Author',
ext_gallery_capabilities: 'Capabilities',
ext_gallery_next_step: '次の手順',
ext_gallery_after_install: 'インストール後',
ext_gallery_local_component_required: 'この拡張機能にはローカルコンポーネントが必要です。インストール後に必要なローカルアプリまたはサイドカーを起動してください。',
ext_gallery_local_app_label: 'ローカルアプリ',
ext_gallery_required_suffix: '{0} が必要',
ext_gallery_sidecar_required: 'サイドカーが必要',
ext_gallery_native_host_required: 'ネイティブホストが必要',
ext_gallery_open_setup_guide: 'セットアップガイドを開く',
ext_gallery_install_restart_required: '拡張機能をインストールしました。適用するには再起動が必要です。',
ext_gallery_install_followup: '拡張機能をインストールしました。次の手順はカードで確認してください。',
ext_gallery_install_ok: 'Extension installed successfully.',
ext_gallery_uninstall_ok: 'Extension uninstalled.',
settings_plugins_title: 'プラグイン',
@ -6233,6 +6263,16 @@ const LOCALES = {
ext_gallery_version: 'Version',
ext_gallery_author: 'Author',
ext_gallery_capabilities: 'Capabilities',
ext_gallery_next_step: 'Следующий шаг',
ext_gallery_after_install: 'После установки',
ext_gallery_local_component_required: 'У этого расширения есть локальный компонент. После установки запустите требуемое локальное приложение или sidecar.',
ext_gallery_local_app_label: 'Локальное приложение',
ext_gallery_required_suffix: 'Требуется {0}',
ext_gallery_sidecar_required: 'Требуется sidecar',
ext_gallery_native_host_required: 'Требуется нативный хост',
ext_gallery_open_setup_guide: 'Открыть руководство по настройке',
ext_gallery_install_restart_required: 'Расширение установлено. Для применения требуется перезапуск.',
ext_gallery_install_followup: 'Расширение установлено. Следующие шаги смотрите на карточке.',
ext_gallery_install_ok: 'Extension installed successfully.',
ext_gallery_uninstall_ok: 'Extension uninstalled.',
settings_plugins_title: 'Plugins', // TODO: translate
@ -7770,6 +7810,16 @@ const LOCALES = {
ext_gallery_version: 'Version',
ext_gallery_author: 'Author',
ext_gallery_capabilities: 'Capabilities',
ext_gallery_next_step: 'Siguiente paso',
ext_gallery_after_install: 'Después de instalar',
ext_gallery_local_component_required: 'Esta extensión tiene un componente local. Después de instalarla, inicia la app local o el sidecar requerido.',
ext_gallery_local_app_label: 'App local',
ext_gallery_required_suffix: 'Se requiere {0}',
ext_gallery_sidecar_required: 'Se requiere sidecar',
ext_gallery_native_host_required: 'Se requiere host nativo',
ext_gallery_open_setup_guide: 'Abrir guía de configuración',
ext_gallery_install_restart_required: 'Extensión instalada. Reinicio requerido para aplicar.',
ext_gallery_install_followup: 'Extensión instalada. Consulta la tarjeta para ver los próximos pasos.',
ext_gallery_install_ok: 'Extension installed successfully.',
ext_gallery_uninstall_ok: 'Extension uninstalled.',
settings_plugins_title: 'Plugins', // TODO: translate
@ -8991,6 +9041,16 @@ const LOCALES = {
ext_gallery_version: 'Version',
ext_gallery_author: 'Author',
ext_gallery_capabilities: 'Capabilities',
ext_gallery_next_step: 'Nächster Schritt',
ext_gallery_after_install: 'Nach der Installation',
ext_gallery_local_component_required: 'Diese Erweiterung hat eine lokale Komponente. Starte nach der Installation die erforderliche lokale App oder den Sidecar.',
ext_gallery_local_app_label: 'Lokale App',
ext_gallery_required_suffix: '{0} erforderlich',
ext_gallery_sidecar_required: 'Sidecar erforderlich',
ext_gallery_native_host_required: 'Nativer Host erforderlich',
ext_gallery_open_setup_guide: 'Einrichtungsanleitung öffnen',
ext_gallery_install_restart_required: 'Erweiterung installiert. Neustart zum Anwenden erforderlich.',
ext_gallery_install_followup: 'Erweiterung installiert. Die nächsten Schritte stehen auf der Karte.',
ext_gallery_install_ok: 'Extension installed successfully.',
ext_gallery_uninstall_ok: 'Extension uninstalled.',
settings_plugins_title: 'Plugins', // TODO: translate
@ -10879,6 +10939,16 @@ const LOCALES = {
ext_gallery_version: 'Version',
ext_gallery_author: 'Author',
ext_gallery_capabilities: 'Capabilities',
ext_gallery_next_step: '下一步',
ext_gallery_after_install: '安装后',
ext_gallery_local_component_required: '此扩展包含本地组件。安装后请启动所需的本地应用或 sidecar。',
ext_gallery_local_app_label: '本地应用',
ext_gallery_required_suffix: '需要 {0}',
ext_gallery_sidecar_required: '需要 sidecar',
ext_gallery_native_host_required: '需要原生主机',
ext_gallery_open_setup_guide: '打开设置指南',
ext_gallery_install_restart_required: '扩展已安装。需要重启后生效。',
ext_gallery_install_followup: '扩展已安装。请查看卡片中的后续步骤。',
ext_gallery_install_ok: 'Extension installed successfully.',
ext_gallery_uninstall_ok: 'Extension uninstalled.',
settings_tab_system: '系统',
@ -11722,6 +11792,16 @@ const LOCALES = {
ext_gallery_version: 'Version',
ext_gallery_author: 'Author',
ext_gallery_capabilities: 'Capabilities',
ext_gallery_next_step: '下一步',
ext_gallery_after_install: '安裝後',
ext_gallery_local_component_required: '此擴充功能包含本機元件。安裝後請啟動所需的本機應用程式或 sidecar。',
ext_gallery_local_app_label: '本機應用程式',
ext_gallery_required_suffix: '需要 {0}',
ext_gallery_sidecar_required: '需要 sidecar',
ext_gallery_native_host_required: '需要原生主機',
ext_gallery_open_setup_guide: '開啟設定指南',
ext_gallery_install_restart_required: '擴充功能已安裝。需要重新啟動後套用。',
ext_gallery_install_followup: '擴充功能已安裝。請查看卡片中的後續步驟。',
ext_gallery_install_ok: 'Extension installed successfully.',
ext_gallery_uninstall_ok: 'Extension uninstalled.',
settings_plugins_title: '外掛',
@ -13230,6 +13310,16 @@ const LOCALES = {
ext_gallery_version: 'Version',
ext_gallery_author: 'Author',
ext_gallery_capabilities: 'Capabilities',
ext_gallery_next_step: 'Próximo passo',
ext_gallery_after_install: 'Após instalar',
ext_gallery_local_component_required: 'Esta extensão tem um componente local. Depois de instalar, inicie o app local ou sidecar necessário.',
ext_gallery_local_app_label: 'App local',
ext_gallery_required_suffix: '{0} necessário',
ext_gallery_sidecar_required: 'Sidecar necessário',
ext_gallery_native_host_required: 'Host nativo necessário',
ext_gallery_open_setup_guide: 'Abrir guia de configuração',
ext_gallery_install_restart_required: 'Extensão instalada. Reinício necessário para aplicar.',
ext_gallery_install_followup: 'Extensão instalada. Veja o cartão para os próximos passos.',
ext_gallery_install_ok: 'Extension installed successfully.',
ext_gallery_uninstall_ok: 'Extension uninstalled.',
settings_plugins_title: 'Plugins', // TODO: translate
@ -14744,6 +14834,16 @@ const LOCALES = {
ext_gallery_version: 'Version',
ext_gallery_author: 'Author',
ext_gallery_capabilities: 'Capabilities',
ext_gallery_next_step: '다음 단계',
ext_gallery_after_install: '설치 후',
ext_gallery_local_component_required: '이 확장에는 로컬 구성 요소가 있습니다. 설치 후 필요한 로컬 앱이나 사이드카를 시작하세요.',
ext_gallery_local_app_label: '로컬 앱',
ext_gallery_required_suffix: '{0} 필요',
ext_gallery_sidecar_required: '사이드카 필요',
ext_gallery_native_host_required: '네이티브 호스트 필요',
ext_gallery_open_setup_guide: '설정 가이드 열기',
ext_gallery_install_restart_required: '확장이 설치되었습니다. 적용하려면 다시 시작해야 합니다.',
ext_gallery_install_followup: '확장이 설치되었습니다. 카드에서 다음 단계를 확인하세요.',
ext_gallery_install_ok: 'Extension installed successfully.',
ext_gallery_uninstall_ok: 'Extension uninstalled.',
settings_plugins_title: 'Plugins', // TODO: translate
@ -16368,6 +16468,16 @@ const LOCALES = {
ext_gallery_version: 'Version',
ext_gallery_author: 'Author',
ext_gallery_capabilities: 'Capabilities',
ext_gallery_next_step: 'Étape suivante',
ext_gallery_after_install: 'Après l’installation',
ext_gallery_local_component_required: 'Cette extension comporte un composant local. Après l’installation, démarrez l’application locale ou le sidecar requis.',
ext_gallery_local_app_label: 'Application locale',
ext_gallery_required_suffix: '{0} requis',
ext_gallery_sidecar_required: 'Sidecar requis',
ext_gallery_native_host_required: 'Hôte natif requis',
ext_gallery_open_setup_guide: 'Ouvrir le guide de configuration',
ext_gallery_install_restart_required: 'Extension installée. Redémarrage requis pour appliquer.',
ext_gallery_install_followup: 'Extension installée. Consultez la carte pour les prochaines étapes.',
ext_gallery_install_ok: 'Extension installed successfully.',
ext_gallery_uninstall_ok: 'Extension uninstalled.',
settings_plugins_title: 'Plugins',
@ -17948,6 +18058,16 @@ const LOCALES = {
ext_gallery_version: 'Version',
ext_gallery_author: 'Author',
ext_gallery_capabilities: 'Capabilities',
ext_gallery_next_step: 'Sonraki adım',
ext_gallery_after_install: 'Kurulumdan sonra',
ext_gallery_local_component_required: "Bu uzantının yerel bir bileşeni var. Kurulumdan sonra gerekli yerel uygulamayı veya sidecar'ı başlatın.",
ext_gallery_local_app_label: 'Yerel uygulama',
ext_gallery_required_suffix: '{0} gerekli',
ext_gallery_sidecar_required: 'Sidecar gerekli',
ext_gallery_native_host_required: 'Native host gerekli',
ext_gallery_open_setup_guide: 'Kurulum kılavuzunu aç',
ext_gallery_install_restart_required: 'Uzantı kuruldu. Uygulamak için yeniden başlatma gerekli.',
ext_gallery_install_followup: 'Uzantı kuruldu. Sonraki adımlar için kartı kontrol edin.',
ext_gallery_install_ok: 'Extension installed successfully.',
ext_gallery_uninstall_ok: 'Extension uninstalled.',
settings_plugins_title: 'Eklentiler',
@ -19574,6 +19694,16 @@ const LOCALES = {
ext_gallery_version: 'Version',
ext_gallery_author: 'Author',
ext_gallery_capabilities: 'Capabilities',
ext_gallery_next_step: 'Następny krok',
ext_gallery_after_install: 'Po instalacji',
ext_gallery_local_component_required: 'To rozszerzenie ma komponent lokalny. Po instalacji uruchom wymaganą aplikację lokalną lub sidecar.',
ext_gallery_local_app_label: 'Aplikacja lokalna',
ext_gallery_required_suffix: 'Wymagane: {0}',
ext_gallery_sidecar_required: 'Wymagany sidecar',
ext_gallery_native_host_required: 'Wymagany host natywny',
ext_gallery_open_setup_guide: 'Otwórz przewodnik konfiguracji',
ext_gallery_install_restart_required: 'Rozszerzenie zainstalowane. Do zastosowania wymagany jest restart.',
ext_gallery_install_followup: 'Rozszerzenie zainstalowane. Sprawdź kartę, aby zobaczyć kolejne kroki.',
ext_gallery_install_ok: 'Extension installed successfully.',
ext_gallery_uninstall_ok: 'Extension uninstalled.',
settings_plugins_title: 'Wtyczki',
@ -21088,6 +21218,16 @@ const LOCALES = {
ext_gallery_version: 'Version',
ext_gallery_author: 'Author',
ext_gallery_capabilities: 'Capabilities',
ext_gallery_next_step: 'Bước tiếp theo',
ext_gallery_after_install: 'Sau khi cài đặt',
ext_gallery_local_component_required: 'Tiện ích mở rộng này có thành phần cục bộ. Sau khi cài đặt, hãy khởi động ứng dụng cục bộ hoặc sidecar bắt buộc.',
ext_gallery_local_app_label: 'Ứng dụng cục bộ',
ext_gallery_required_suffix: 'Cần {0}',
ext_gallery_sidecar_required: 'Cần sidecar',
ext_gallery_native_host_required: 'Cần native host',
ext_gallery_open_setup_guide: 'Mở hướng dẫn thiết lập',
ext_gallery_install_restart_required: 'Đã cài đặt tiện ích mở rộng. Cần khởi động lại để áp dụng.',
ext_gallery_install_followup: 'Đã cài đặt tiện ích mở rộng. Xem thẻ để biết các bước tiếp theo.',
ext_gallery_install_ok: 'Extension installed successfully.',
ext_gallery_uninstall_ok: 'Extension uninstalled.',
settings_plugins_title: 'Plugin',

View file

@ -8421,6 +8421,50 @@ function switchExtensionsTab(tab){
if(tab==='gallery'&&!_extensionsGalleryLoaded) loadExtensionsGallery();
}
function _extensionSafeHttpUrl(value){
if(!value) return '';
const raw=String(value).trim();
if(!/^https?:\/\//i.test(raw)) return '';
try{
const url=new URL(raw);
if(url.username||url.password) return '';
return (url.protocol==='http:'||url.protocol==='https:')?url.href:'';
}catch(_){
return '';
}
}
function _extensionPostInstallNote(entry,isInstalled){
const lifecycle=(entry&&entry.lifecycle&&typeof entry.lifecycle==='object')?entry.lifecycle:{};
const post=(entry&&entry.post_install&&typeof entry.post_install==='object')?entry.post_install:null;
const needsSidecar=!!lifecycle.sidecar_start_required;
const needsNative=!!lifecycle.native_host_start_required;
const summary=post&&post.summary?String(post.summary):(
(needsSidecar||needsNative)
? t('ext_gallery_local_component_required')
: ''
);
if(!summary) return '';
const docsUrl=_extensionSafeHttpUrl(post&&post.docs_url);
const localAppLabel=post&&post.local_app_label?String(post.local_app_label):t('ext_gallery_local_app_label');
const chips=[];
if(post&&post.requires_local_app===true) chips.push(t('ext_gallery_required_suffix',localAppLabel));
if(needsSidecar) chips.push(t('ext_gallery_sidecar_required'));
if(needsNative) chips.push(t('ext_gallery_native_host_required'));
const chipHtml=chips.length
? '<div class="extension-gallery-next-chips">'+chips.map(item=>`<span>${esc(item)}</span>`).join('')+'</div>'
: '';
const docsHtml=docsUrl
? `<a class="extension-gallery-next-link" href="${esc(docsUrl)}" target="_blank" rel="noopener noreferrer">${esc(t('ext_gallery_open_setup_guide'))}</a>`
: '';
return `<div class="extension-gallery-next-step">
<div class="extension-gallery-next-label">${esc(t(isInstalled?'ext_gallery_next_step':'ext_gallery_after_install'))}</div>
<div class="extension-gallery-next-summary">${esc(summary)}</div>
${chipHtml}
${docsHtml}
</div>`;
}
async function loadExtensionsGallery(){
_extensionsGalleryLoaded=true;
const galleryEl=$('extensionsGallery');
@ -8468,9 +8512,10 @@ function _renderExtensionsGallery(entries,statusData){
const caps=Array.isArray(entry.capabilities)?entry.capabilities:[];
const perms=entry.permissions||null;
const isInstalled=installedIds.has(String(entry.id||''));
const restartRequired=!!(entry.lifecycle&&entry.lifecycle.restart_required);
const restartRequired=!!(entry.lifecycle&&(entry.lifecycle.restart_required||entry.lifecycle.webui_restart_required));
const badgesHtml=caps.map(c=>`<span class="extension-gallery-badge">${esc(String(c))}</span>`).join('');
const permsHtml=perms?`<details class="extension-gallery-perms"><summary data-i18n="ext_gallery_permissions_show">Permissions</summary><pre>${esc(JSON.stringify(perms,null,2))}</pre></details>`:'';
const postInstallHtml=_extensionPostInstallNote(entry,isInstalled);
const actionBtn=isInstalled
?`<button class="extension-gallery-uninstall-btn" data-ext-uninstall-id="${id}" type="button" data-i18n="ext_gallery_uninstall">Uninstall</button>`
:`<button class="extension-gallery-install-btn" data-ext-install-id="${id}" type="button" data-i18n="ext_gallery_install">Install</button>`;
@ -8484,6 +8529,7 @@ function _renderExtensionsGallery(entries,statusData){
</div>
<div class="extension-gallery-desc">${desc}</div>
${badgesHtml?'<div class="extension-gallery-badge-row">'+badgesHtml+'</div>':''}
${postInstallHtml}
${permsHtml}
<div class="extension-gallery-actions">${actionBtn}</div>
</div>`;
@ -8511,15 +8557,18 @@ async function handleExtensionInstall(btn,entry){
if(!btn||btn.disabled) return;
const previousText=btn.textContent;
btn.disabled=true;
btn.textContent='Installing…';
btn.textContent=t('ext_gallery_installing');
try{
const result=await api('/api/extensions/install',{method:'POST',body:JSON.stringify({
id:entry.id,
download_url:entry.download_url||entry.download,
sha256:entry.sha256,
})});
const restart=!!(entry.lifecycle&&entry.lifecycle.restart_required);
showToast(restart?'Extension installed. Restart required to apply.':'Extension installed successfully.');
const restart=!!(entry.lifecycle&&(entry.lifecycle.restart_required||entry.lifecycle.webui_restart_required));
const hasPostInstall=!!(entry.post_install||(entry.lifecycle&&(entry.lifecycle.sidecar_start_required||entry.lifecycle.native_host_start_required)));
showToast(restart
? t('ext_gallery_install_restart_required')
: (hasPostInstall?t('ext_gallery_install_followup'):t('ext_gallery_install_ok')));
_extensionsGalleryLoaded=false;
await loadExtensionsGallery();
}catch(e){

View file

@ -4758,6 +4758,13 @@ main.main > #mainPlugin{display:none;}
.extension-gallery-desc{font-size:12px;color:var(--muted);margin-top:6px;line-height:1.5;}
.extension-gallery-badge-row{display:flex;flex-wrap:wrap;gap:4px;margin-top:8px;}
.extension-gallery-badge{display:inline-flex;align-items:center;background:var(--code-bg);color:var(--muted);border-radius:999px;padding:2px 8px;font-size:10.5px;font-weight:600;font-family:var(--font-mono);}
.extension-gallery-next-step{margin-top:10px;padding:9px 10px;border:1px solid var(--border);border-radius:8px;background:var(--subtle-bg,rgba(127,127,127,.06));}
.extension-gallery-next-label{font-size:11px;font-weight:700;color:var(--text);text-transform:uppercase;letter-spacing:0;}
.extension-gallery-next-summary{font-size:12px;color:var(--muted);line-height:1.45;margin-top:4px;}
.extension-gallery-next-chips{display:flex;flex-wrap:wrap;gap:4px;margin-top:7px;}
.extension-gallery-next-chips span{display:inline-flex;align-items:center;border:1px solid var(--border);border-radius:999px;color:var(--muted);font-size:10.5px;font-weight:600;padding:2px 7px;background:var(--card-bg,var(--sidebar-bg));}
.extension-gallery-next-link{display:inline-flex;margin-top:7px;font-size:12px;font-weight:600;color:var(--accent);text-decoration:none;}
.extension-gallery-next-link:hover{text-decoration:underline;}
.extension-gallery-perms{margin-top:8px;font-size:12px;}
.extension-gallery-perms summary{cursor:pointer;color:var(--muted);user-select:none;}
.extension-gallery-perms pre{margin:6px 0 0;padding:8px;background:var(--code-bg);border-radius:4px;font-size:11px;overflow-x:auto;white-space:pre-wrap;word-break:break-all;}

View file

@ -38,6 +38,12 @@ def _locale_blocks() -> dict[str, str]:
return blocks
def _locale_string(block: str, key: str) -> str:
match = re.search(rf"\b{re.escape(key)}:\s*([\"'])(.*?)\1", block, re.DOTALL)
assert match, f"{key} not found in locale block"
return match.group(2)
def _contains_post_method(block: str) -> bool:
"""Return True when a JS block contains a method: 'POST' style mutation."""
return bool(re.search(r"\bmethod\s*:\s*([\"'`])POST\1", block))
@ -207,6 +213,40 @@ def test_extensions_panel_toggle_uses_dedicated_endpoint_without_settings_or_ins
assert "marketplace" not in combined.lower()
def test_extensions_gallery_renders_post_install_guidance():
url_block = _between("function _extensionSafeHttpUrl", "function _extensionPostInstallNote")
gallery_block = _between("function _extensionPostInstallNote", "async function loadExtensionsGallery")
render_block = _between("function _renderExtensionsGallery", "function _bindExtensionGalleryButtons")
install_block = _between("async function handleExtensionInstall", "async function handleExtensionUninstall")
assert "/^https?:\\/\\//i.test(raw)" in url_block
assert "url.username||url.password" in url_block
assert "entry.post_install" in gallery_block
assert "post&&post.docs_url" in gallery_block
assert "sidecar_start_required" in gallery_block
assert "native_host_start_required" in gallery_block
assert "requires_local_app" in gallery_block
assert "local_app_label" in gallery_block
assert "t('ext_gallery_local_component_required')" in gallery_block
assert "t('ext_gallery_local_app_label')" in gallery_block
assert "t('ext_gallery_required_suffix',localAppLabel)" in gallery_block
assert "t('ext_gallery_sidecar_required')" in gallery_block
assert "t('ext_gallery_native_host_required')" in gallery_block
assert "t('ext_gallery_open_setup_guide')" in gallery_block
assert "t(isInstalled?'ext_gallery_next_step':'ext_gallery_after_install')" in gallery_block
assert "target=\"_blank\"" in gallery_block
assert "rel=\"noopener noreferrer\"" in gallery_block
assert "extension-gallery-next-step" in gallery_block
assert "esc(summary)" in gallery_block
assert "esc(docsUrl)" in gallery_block
assert "esc(item)" in gallery_block
assert "_extensionPostInstallNote(entry,isInstalled)" in render_block
assert "t('ext_gallery_install_restart_required')" in install_block
assert "t('ext_gallery_install_followup')" in install_block
assert "t('ext_gallery_install_ok')" in install_block
assert "webui_restart_required" in install_block
def test_copy_extensions_diagnostics_copies_current_sanitized_payload():
copy_block = _between("function copyExtensionsDiagnostics", "// ── Plugins panel")
@ -228,14 +268,71 @@ def test_extensions_styles_are_scoped_to_extensions_panel():
assert ".extension-sidecar-list" in STYLE_CSS
assert ".extension-sidecar-runtime" in STYLE_CSS
assert ".extension-sidecar-status-badge" in STYLE_CSS
assert ".extension-gallery-next-step" in STYLE_CSS
assert ".extension-gallery-next-link" in STYLE_CSS
def test_extensions_tab_i18n_key_exists_for_all_locales():
def test_extensions_i18n_keys_exist_for_all_locales():
blocks = _locale_blocks()
assert len(blocks) == _locale_count()
missing = [name for name, block in blocks.items() if "settings_tab_extensions" not in block]
assert not missing, f"Locale(s) missing settings_tab_extensions: {missing}"
required_keys = [
"settings_tab_extensions",
"ext_gallery_next_step",
"ext_gallery_after_install",
"ext_gallery_local_component_required",
"ext_gallery_local_app_label",
"ext_gallery_required_suffix",
"ext_gallery_sidecar_required",
"ext_gallery_native_host_required",
"ext_gallery_open_setup_guide",
"ext_gallery_install_restart_required",
"ext_gallery_install_followup",
]
missing = {
name: [key for key in required_keys if key not in block]
for name, block in blocks.items()
}
missing = {name: keys for name, keys in missing.items() if keys}
assert not missing, f"Locale(s) missing extension i18n key(s): {missing}"
def test_extensions_post_install_i18n_is_localized_outside_english():
blocks = _locale_blocks()
english = blocks["en"]
post_install_keys = [
"ext_gallery_next_step",
"ext_gallery_after_install",
"ext_gallery_local_component_required",
"ext_gallery_local_app_label",
"ext_gallery_required_suffix",
"ext_gallery_sidecar_required",
"ext_gallery_native_host_required",
"ext_gallery_open_setup_guide",
"ext_gallery_install_restart_required",
"ext_gallery_install_followup",
]
english_values = {key: _locale_string(english, key) for key in post_install_keys}
untranslated = {
name: [
key
for key in post_install_keys
if _locale_string(block, key) == english_values[key]
]
for name, block in blocks.items()
if name != "en"
}
untranslated = {name: keys for name, keys in untranslated.items() if keys}
assert not untranslated, f"Locale(s) keep English post-install guidance: {untranslated}"
for name, block in blocks.items():
assert "{0}" in _locale_string(block, "ext_gallery_required_suffix"), (
f"{name} must preserve the local-app label placeholder"
)
def test_extensions_i18n_does_not_include_replacement_characters():
assert "\ufffd" not in I18N_JS
def test_extensions_docs_mentions_settings_panel_without_install_or_proxy_claims():