From 11260c8df5230cf6cee2e74c29e418c24084fe7b Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Sat, 27 Jun 2026 05:44:10 +0000 Subject: [PATCH 1/2] feat(extensions): show post-install next steps in the gallery (#4964) Renders generic registry post_install guidance + lifecycle requirements in the Settings extensions gallery card (manifest-driven, no vendor special-casing). Fixes #4959. Rebased onto current master (squash-applied #4964's net diff so it coexists with the #4979 sidecar-runtime-status feature that landed in v0.51.697). Co-authored-by: franksong2702 --- docs/EXTENSIONS.md | 7 ++ static/i18n.js | 140 ++++++++++++++++++++++++ static/panels.js | 57 +++++++++- static/style.css | 7 ++ tests/test_extensions_settings_panel.py | 103 ++++++++++++++++- 5 files changed, 307 insertions(+), 7 deletions(-) diff --git a/docs/EXTENSIONS.md b/docs/EXTENSIONS.md index 24b69c46..51e5b5e7 100644 --- a/docs/EXTENSIONS.md +++ b/docs/EXTENSIONS.md @@ -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. diff --git a/static/i18n.js b/static/i18n.js index 08864c9f..e47b779b 100644 --- a/static/i18n.js +++ b/static/i18n.js @@ -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', diff --git a/static/panels.js b/static/panels.js index ab9d50ff..14e8bf9b 100644 --- a/static/panels.js +++ b/static/panels.js @@ -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 + ? '' + : ''; + const docsHtml=docsUrl + ? `${esc(t('ext_gallery_open_setup_guide'))}` + : ''; + return ``; +} + 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=>`${esc(String(c))}`).join(''); const permsHtml=perms?``:''; + const postInstallHtml=_extensionPostInstallNote(entry,isInstalled); const actionBtn=isInstalled ?`` :``; @@ -8484,6 +8529,7 @@ function _renderExtensionsGallery(entries,statusData){ ${badgesHtml?'':''} + ${postInstallHtml} ${permsHtml} `; @@ -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){ diff --git a/static/style.css b/static/style.css index 8e0b13d1..d645ccd4 100644 --- a/static/style.css +++ b/static/style.css @@ -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;} diff --git a/tests/test_extensions_settings_panel.py b/tests/test_extensions_settings_panel.py index ca7853ea..20065e1a 100644 --- a/tests/test_extensions_settings_panel.py +++ b/tests/test_extensions_settings_panel.py @@ -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(): From 470769454fadf396ef8abcc6fbccf08d55bfcd15 Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Sat, 27 Jun 2026 05:53:40 +0000 Subject: [PATCH 2/2] Release: extension post-install next steps in gallery (#4964, fixes #4959) --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f488f3f..2ecce844 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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)