Compare commits

...
Author SHA1 Message Date
Codex 4213d8e672 fix(plugins): distinguish third-party catalog status copy 2026-10-26 14:48:46 +00:00
Codex 59721f9717 fix(plugins): pin sync owners across source lifecycle awaits 2026-10-26 14:37:25 +00:00
Codex ab8764a317 Merge reviewed theme security color aliases into source integration 2026-10-26 14:06:02 +00:00
Codex 7e72ae65ad test(plugins): assert catalog operations bind official source 2026-10-26 14:02:00 +00:00
Codex f2e878858f test(plugins): drain dialog autofocus cleanup before restoring DOM globals 2026-10-26 13:49:24 +00:00
Codex 2ff86c2217 fix(plugins): restore opener focus after source and install dialogs 2026-10-26 13:42:31 +00:00
Codex 5e6278cbc7 fix(plugins): distinguish installed packages from mirrored source entries 2026-10-26 13:34:59 +00:00
Codex 83952aac0d fix(plugins): resolve source-scoped credential owners for provider recovery 2026-10-26 13:31:36 +00:00
Codex 7ec6473112 fix(plugins): retain local provenance through source schema upgrades 2026-10-26 13:28:34 +00:00
Codex 17e6834f74 test(plugins): include source binding in catalog interaction fixtures 2026-10-26 13:19:46 +00:00
Codex 9ed8bf76dd feat(plugins): bind third-party markets to isolated source identities 2026-10-26 13:15:52 +00:00
陈奇 91b8927c56 fix(plugin-themes): pin utility color aliases in security surfaces 2026-10-26 13:07:03 +00:00
Codex 1e5e77fb9c merge: integrate plugin static distribution and capability theme stack 2026-10-26 12:34:54 +00:00
陈奇 f868221b5f fix(plugin-themes): validate text across shared neutral surfaces 2026-10-26 12:07:46 +00:00
陈奇 fd45463d98 test(transfers): avoid assuming concurrent file-preflight order 2026-10-26 12:07:46 +00:00
陈奇 fdeeaf74bb feat(plugins): add validated data-only host color themes 2026-10-26 11:46:08 +00:00
陈奇 ffabb9e9c7 Merge terminal sidebar navigation fix and projection regression 2026-10-26 11:17:24 +00:00
陈奇 733f44cfdc test(plugin-sidebar): cover production bridge callback projection 2026-10-26 11:10:11 +00:00
陈奇 a8adeb86fe fix(plugin-sidebar): forward production terminal navigation actions 2026-10-26 11:06:50 +00:00
陈奇 0c097a1260 docs(plugins): record bounded host workflow native acceptance 2026-10-26 10:29:12 +00:00
陈奇 5c9286775e feat(plugins): host extension pages in terminal split panes 2026-10-26 10:27:00 +00:00
陈奇 618257e593 Merge branch 'integration/plugin-capabilities-ui' into feat/plugin-vault-pages 2026-10-26 09:59:53 +00:00
陈奇 342941b618 Merge branch 'integration/plugin-capabilities-ui' into feat/plugin-vault-pages
# Conflicts:
#	electron/plugins/hostService.cjs
#	packages/plugin-cli/src/manifest.ts
#	packages/plugin-contract/src/index.ts
#	packages/plugin-sdk/src/index.ts
2026-10-26 09:59:42 +00:00
陈奇 1d369bf322 fix(plugins): ignore withdrawn page layout failures 2026-10-26 09:45:04 +00:00
陈奇 0d77a2e56c feat(plugins): mount sandbox extension pages inside Vault 2026-10-26 09:32:00 +00:00
Codex d0e2c905cb feat(plugins): bind static reads to versioned snapshot sidecar 2026-10-26 09:13:45 +00:00
Codex c11cde9bb2 feat(plugins): use official static distribution for marketplace reads 2026-10-26 08:57:23 +00:00
161 changed files with 7363 additions and 433 deletions

No files matched your search

+2 -1
View File
@@ -167,7 +167,8 @@ function AppPluginKeybindingThemeHost({
sessions: any[];
workspaces: any[];
}) {
const appThemeStyle = useAppThemeStyle();
// View environment is global in the current host: publish committed colors only.
const appThemeStyle = useAppThemeStyle(false);
const { resolvedTheme, uiLanguage } = useSettingsChromeStore();
const pluginThemeTokens = useMemo(
() => selectPluginThemeTokens(appThemeStyle as Record<string, unknown>),
+6 -2
View File
@@ -1,3 +1,4 @@
import { usePluginThemeRevision } from '../state/usePluginThemeRuntime';
import type React from 'react';
import { useMemo } from 'react';
@@ -12,17 +13,20 @@ import { getUiThemeById } from '../../infrastructure/config/uiThemes';
* settingsChromeStore so accent drags only re-render the leaf that applies the
* vars, never the App shell.
*/
export function useAppThemeStyle(): React.CSSProperties {
export function useAppThemeStyle(includePreview = true): React.CSSProperties {
const pluginThemeRevision = usePluginThemeRevision();
const { accentMode, customAccent } = useAppearanceChromeStore();
const { resolvedTheme, darkUiThemeId, lightUiThemeId } = useSettingsChromeStore();
return useMemo(() => {
void pluginThemeRevision; // getUiThemeById reads this external registry revision.
const tokens = getUiThemeById(
resolvedTheme,
resolvedTheme === 'dark' ? darkUiThemeId : lightUiThemeId,
includePreview,
).tokens;
return {
...buildAppThemeCssVars(tokens, accentMode, customAccent),
colorScheme: resolvedTheme,
} as React.CSSProperties;
}, [accentMode, customAccent, darkUiThemeId, lightUiThemeId, resolvedTheme]);
}, [accentMode, customAccent, darkUiThemeId, lightUiThemeId, resolvedTheme, pluginThemeRevision, includePreview]);
}
+41
View File
@@ -1246,6 +1246,12 @@ Highlight the focused split pane:
"settings.plugins.manager.remove": "Uninstall",
"settings.plugins.manager.removeNote": "Uninstall removes package code and local KV data. User-owned settings, secrets and sync sidecars are retained.",
"settings.plugins.permissions.statusUnknown": "Status unknown",
"plugins.sidebar.extensions": "Extension pages",
"plugins.sidebar.selectTerminal": "Select an open terminal first.",
"plugins.sidebar.loading": "Loading extension page…",
"plugins.vault.loading": "Loading plugin page…",
"plugins.vault.extensions": "Extensions",
"plugins.vault.back": "Back to hosts",
"plugins.interaction.title": "Plugin request",
"plugins.interaction.sharing": "Text you share becomes available to this plugin and its granted capabilities, including network access. Reading does not approve a new network destination.",
"plugins.interaction.input": "Input text",
@@ -1274,11 +1280,27 @@ Highlight the focused split pane:
"settings.plugins.manager.noPermissions": "No privileged permissions declared.",
"settings.plugins.manager.required": "Required",
"settings.plugins.manager.optional": "Optional",
"settings.plugins.themes.title": "Plugin themes",
"settings.plugins.themes.description": "Preview changes only this window. Apply saves this device’s selection. Personal accent, fonts and terminal overrides stay in control.",
"settings.plugins.themes.empty": "No enabled plugin themes for this mode.",
"settings.plugins.themes.choose": "Choose a theme",
"settings.plugins.themes.unavailable": "Saved theme unavailable; using the host default",
"settings.plugins.themes.applied": "Applied",
"settings.plugins.themes.preview": "Preview",
"settings.plugins.themes.apply": "Apply",
"settings.plugins.themes.cancel": "Cancel preview",
"settings.plugins.themes.restore": "Restore default",
"settings.plugins.themes.invalid": "Some theme palettes were rejected or unavailable.",
"settings.plugins.themes.failed": "Could not apply theme.",
"settings.plugins.manager.dataTheme": "Theme pack: validated colors, no executable runtime.",
"settings.plugins.manager.browser": "Browser runtime: privileged access goes through host permission brokers.",
"settings.plugins.manager.advanced": "Advanced runtime: full Node filesystem and network authority. Broker permissions cannot restrict this access. Development preview only.",
"settings.plugins.manager.data": "Local KV stays on this device. Only non-secret settings explicitly marked for sync participate in encrypted cloud sidecars. Packages and permission grants do not sync.",
"settings.plugins.market.results": "{count} plugins",
"settings.plugins.manager.lastError": "Last recorded error",
"settings.plugins.sources.offline": "Source unavailable. If a previously validated catalog exists for this source, it remains available for browsing only. Installation is unavailable until refresh succeeds.",
"settings.plugins.sources.note": "This catalog is provided by the selected third party, not curated by Netcatty. Review the source domain and permissions before installing. Updates are manual.",
"settings.plugins.sources.sourceOnly": "Source-only entry from this provider. No downloadable package is available; review the author’s build and import instructions.",
"settings.plugins.market.offline": "Registry unavailable. Showing the last valid catalog or bundled examples. Installation is unavailable until refresh succeeds.",
"settings.plugins.manager.details": "View details",
"settings.plugins.manager.noDescription": "No description provided.",
@@ -1309,6 +1331,25 @@ Highlight the focused split pane:
"settings.plugins.manager.unbounded": "No narrower resource scope is declared. Check the actual scope in the permission prompt.",
"settings.plugins.openView": "Open",
"settings.plugins.openManager": "Open plugin manager",
"settings.plugins.sources.conflict": "This ID is installed from another source. Uninstall and explicitly reset its private data before switching. Existing source:",
"settings.plugins.sources.clearPrivate": "Also clear this source’s private data, grants and secrets to reinstall from another source. This cannot be undone.",
"settings.plugins.sources.official": "Official marketplace",
"settings.plugins.sources.thirdparty": "Third-party marketplace",
"settings.plugins.sources.manage": "Manage sources",
"settings.plugins.sources.select": "Select a source",
"settings.plugins.sources.empty": "Choose or add a third-party source.",
"settings.plugins.sources.add": "Add source",
"settings.plugins.sources.remove": "Remove source",
"settings.plugins.sources.name": "Source name",
"settings.plugins.sources.address": "HTTPS source address",
"settings.plugins.sources.risk": "Third-party sources are not maintained or endorsed by Netcatty. Review the real domain, package permissions and author before installing.",
"settings.plugins.sources.accept": "I trust this domain to supply plugin metadata and packages. This does not grant plugin permissions.",
"settings.plugins.sources.removeNote": "Installed plugins and their data remain. Updates from this source stop; another source will not replace it.",
"settings.plugins.sources.disablePlugins": "Also disable plugins installed from this source",
"settings.plugins.sources.installConfirm": "Review third-party installation",
"settings.plugins.sources.source": "Source",
"settings.plugins.sources.local": "Local import",
"settings.plugins.sources.all": "All sources",
"settings.plugins.title": "Plugins",
"settings.plugins.description": "Manage your plugins and explore the catalog.",
'settings.plugins.loading': 'Loading plugin settings…',
+41
View File
@@ -1246,6 +1246,12 @@ Resalta el panel dividido enfocado:
"settings.plugins.manager.remove": "Desinstalar",
"settings.plugins.manager.removeNote": "La desinstalación elimina el código y los datos KV locales. Se conservan los ajustes del usuario, los secretos y los datos auxiliares de sincronización.",
"settings.plugins.permissions.statusUnknown": "Estado desconocido",
"plugins.sidebar.extensions": "Páginas de extensiones",
"plugins.sidebar.selectTerminal": "Selecciona primero un terminal abierto.",
"plugins.sidebar.loading": "Cargando página de extensión…",
"plugins.vault.loading": "Cargando página del plugin…",
"plugins.vault.extensions": "Extensiones",
"plugins.vault.back": "Volver a hosts",
"plugins.interaction.title": "Solicitud de complemento",
"plugins.interaction.sharing": "El texto compartido estará disponible para este complemento y sus capacidades autorizadas, incluida la red. Leer no autoriza un nuevo destino de red.",
"plugins.interaction.input": "Introducir texto",
@@ -1274,11 +1280,27 @@ Resalta el panel dividido enfocado:
"settings.plugins.manager.noPermissions": "No se han declarado permisos privilegiados.",
"settings.plugins.manager.required": "Obligatorio",
"settings.plugins.manager.optional": "Opcional",
"settings.plugins.themes.title": "Temas de plugins",
"settings.plugins.themes.description": "La vista previa solo cambia esta ventana. Aplicar guarda la selección en este dispositivo. Se conservan los ajustes personales.",
"settings.plugins.themes.empty": "No hay temas habilitados para este modo.",
"settings.plugins.themes.choose": "Elegir tema",
"settings.plugins.themes.unavailable": "Tema guardado no disponible; se usa el predeterminado",
"settings.plugins.themes.applied": "Aplicado",
"settings.plugins.themes.preview": "Vista previa",
"settings.plugins.themes.apply": "Aplicar",
"settings.plugins.themes.cancel": "Cancelar vista previa",
"settings.plugins.themes.restore": "Restaurar predeterminado",
"settings.plugins.themes.invalid": "Algunas paletas no están disponibles o no son válidas.",
"settings.plugins.themes.failed": "No se pudo aplicar el tema.",
"settings.plugins.manager.dataTheme": "Paquete de temas: colores validados, sin ejecución de código.",
"settings.plugins.manager.browser": "Entorno de navegador: el acceso privilegiado pasa por los controles de permisos del anfitrión.",
"settings.plugins.manager.advanced": "Entorno avanzado: acceso completo de Node al sistema de archivos y a la red. Los permisos del intermediario no restringen este acceso. Solo para desarrollo.",
"settings.plugins.manager.data": "Los datos KV permanecen en este dispositivo. Solo los ajustes no secretos marcados para sincronización participan en los datos auxiliares cifrados. Los paquetes y las autorizaciones no se sincronizan.",
"settings.plugins.market.results": "{count} plugins",
"settings.plugins.manager.lastError": "Último error registrado",
"settings.plugins.sources.offline": "Fuente no disponible. Si existe un catálogo validado en caché de esta fuente, solo se permite consultarlo. La instalación no estará disponible hasta que se actualice correctamente.",
"settings.plugins.sources.note": "Este catálogo lo proporciona el tercero seleccionado; Netcatty no lo selecciona ni revisa. Comprueba el dominio y los permisos antes de instalar. Las actualizaciones son manuales.",
"settings.plugins.sources.sourceOnly": "Entrada de código fuente de este proveedor. No hay un paquete descargable; consulta las instrucciones de compilación e importación del autor.",
"settings.plugins.market.offline": "El registro no está disponible. Se muestra el último catálogo válido o los ejemplos incluidos. La instalación requiere una actualización correcta.",
"settings.plugins.manager.details": "Ver detalles",
"settings.plugins.manager.noDescription": "No se proporcionó una descripción.",
@@ -1309,6 +1331,25 @@ Resalta el panel dividido enfocado:
"settings.plugins.manager.unbounded": "No se declara un alcance de recursos más limitado. Revisa el alcance real en la solicitud de permiso.",
"settings.plugins.openView": "Abrir",
"settings.plugins.openManager": "Abrir gestor de plugins",
"settings.plugins.sources.conflict": "This ID is installed from another source. Uninstall and explicitly reset its private data before switching. Existing source:",
"settings.plugins.sources.clearPrivate": "Also clear this source’s private data, grants and secrets to reinstall from another source. This cannot be undone.",
"settings.plugins.sources.official": "Official marketplace",
"settings.plugins.sources.thirdparty": "Third-party marketplace",
"settings.plugins.sources.manage": "Manage sources",
"settings.plugins.sources.select": "Select a source",
"settings.plugins.sources.empty": "Choose or add a third-party source.",
"settings.plugins.sources.add": "Add source",
"settings.plugins.sources.remove": "Remove source",
"settings.plugins.sources.name": "Source name",
"settings.plugins.sources.address": "HTTPS source address",
"settings.plugins.sources.risk": "Third-party sources are not maintained or endorsed by Netcatty. Review the real domain, package permissions and author before installing.",
"settings.plugins.sources.accept": "I trust this domain to supply plugin metadata and packages. This does not grant plugin permissions.",
"settings.plugins.sources.removeNote": "Installed plugins and their data remain. Updates from this source stop; another source will not replace it.",
"settings.plugins.sources.disablePlugins": "Also disable plugins installed from this source",
"settings.plugins.sources.installConfirm": "Review third-party installation",
"settings.plugins.sources.source": "Source",
"settings.plugins.sources.local": "Local import",
"settings.plugins.sources.all": "All sources",
"settings.plugins.title": "Plugins",
"settings.plugins.description": "Gestiona tus plugins y explora el catálogo.",
'settings.plugins.loading': 'Cargando ajustes del plugin…',
+41
View File
@@ -1141,6 +1141,12 @@ export const ruCoreMessages: Messages = {
"settings.plugins.manager.remove": "Удалить",
"settings.plugins.manager.removeNote": "Удаление очищает код пакета и локальные данные KV. Настройки пользователя, секреты и данные синхронизации сохраняются.",
"settings.plugins.permissions.statusUnknown": "Состояние неизвестно",
"plugins.sidebar.extensions": "Страницы расширений",
"plugins.sidebar.selectTerminal": "Сначала выберите открытый терминал.",
"plugins.sidebar.loading": "Загрузка страницы расширения…",
"plugins.vault.loading": "Загрузка страницы плагина…",
"plugins.vault.extensions": "Расширения",
"plugins.vault.back": "К хостам",
"plugins.interaction.title": "Запрос плагина",
"plugins.interaction.sharing": "Переданный текст доступен плагину и его разрешённым возможностям, включая сеть. Чтение не разрешает новый сетевой адрес.",
"plugins.interaction.input": "Ввести текст",
@@ -1169,11 +1175,27 @@ export const ruCoreMessages: Messages = {
"settings.plugins.manager.noPermissions": "Привилегированные разрешения не заявлены.",
"settings.plugins.manager.required": "Обязательно",
"settings.plugins.manager.optional": "Необязательно",
"settings.plugins.themes.title": "Темы плагинов",
"settings.plugins.themes.description": "Предпросмотр меняет только это окно. Применение сохраняет выбор на этом устройстве. Личные настройки сохраняются.",
"settings.plugins.themes.empty": "Нет включённых тем для этого режима.",
"settings.plugins.themes.choose": "Выберите тему",
"settings.plugins.themes.unavailable": "Сохранённая тема недоступна; используется стандартная палитра",
"settings.plugins.themes.applied": "Применено",
"settings.plugins.themes.preview": "Предпросмотр",
"settings.plugins.themes.apply": "Применить",
"settings.plugins.themes.cancel": "Отменить предпросмотр",
"settings.plugins.themes.restore": "Сбросить тему",
"settings.plugins.themes.invalid": "Некоторые палитры недоступны или отклонены.",
"settings.plugins.themes.failed": "Не удалось применить тему.",
"settings.plugins.manager.dataTheme": "Пакет тем: проверенные цвета без выполнения кода.",
"settings.plugins.manager.browser": "Браузерная среда: привилегированный доступ проходит через проверку разрешений приложения.",
"settings.plugins.manager.advanced": "Расширенная среда: полный доступ Node к файловой системе и сети. Разрешения посредника не ограничивают этот доступ. Только для разработки.",
"settings.plugins.manager.data": "Данные KV остаются на устройстве. Синхронизируются только явно отмеченные несекретные настройки в зашифрованных данных. Пакеты и разрешения не синхронизируются.",
"settings.plugins.market.results": "Плагинов: {count}",
"settings.plugins.manager.lastError": "Последняя записанная ошибка",
"settings.plugins.sources.offline": "Источник недоступен. Ранее проверенный каталог этого источника, если он есть в кэше, доступен только для просмотра. Установка недоступна до успешного обновления.",
"settings.plugins.sources.note": "Этот каталог предоставляет выбранная третья сторона; Netcatty не отбирает его содержимое. Перед установкой проверьте домен источника и разрешения. Обновления выполняются вручную.",
"settings.plugins.sources.sourceOnly": "Запись с исходным кодом от этого поставщика. Пакет для загрузки отсутствует; см. инструкции автора по сборке и импорту.",
"settings.plugins.market.offline": "Реестр недоступен. Показан последний корректный каталог или встроенные примеры. Для установки необходимо успешно обновить каталог.",
"settings.plugins.manager.details": "Подробнее",
"settings.plugins.manager.noDescription": "Описание не предоставлено.",
@@ -1204,6 +1226,25 @@ export const ruCoreMessages: Messages = {
"settings.plugins.manager.unbounded": "Более узкая область ресурсов не заявлена. Проверьте фактическую область в запросе разрешения.",
"settings.plugins.openView": "Открыть",
"settings.plugins.openManager": "Открыть управление плагинами",
"settings.plugins.sources.conflict": "This ID is installed from another source. Uninstall and explicitly reset its private data before switching. Existing source:",
"settings.plugins.sources.clearPrivate": "Also clear this source’s private data, grants and secrets to reinstall from another source. This cannot be undone.",
"settings.plugins.sources.official": "Official marketplace",
"settings.plugins.sources.thirdparty": "Third-party marketplace",
"settings.plugins.sources.manage": "Manage sources",
"settings.plugins.sources.select": "Select a source",
"settings.plugins.sources.empty": "Choose or add a third-party source.",
"settings.plugins.sources.add": "Add source",
"settings.plugins.sources.remove": "Remove source",
"settings.plugins.sources.name": "Source name",
"settings.plugins.sources.address": "HTTPS source address",
"settings.plugins.sources.risk": "Third-party sources are not maintained or endorsed by Netcatty. Review the real domain, package permissions and author before installing.",
"settings.plugins.sources.accept": "I trust this domain to supply plugin metadata and packages. This does not grant plugin permissions.",
"settings.plugins.sources.removeNote": "Installed plugins and their data remain. Updates from this source stop; another source will not replace it.",
"settings.plugins.sources.disablePlugins": "Also disable plugins installed from this source",
"settings.plugins.sources.installConfirm": "Review third-party installation",
"settings.plugins.sources.source": "Source",
"settings.plugins.sources.local": "Local import",
"settings.plugins.sources.all": "All sources",
"settings.plugins.title": "Плагины",
"settings.plugins.description": "Управляйте плагинами и просматривайте каталог.",
'settings.plugins.loading': 'Загрузка настроек плагинов…',
+41
View File
@@ -1161,6 +1161,12 @@ Vaults 标签保持扁平,不使用这块背景。
"settings.plugins.manager.remove": "卸载",
"settings.plugins.manager.removeNote": "卸载会移除插件代码和本地 KV 数据,保留用户设置、秘密存储和同步 sidecar。",
"settings.plugins.permissions.statusUnknown": "状态未知",
"plugins.sidebar.extensions": "扩展页面",
"plugins.sidebar.selectTerminal": "请先选择已打开的终端。",
"plugins.sidebar.loading": "正在加载扩展页面…",
"plugins.vault.loading": "正在加载插件页面…",
"plugins.vault.extensions": "扩展",
"plugins.vault.back": "返回主机",
"plugins.interaction.title": "插件请求",
"plugins.interaction.sharing": "分享的文本将交给此插件及其已授权的能力(包括网络访问)。读取文本不代表批准新的网络目标。",
"plugins.interaction.input": "输入文本",
@@ -1189,11 +1195,27 @@ Vaults 标签保持扁平,不使用这块背景。
"settings.plugins.manager.noPermissions": "未声明特权权限。",
"settings.plugins.manager.required": "必需",
"settings.plugins.manager.optional": "可选",
"settings.plugins.themes.title": "插件主题",
"settings.plugins.themes.description": "预览仅影响当前窗口;应用后保存在本设备。个人强调色、字体和终端覆盖设置仍然优先。",
"settings.plugins.themes.empty": "当前模式没有已启用的插件主题。",
"settings.plugins.themes.choose": "选择主题",
"settings.plugins.themes.unavailable": "已保存的主题不可用,正在使用宿主默认配色",
"settings.plugins.themes.applied": "已应用",
"settings.plugins.themes.preview": "预览",
"settings.plugins.themes.apply": "应用",
"settings.plugins.themes.cancel": "取消预览",
"settings.plugins.themes.restore": "恢复默认",
"settings.plugins.themes.invalid": "部分主题配色未通过验证或不可用。",
"settings.plugins.themes.failed": "无法应用主题。",
"settings.plugins.manager.dataTheme": "主题包:已验证配色,不启动代码运行时。",
"settings.plugins.manager.browser": "浏览器运行时:特权访问通过宿主权限代理。",
"settings.plugins.manager.advanced": "高级运行时:拥有完整 Node 文件系统和网络能力,代理权限无法限制这些访问。仅供开发预览。",
"settings.plugins.manager.data": "本地 KV 仅保留在此设备。只有明确标记为同步的非秘密设置进入加密云 sidecar。插件包和权限授权不参与同步。",
"settings.plugins.market.results": "{count} 个插件",
"settings.plugins.manager.lastError": "上次记录的错误",
"settings.plugins.sources.offline": "来源暂不可用。如已缓存此来源通过校验的目录,仅可继续浏览;刷新成功前无法安装。",
"settings.plugins.sources.note": "此目录由所选第三方提供,不属于 Netcatty 精选目录。安装前请核对来源域名和权限,更新需手动确认。",
"settings.plugins.sources.sourceOnly": "此提供方仅提供源码条目,没有可下载插件包;请查看作者的构建和导入说明。",
"settings.plugins.market.offline": "官方目录暂时不可用,正在显示上次有效目录或内置示例。刷新成功前不能从市场安装。",
"settings.plugins.manager.details": "查看详情",
"settings.plugins.manager.noDescription": "作者暂未提供功能简介。",
@@ -1224,6 +1246,25 @@ Vaults 标签保持扁平,不使用这块背景。
"settings.plugins.manager.unbounded": "未声明更细的资源范围。请在权限提示中检查实际授权范围。",
"settings.plugins.openView": "打开",
"settings.plugins.openManager": "打开插件管理",
"settings.plugins.sources.conflict": "此 ID 已从其他来源安装。请先卸载并明确清理旧来源数据,再换源重装。原来源:",
"settings.plugins.sources.clearPrivate": "同时清除本来源的私有数据、授权和秘密,以便显式换源重装。此操作不可撤销。",
"settings.plugins.sources.official": "官方市场",
"settings.plugins.sources.thirdparty": "第三方市场",
"settings.plugins.sources.manage": "管理来源",
"settings.plugins.sources.select": "选择来源",
"settings.plugins.sources.empty": "选择或添加第三方来源。",
"settings.plugins.sources.add": "添加来源",
"settings.plugins.sources.remove": "移除来源",
"settings.plugins.sources.name": "来源名称",
"settings.plugins.sources.address": "HTTPS 来源地址",
"settings.plugins.sources.risk": "第三方来源不代表 Netcatty 维护或认可。安装前请核对真实域名、插件权限与作者。",
"settings.plugins.sources.accept": "我信任此域名提供插件目录和软件包。此操作不会授予插件权限。",
"settings.plugins.sources.removeNote": "保留已安装插件及数据,停止此来源更新,不会自动切换到其他来源。",
"settings.plugins.sources.disablePlugins": "同时停用来自该来源的插件",
"settings.plugins.sources.installConfirm": "确认第三方安装",
"settings.plugins.sources.source": "来源",
"settings.plugins.sources.local": "本地导入",
"settings.plugins.sources.all": "全部来源",
"settings.plugins.title": "插件",
"settings.plugins.description": "管理已安装插件,浏览精选目录。",
'settings.plugins.loading': '正在加载插件设置…',
+41
View File
@@ -1164,6 +1164,12 @@ Vaults 分頁維持扁平,不使用這塊背景。
"settings.plugins.manager.remove": "卸載",
"settings.plugins.manager.removeNote": "卸載會移除插件代碼和本地 KV 數據,保留用戶設置、秘密存儲和同步 sidecar。",
"settings.plugins.permissions.statusUnknown": "狀態不明",
"plugins.sidebar.extensions": "擴充功能頁面",
"plugins.sidebar.selectTerminal": "請先選擇已開啟的終端。",
"plugins.sidebar.loading": "正在載入擴充功能頁面…",
"plugins.vault.loading": "正在載入擴充功能頁面…",
"plugins.vault.extensions": "擴充功能",
"plugins.vault.back": "返回主機",
"plugins.interaction.title": "外掛請求",
"plugins.interaction.sharing": "分享的文字會交給此外掛及其已授權能力(包括網路存取)。讀取文字不代表核准新的網路目的地。",
"plugins.interaction.input": "輸入文字",
@@ -1192,17 +1198,52 @@ Vaults 分頁維持扁平,不使用這塊背景。
"settings.plugins.manager.noPermissions": "未聲明特權權限。",
"settings.plugins.manager.required": "必需",
"settings.plugins.manager.optional": "可選",
"settings.plugins.themes.title": "外掛主題",
"settings.plugins.themes.description": "預覽僅影響目前視窗;套用後儲存於本機。個人強調色、字型和終端覆寫設定仍然優先。",
"settings.plugins.themes.empty": "目前模式沒有已啟用的外掛主題。",
"settings.plugins.themes.choose": "選擇主題",
"settings.plugins.themes.unavailable": "已儲存的主題無法使用,正在使用宿主預設配色",
"settings.plugins.themes.applied": "已套用",
"settings.plugins.themes.preview": "預覽",
"settings.plugins.themes.apply": "套用",
"settings.plugins.themes.cancel": "取消預覽",
"settings.plugins.themes.restore": "恢復預設",
"settings.plugins.themes.invalid": "部分主題配色未通過驗證或無法使用。",
"settings.plugins.themes.failed": "無法套用主題。",
"settings.plugins.manager.dataTheme": "主題套件:已驗證配色,不啟動程式執行環境。",
"settings.plugins.manager.browser": "瀏覽器運行時:特權訪問通過宿主權限代理。",
"settings.plugins.manager.advanced": "高級運行時:擁有完整 Node 文件系統和網絡能力,代理權限無法限制這些訪問。僅供開發預覽。",
"settings.plugins.manager.data": "本地 KV 僅保留在此設備。只有明確標記為同步的非秘密設置進入加密雲 sidecar。插件包和權限授權不參與同步。",
"settings.plugins.manager.unbounded": "未宣告更細的資源範圍。請在權限提示中檢查實際授權範圍。",
"settings.plugins.openView": "開啟",
"settings.plugins.openManager": "開啟插件管理",
"settings.plugins.sources.conflict": "此 ID 已从其他来源安装。请先卸载并明确清理旧来源数据,再换源重装。原来源:",
"settings.plugins.sources.clearPrivate": "同时清除本来源的私有数据、授权和秘密,以便显式换源重装。此操作不可撤销。",
"settings.plugins.sources.official": "官方市场",
"settings.plugins.sources.thirdparty": "第三方市场",
"settings.plugins.sources.manage": "管理来源",
"settings.plugins.sources.select": "选择来源",
"settings.plugins.sources.empty": "选择或添加第三方来源。",
"settings.plugins.sources.add": "添加来源",
"settings.plugins.sources.remove": "移除来源",
"settings.plugins.sources.name": "来源名称",
"settings.plugins.sources.address": "HTTPS 来源地址",
"settings.plugins.sources.risk": "第三方来源不代表 Netcatty 维护或认可。安装前请核对真实域名、插件权限与作者。",
"settings.plugins.sources.accept": "我信任此域名提供插件目录和软件包。此操作不会授予插件权限。",
"settings.plugins.sources.removeNote": "保留已安装插件及数据,停止此来源更新,不会自动切换到其他来源。",
"settings.plugins.sources.disablePlugins": "同时停用来自该来源的插件",
"settings.plugins.sources.installConfirm": "确认第三方安装",
"settings.plugins.sources.source": "来源",
"settings.plugins.sources.local": "本地导入",
"settings.plugins.sources.all": "全部来源",
"settings.plugins.title": "插件",
"settings.plugins.description": "管理已安裝插件,瀏覽精選目錄。",
'settings.plugins.loading': '正在載入外掛程式設定…',
"settings.plugins.market.results": "{count} 個插件",
"settings.plugins.manager.lastError": "上次記錄的錯誤",
"settings.plugins.sources.offline": "來源暫不可用。如已快取此來源通過驗證的目錄,僅可繼續瀏覽;重新整理成功前無法安裝。",
"settings.plugins.sources.note": "此目錄由所選第三方提供,不屬於 Netcatty 精選目錄。安裝前請核對來源網域和權限,更新需手動確認。",
"settings.plugins.sources.sourceOnly": "此提供方僅提供原始碼項目,沒有可下載外掛套件;請查看作者的建置和匯入說明。",
"settings.plugins.market.offline": "官方目錄暫時無法使用,正在顯示上次有效目錄或內建範例。重新整理成功前無法從市場安裝。",
"settings.plugins.manager.details": "查看詳情",
"settings.plugins.manager.noDescription": "作者尚未提供功能簡介。",
+27
View File
@@ -0,0 +1,27 @@
export const PLUGIN_VAULT_PAGE_PREFIX = 'plugin-vault:';
export interface PluginVaultPage {
key: `plugin-vault:${string}`;
pluginId: string;
pluginName: string;
version: string;
viewId: string;
title: string;
icon?: NetcattyPluginIconReference;
order: number;
}
export function collectPluginVaultPages(plugins: NetcattyPluginContributionSnapshot['plugins']): PluginVaultPage[] {
return plugins.flatMap((plugin) => plugin.views
.filter((view) => view.location === 'vault-page' && view.visible)
.map((view): PluginVaultPage => ({
key: `${PLUGIN_VAULT_PAGE_PREFIX}${view.id}`,
pluginId: plugin.id, pluginName: plugin.displayName, version: plugin.version,
viewId: view.id, title: view.title, icon: view.icon, order: view.order ?? 0,
})))
.sort((left, right) => left.order - right.order || left.viewId.localeCompare(right.viewId));
}
export function resolvePluginVaultPage(pages: readonly PluginVaultPage[], section: string): PluginVaultPage | null {
return pages.find((page) => page.key === section) ?? null;
}
+18
View File
@@ -0,0 +1,18 @@
/** Native child views must yield to host dialogs and overlapping portals. */
export function isPluginViewOccluded(mount: HTMLElement): boolean {
const rect = mount.getBoundingClientRect();
if (!mount.isConnected || mount.closest('[hidden], [inert], [aria-hidden="true"]')) return true;
const overlays = mount.ownerDocument.querySelectorAll(
'[data-app-lock-overlay], [role="dialog"], [role="alertdialog"], '
+ '[role="menu"][data-state="open"], [data-radix-popper-content-wrapper] > *, [data-native-view-occluder]',
);
return [...overlays].some((overlay) => {
if (mount.contains(overlay)) return false;
const style = getComputedStyle(overlay);
if (style.display === 'none' || style.visibility === 'hidden' || overlay.closest('[hidden], [inert]')) return false;
if (overlay.matches('[data-app-lock-overlay], [role="dialog"][aria-modal="true"], [role="alertdialog"]')) return true;
const other = overlay.getBoundingClientRect();
return other.width > 0 && other.height > 0 && other.x < rect.right && other.right > rect.x
&& other.y < rect.bottom && other.bottom > rect.y;
});
}
@@ -1,3 +1,4 @@
import { pluginThemeRegistry } from '../../infrastructure/config/pluginThemeRegistry';
import {
DEFAULT_SHIFT_ENTER_NEWLINE_TEXT,
isLegacyDefaultShiftEnterNewlineText,
@@ -252,6 +253,9 @@ export const applyThemeTokens = (
accentOverride: string,
) => {
const root = window.document.documentElement;
root.toggleAttribute('data-plugin-theme-active', pluginThemeRegistry.isActive(resolvedTheme));
const securityVars = buildAppThemeCssVars(pluginThemeRegistry.securityTokens(resolvedTheme), 'theme', '');
for (const [key, value] of Object.entries(securityVars)) root.style.setProperty('--host-security' + key.slice(1), value);
root.classList.remove('light', 'dark');
root.classList.add(resolvedTheme);
const cssVars = buildAppThemeCssVars(tokens, accentMode, accentOverride);
+2 -2
View File
@@ -13,11 +13,11 @@ import {
} from '../../infrastructure/config/storageKeys';
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
import { useToolbarItemLayout } from './useToolbarItemLayout';
import type { SidePanelTool } from '../../domain/sidePanelLayout';
import type { BuiltinSidePanelTool, SidePanelTool } from '../../domain/sidePanelLayout';
export type TerminalSidePanelTabId = SidePanelTool;
export const TERMINAL_SIDE_PANEL_TAB_DEFAULT_ORDER: TerminalSidePanelTabId[] = [
export const TERMINAL_SIDE_PANEL_TAB_DEFAULT_ORDER: BuiltinSidePanelTool[] = [
'sftp',
'browser',
'scripts',
+8 -2
View File
@@ -1,3 +1,6 @@
import { pluginThemeRegistry } from '../../infrastructure/config/pluginThemeRegistry';
import { usePluginThemeRevision } from './usePluginThemeRuntime';
import { useSettingsChromeStore } from './settingsChromeStore';
import { useLayoutEffect, useRef } from "react";
import type { TerminalTheme } from "../../domain/models";
import {
@@ -271,12 +274,15 @@ export function useActiveChromeTheme({
activeTheme: TerminalTheme | null;
applyAppTheme: () => void;
}) {
const pluginThemeRevision = usePluginThemeRevision();
const { resolvedTheme } = useSettingsChromeStore();
const effectiveTheme = pluginThemeRegistry.isActive(resolvedTheme) ? null : activeTheme;
const applyAppThemeRef = useRef(applyAppTheme);
applyAppThemeRef.current = applyAppTheme;
useLayoutEffect(() => {
syncActiveChromeTheme(activeTheme, applyAppTheme);
}, [activeTheme, applyAppTheme]);
syncActiveChromeTheme(effectiveTheme, applyAppTheme);
}, [effectiveTheme, applyAppTheme, pluginThemeRevision]);
useLayoutEffect(() => {
return () => {
+119
View File
@@ -0,0 +1,119 @@
import { useEffect, useRef, useState } from 'react';
import { PluginViewLifecycleController, type HostedPluginViewState } from './pluginViewLifecycle';
import { resolvePluginViewWindowScope } from './pluginViewScopes';
import { isPluginViewOccluded } from './pluginViewOcclusion';
import type { UsePluginContributionsResult } from './usePluginContributions';
type ViewAdapter = Pick<UsePluginContributionsResult, 'openView' | 'closeView' | 'focusView' | 'setViewBounds' | 'setViewVisibility' | 'onViewClosed' | 'onViewFocused'>;
/** Host-owned mounting only. No plugin router, DOM, session or vault data API. */
export function useEmbeddedPluginView({ viewId, version, slot, active, context, adapter, subscribeHidden, unavailable, focused, focusOnReveal = false }: {
viewId: string;
version: string;
slot: string;
active: boolean;
context: Record<string, unknown>;
adapter: ViewAdapter;
subscribeHidden(listener: () => void): () => void;
unavailable(): void;
focused?(): void;
focusOnReveal?: boolean;
}) {
const mountRef = useRef<HTMLDivElement>(null);
const [loading, setLoading] = useState(true);
const adapterRef = useRef(adapter);
const unavailableRef = useRef(unavailable);
const focusedRef = useRef(focused);
const revealFocusRef = useRef(focusOnReveal);
const requestFocusRef = useRef<() => void>(() => {});
revealFocusRef.current = focusOnReveal;
focusedRef.current = focused;
adapterRef.current = adapter;
unavailableRef.current = unavailable;
useEffect(() => {
const mount = mountRef.current;
if (!active || !mount) return;
let cancelled = false;
const lifecycle = new PluginViewLifecycleController<HostedPluginViewState>();
const instanceId = crypto.randomUUID();
const scopeId = resolvePluginViewWindowScope({ pathname: window.location.pathname,
search: window.location.search, hash: `${window.location.hash}|plugin-slot:${slot}` });
const { openView, closeView, focusView, setViewBounds, setViewVisibility, onViewClosed, onViewFocused } = adapterRef.current;
const close = () => {
if (cancelled) return;
cancelled = true;
lifecycle.markViewClosed(viewId);
void closeView(instanceId).catch(() => {});
};
const failCurrent = () => {
if (cancelled) return;
close();
unavailableRef.current();
};
const unsubscribe = onViewClosed((event) => {
lifecycle.handleHostClose(event.instanceId);
if (event.instanceId === instanceId && !cancelled) {
close();
unavailableRef.current();
}
});
const stopOnHide = subscribeHidden(close);
const stopOnFocus = onViewFocused((event) => {
if (!cancelled && event.instanceId === instanceId) focusedRef.current?.();
});
const bounds = () => {
const rect = mount.getBoundingClientRect();
return { x: Math.round(rect.x), y: Math.round(rect.y), width: Math.max(1, Math.round(rect.width)), height: Math.max(1, Math.round(rect.height)) };
};
let opened = false;
let lastBounds = '';
let visible = false;
const focus = () => {
if (!cancelled && opened && visible && revealFocusRef.current && !isPluginViewOccluded(mount)) void focusView(instanceId).catch(() => {});
};
requestFocusRef.current = focus;
const updateVisibility = () => {
if (!opened || cancelled) return;
const rect = mount.getBoundingClientRect();
const next = rect.width > 0 && rect.height > 0 && !isPluginViewOccluded(mount);
if (visible === next) return;
visible = next;
void setViewVisibility(instanceId, next).then(() => { if (next) focus(); }).catch(failCurrent);
};
const resize = () => {
if (opened && !cancelled) {
const next = bounds();
const key = JSON.stringify(next);
if (key !== lastBounds) { lastBounds = key; void setViewBounds(instanceId, next).catch(failCurrent); }
}
updateVisibility();
};
const observer = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(resize);
observer?.observe(mount);
const mutations = new MutationObserver(resize);
mutations.observe(document.body, { childList: true, subtree: true, attributes: true,
attributeFilter: ['class', 'style', 'hidden', 'inert', 'role', 'aria-modal', 'aria-hidden', 'data-state', 'data-app-lock-overlay', 'data-radix-popper-content-wrapper', 'data-native-view-occluder'] });
window.addEventListener('resize', resize);
document.addEventListener('scroll', resize, true);
setLoading(true);
const opening = lifecycle.beginOpen({ viewKey: viewId });
void openView({ viewId, instanceId, scopeId, bounds: bounds(), context }).then(async (result) => {
if (cancelled || lifecycle.shouldCloseOpen(opening) || lifecycle.consumeHostClose(result.instanceId)) {
await closeView(result.instanceId);
return;
}
opened = true;
lifecycle.setCurrent({ id: result.instanceId, viewId, scopeId, retainContextWhenHidden: false });
resize();
setLoading(false);
}).catch(() => {
if (!cancelled) { close(); unavailableRef.current(); }
}).finally(() => lifecycle.finishOpen({ token: opening, viewKey: viewId }));
return () => {
close(); unsubscribe(); stopOnHide(); stopOnFocus(); observer?.disconnect(); mutations.disconnect();
window.removeEventListener('resize', resize); document.removeEventListener('scroll', resize, true);
};
}, [active, context, slot, subscribeHidden, version, viewId]);
useEffect(() => { if (focusOnReveal) requestFocusRef.current(); }, [focusOnReveal]);
return { mountRef, loading };
}
+2 -2
View File
@@ -77,8 +77,8 @@ test('offline snapshot stays browsable, retry clears warning, and operations bin
offline = false;
await act(async () => state.retryList());
assert.equal(state.listError, null); assert.equal(state.source, 'registry');
assert.deepEqual(reads.at(-1), [entry.id, 'second']);
assert.deepEqual(reads.at(-1), [entry.id, 'second', 'official']);
await act(async () => state.install(entry.id));
assert.deepEqual(installs, [[entry.id, 'second']]);
assert.deepEqual(installs, [[entry.id, 'second', 'official', undefined]]);
await act(async () => renderer.unmount());
});
+20 -8
View File
@@ -2,7 +2,7 @@ import { useEffect, useRef, useState } from 'react';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
type ReadmeState = { id: string; token?: string; attempt: number; status: 'loading' | 'ready' | 'error'; text: string; error: string | null };
export function usePluginCatalog(selectedId: string | null) {
export function usePluginCatalog(selectedId: string | null, sourceId = 'official') {
const [entries, setEntries] = useState<NetcattyPluginCatalogEntry[]>([]);
const [listStatus, setListStatus] = useState<'loading' | 'ready' | 'error'>('loading');
const [source, setSource] = useState<NetcattyPluginCatalogSnapshot['source']>('builtin');
@@ -12,6 +12,7 @@ export function usePluginCatalog(selectedId: string | null) {
const [readmeAttempt, setReadmeAttempt] = useState(0);
const [installError, setInstallError] = useState<{ id: string; message: string } | null>(null);
const [busy, setBusy] = useState(false);
const [sourceBinding, setSourceBinding] = useState<NetcattyPluginSource | null>(null);
const installing = useRef(false);
const mounted = useRef(false);
useEffect(() => { mounted.current = true; return () => { mounted.current = false; }; }, []);
@@ -22,12 +23,18 @@ export function usePluginCatalog(selectedId: string | null) {
try {
const bridge = netcattyBridge.get();
if (!bridge?.listPluginCatalog) throw new Error('Catalog browsing unavailable');
const list = await bridge.listPluginCatalog();
const list = await bridge.listPluginCatalog(sourceId);
if (active) { setEntries(list.entries); setSource(list.source); setListError(list.error); setListStatus('ready'); }
} catch (cause) { if (active) { setListStatus('error'); setListError(String(cause)); } }
})();
return () => { active = false; };
}, [listAttempt]);
}, [listAttempt, sourceId]);
useEffect(() => {
let active = true;
setSourceBinding(null);
if (selectedId) void netcattyBridge.get()?.getPluginSourceBinding?.(selectedId).then((binding) => { if (active) setSourceBinding(binding); }).catch((cause) => { if (active) setInstallError({ id: selectedId, message: String(cause) }); });
return () => { active = false; };
}, [selectedId, sourceId]);
const selectionToken = entries.find((entry) => entry.id === selectedId)?.selectionToken;
useEffect(() => {
if (!selectedId) return;
@@ -38,15 +45,15 @@ export function usePluginCatalog(selectedId: string | null) {
try {
const bridge = netcattyBridge.get();
if (!bridge?.getPluginCatalogReadme) throw new Error('README retrieval unavailable');
const text = await bridge.getPluginCatalogReadme(selectedId, selectionToken);
const text = await bridge.getPluginCatalogReadme(selectedId, selectionToken, sourceId);
if (active) setReadmeState({ ...base, status: 'ready', text });
} catch (cause) { if (active) setReadmeState({ ...base, status: 'error', error: String(cause) }); }
})();
return () => { active = false; };
}, [selectedId, selectionToken, readmeAttempt]);
}, [selectedId, selectionToken, readmeAttempt, sourceId]);
const currentReadme = readmeState?.id === selectedId && readmeState.token === selectionToken && readmeState.attempt === readmeAttempt ? readmeState : null;
return {
entries, listStatus, listError, source,
entries, listStatus, listError, source, sourceBinding,
readme: currentReadme?.status === 'ready' ? currentReadme.text : null,
readmeStatus: currentReadme?.status ?? (selectedId ? 'loading' : 'idle'),
readmeError: currentReadme?.error ?? null,
@@ -54,14 +61,19 @@ export function usePluginCatalog(selectedId: string | null) {
busy,
retryList: () => setListAttempt((attempt) => attempt + 1),
retryReadme: () => setReadmeAttempt((attempt) => attempt + 1),
async install(id: string) {
async install(id: string, acceptedSourceId?: string, clearPreviousSource = false) {
if (installing.current) return;
installing.current = true;
setBusy(true); setInstallError(null);
try {
const bridge = netcattyBridge.require();
if (!bridge.installCatalogPlugin) throw new Error('Catalog installation unavailable');
await bridge.installCatalogPlugin(id, entries.find((entry) => entry.id === id)?.selectionToken);
if (clearPreviousSource && sourceBinding && sourceBinding.id !== sourceId) {
if (!bridge.resetPluginSource) throw new Error('Source reset unavailable');
await bridge.resetPluginSource(id, sourceBinding.id);
setSourceBinding(null);
}
await bridge.installCatalogPlugin(id, entries.find((entry) => entry.id === id)?.selectionToken, sourceId, acceptedSourceId);
window.dispatchEvent(new Event('netcatty:plugin-packages-changed'));
} catch (cause) { if (mounted.current) setInstallError({ id, message: String(cause) }); }
finally { installing.current = false; if (mounted.current) setBusy(false); }
@@ -88,10 +88,12 @@ export interface UsePluginContributionsResult {
selectSettingPath(kind: 'file' | 'directory', title: string, defaultPath?: string): Promise<string | null>;
openView(payload: NetcattyPluginViewOpenRequest): Promise<{ instanceId: string }>;
closeView(instanceId: string): Promise<void>;
focusView(instanceId: string): Promise<void>;
setViewBounds(instanceId: string, bounds: { x: number; y: number; width: number; height: number }): Promise<void>;
setViewVisibility(instanceId: string, visible: boolean): Promise<void>;
setEnvironment(environment: NetcattyPluginEnvironment): Promise<void>;
onViewClosed(callback: (event: NetcattyPluginViewClosedEvent) => void): () => void;
onViewFocused(callback: (event: import('@netcatty/plugin-contract').PluginViewFocusEvent) => void): () => void;
}
export function usePluginContributions(
@@ -196,6 +198,9 @@ export function usePluginContributions(
if (!bridge?.closePluginView) return;
await bridge.closePluginView(instanceId);
}, [bridge]);
const focusView = useCallback(async (instanceId: string) => {
await bridge?.focusPluginView?.({ instanceId });
}, [bridge]);
const setViewBounds = useCallback(async (
instanceId: string,
@@ -218,6 +223,9 @@ export function usePluginContributions(
const onViewClosed = useCallback((callback: (event: NetcattyPluginViewClosedEvent) => void) => (
bridge?.onPluginViewClosed?.(callback) ?? (() => {})
), [bridge]);
const onViewFocused = useCallback((callback: (event: import('@netcatty/plugin-contract').PluginViewFocusEvent) => void) => (
bridge?.onPluginViewFocused?.(callback) ?? (() => {})
), [bridge]);
const currentLoadState = resolvePluginContributionLoadState({
currentQueryKey: queryKey,
@@ -239,9 +247,11 @@ export function usePluginContributions(
selectSettingPath,
openView,
closeView,
focusView,
setViewBounds,
setViewVisibility,
setEnvironment,
onViewClosed,
onViewFocused,
};
}
+7 -2
View File
@@ -61,9 +61,14 @@ export function usePluginManager() {
if (!bridge.setPluginEnabled) throw new Error('Plugin management unavailable');
return bridge.setPluginEnabled(id, enabled);
}),
uninstall: (id: string) => run(async (bridge) => {
uninstall: (id: string, clearSource = false) => run(async (bridge) => {
if (!bridge.uninstallPlugin) throw new Error('Plugin management unavailable');
return bridge.uninstallPlugin(id);
const source = clearSource ? await bridge.getPluginSourceBinding?.(id) : null;
await bridge.uninstallPlugin(id);
if (clearSource && source) {
if (!bridge.resetPluginSource) throw new Error('Private source reset unavailable');
await bridge.resetPluginSource(id, source.id);
}
}),
};
}
+25
View File
@@ -0,0 +1,25 @@
import { useCallback, useEffect, useState } from 'react';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
export function usePluginSources() {
const [sources, setSources] = useState<NetcattyPluginSource[]>([]);
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const reload = useCallback(async () => {
const values = await netcattyBridge.require().listPluginSources?.();
if (!values) throw new Error('Source management unavailable');
setSources(values);
}, []);
useEffect(() => { void reload().catch((cause) => setError(String(cause))); }, [reload]);
const mutate = async (operation: () => Promise<unknown>) => {
setBusy(true); setError(null);
try { await operation(); await reload(); window.dispatchEvent(new Event('netcatty:plugin-packages-changed')); return true; }
catch (cause) { setError(String(cause)); return false; }
finally { setBusy(false); }
};
return { sources, error, busy,
add: (name: string, address: string) => mutate(async () => netcattyBridge.require().addPluginSource?.({ name, address })),
update: (source: NetcattyPluginSource, name: string, enabled: boolean) => mutate(async () => netcattyBridge.require().updatePluginSource?.({ id: source.id, name, enabled })),
remove: (id: string, disablePlugins: boolean) => mutate(async () => netcattyBridge.require().removePluginSource?.(id, disablePlugins)),
};
}
@@ -0,0 +1,79 @@
import { useEffect, useMemo, useRef, useSyncExternalStore } from 'react';
import { useI18n } from '../i18n/I18nProvider';
import { toast } from '../../components/ui/toast';
import { collectSidePanelPanes, isPluginSidePanelTool, type SidePanelLayout } from '../../domain/sidePanelLayout';
import { activeTabStore, useActiveTabId } from './activeTabStore';
import { getSidePanelLiveSnapshot, subscribeSidePanelLiveSnapshot } from './sidePanelLiveStore';
import { usePluginContributions } from './usePluginContributions';
import { OPEN_PLUGIN_VIEW_EVENT, type OpenPluginViewDetail } from './usePluginViewLifecycle';
export const OPEN_PLUGIN_TERMINAL_SIDEBAR_EVENT = 'netcatty:open-plugin-terminal-sidebar';
export const PLUGIN_TERMINAL_SIDEBAR_CONTEXT = Object.freeze({ 'netcatty.surface': 'terminal-sidebar' });
/** Required production view-context projection, not a plugin-facing API. */
export interface PluginSidebarHostActions {
getActiveTerminalSessionId(): string | null;
handleSwitchSidePanelTab(tool: import('../../domain/sidePanelLayout').SidePanelTool): void;
handleWithdrawPluginSidebarPane(tabId: string, paneId: string, expectedTool: import('../../domain/sidePanelLayout').SidePanelTool): void;
}
export interface PluginTerminalSidebarPage {
tool: `plugin-view:${string}`;
viewId: string;
pluginId: string;
pluginName: string;
version: string;
title: string;
icon?: NetcattyPluginIconReference;
order: number;
}
export function collectPluginTerminalSidebarPages(plugins: NetcattyPluginContributionSnapshot['plugins']): PluginTerminalSidebarPage[] {
return plugins.flatMap((plugin) => plugin.views.filter((view) => view.location === 'terminal-sidebar' && view.visible)
.map((view): PluginTerminalSidebarPage => ({ tool: `plugin-view:${view.id}`, viewId: view.id, pluginId: plugin.id,
pluginName: plugin.displayName, version: plugin.version, title: view.title, icon: view.icon, order: view.order ?? 0 })))
.sort((a, b) => a.order - b.order || a.viewId.localeCompare(b.viewId));
}
export function requestOpenPluginTerminalSidebar(viewId: string, sessionId: string) {
window.dispatchEvent(new CustomEvent(OPEN_PLUGIN_TERMINAL_SIDEBAR_EVENT, { detail: { viewId, sessionId } }));
}
export function usePluginTerminalSidebar(host: {
layouts: ReadonlyMap<string, SidePanelLayout>;
getSession(): string | null;
isTerminalTab(tabId: string): boolean;
select(tool: `plugin-view:${string}`): void;
withdraw(tabId: string, paneId: string, tool: `plugin-view:${string}`): void;
}) {
const { locale, t } = useI18n();
const activeTabId = useActiveTabId();
// Follow workspace focus without subscribing the whole sidebar to cwd/title
// or forwarding the host's live session DTO to a plugin.
useSyncExternalStore((listener) => subscribeSidePanelLiveSnapshot(true, listener),
() => getSidePanelLiveSnapshot(true).focusedSessionId, () => null);
const hostRef = useRef(host); hostRef.current = host;
const contributions = usePluginContributions({ locale, context: PLUGIN_TERMINAL_SIDEBAR_CONTEXT });
const pages = useMemo(() => collectPluginTerminalSidebarPages(contributions.snapshot.plugins), [contributions.snapshot.plugins]);
const sessionId = activeTabId && host.isTerminalTab(activeTabId) ? host.getSession() : null;
useEffect(() => {
const open = (event: Event) => {
const detail = (event as CustomEvent<OpenPluginViewDetail & { sessionId?: string }>).detail;
const page = pages.find((item) => item.viewId === detail?.viewId);
if (!page) return;
const tabId = activeTabStore.getActiveTabId();
const current = hostRef.current.getSession();
if (!hostRef.current.isTerminalTab(tabId) || !current || (detail.sessionId && detail.sessionId !== current)) {
toast.info(t('plugins.sidebar.selectTerminal'));
return;
}
hostRef.current.select(page.tool);
};
window.addEventListener(OPEN_PLUGIN_TERMINAL_SIDEBAR_EVENT, open);
window.addEventListener(OPEN_PLUGIN_VIEW_EVENT, open);
return () => { window.removeEventListener(OPEN_PLUGIN_TERMINAL_SIDEBAR_EVENT, open); window.removeEventListener(OPEN_PLUGIN_VIEW_EVENT, open); };
}, [pages, t]);
useEffect(() => {
if (contributions.loading) return;
const allowed = new Set(pages.map((page) => page.tool));
for (const [tabId, layout] of host.layouts) for (const pane of collectSidePanelPanes(layout.root)) {
if (isPluginSidePanelTool(pane.tool) && !allowed.has(pane.tool)) host.withdraw(tabId, pane.id, pane.tool);
}
}, [contributions.loading, host, pages]);
return { pages, contributions, tabId: activeTabId, sessionId };
}
@@ -0,0 +1,77 @@
import { useEffect, useSyncExternalStore } from "react";
import { netcattyBridge } from "../../infrastructure/services/netcattyBridge";
import { pluginThemeRegistry } from "../../infrastructure/config/pluginThemeRegistry";
export function usePluginThemeRevision() {
return useSyncExternalStore(
pluginThemeRegistry.subscribe,
pluginThemeRegistry.getRevision,
pluginThemeRegistry.getRevision,
);
}
/** Host-owned selection; never stored in plugin KV or cloud sidecars. */
let runtimeUsers = 0;
let disconnectRuntime: (() => void) | undefined;
function connectRuntime() {
const bridge = netcattyBridge.get();
let active = true,
request = 0;
const refresh = async () => {
const current = ++request;
try {
const catalog = await bridge?.getPluginThemes?.();
if (active && current === request && catalog)
pluginThemeRegistry.setCatalog(catalog);
} catch {
/* Keep the last host-validated selection; never apply unvalidated data. */
}
};
void refresh();
const dispose = bridge?.onPluginThemesChanged?.(() => {
pluginThemeRegistry.invalidate();
void refresh();
});
const forced = window.matchMedia?.("(forced-colors: active)"),
contrast = window.matchMedia?.("(prefers-contrast: more)");
const update = () =>
pluginThemeRegistry.setHighContrast(
Boolean(forced?.matches || contrast?.matches),
);
update();
forced?.addEventListener?.("change", update);
contrast?.addEventListener?.("change", update);
return () => {
active = false;
request++;
dispose?.();
forced?.removeEventListener?.("change", update);
contrast?.removeEventListener?.("change", update);
};
}
export function usePluginThemeRuntime() {
const revision = usePluginThemeRevision();
useEffect(() => {
if (runtimeUsers++ === 0) disconnectRuntime = connectRuntime();
return () => {
if (--runtimeUsers === 0) {
disconnectRuntime?.();
disconnectRuntime = undefined;
}
};
}, []);
return revision;
}
export async function applyPluginThemeSelection(
mode: "light" | "dark",
choice: import("@netcatty/plugin-contract").ThemePackChoice | null,
) {
if (choice === null && !pluginThemeRegistry.isActive(mode))
return pluginThemeRegistry.getCatalog();
const bridge = netcattyBridge.require();
if (!bridge.applyPluginTheme) throw new Error("Plugin themes unavailable");
const next = await bridge.applyPluginTheme({
mode,
choice,
expectedRevision: pluginThemeRegistry.getCatalog().selection.revision,
});
return next; // The host change event refreshes every window; never publish a potentially stale apply response.
}
+47
View File
@@ -0,0 +1,47 @@
import { useCallback, useEffect, useMemo, useRef } from 'react';
import { activeTabStore, useActiveTabId } from './activeTabStore';
import { collectPluginVaultPages, PLUGIN_VAULT_PAGE_PREFIX, resolvePluginVaultPage } from './pluginVaultPages';
import { usePluginContributions } from './usePluginContributions';
import { OPEN_PLUGIN_VIEW_EVENT, type OpenPluginViewDetail } from './usePluginViewLifecycle';
const CONTEXT = Object.freeze({ 'netcatty.surface': 'vault-page' });
export function usePluginVaultPages({ locale, section, select }: {
locale: string;
section: string;
select(section: 'hosts' | `plugin-vault:${string}`): void;
}) {
const active = useActiveTabId() === 'vault';
const sectionRef = useRef(section);
const focusFrame = useRef<number | null>(null);
sectionRef.current = section;
useEffect(() => () => { if (focusFrame.current !== null) cancelAnimationFrame(focusFrame.current); }, []);
const contributions = usePluginContributions({ locale, context: CONTEXT });
const pages = useMemo(() => collectPluginVaultPages(contributions.snapshot.plugins), [contributions.snapshot.plugins]);
const selected = resolvePluginVaultPage(pages, section);
const fallback = useCallback(() => {
select('hosts');
if (focusFrame.current !== null) cancelAnimationFrame(focusFrame.current);
focusFrame.current = requestAnimationFrame(() => {
focusFrame.current = null;
if (sectionRef.current === 'hosts' && activeTabStore.getActiveTabId() === 'vault') {
document.querySelector<HTMLButtonElement>('[data-plugin-vault-fallback]')?.focus();
}
});
}, [select]);
useEffect(() => {
if (!contributions.loading && section.startsWith(PLUGIN_VAULT_PAGE_PREFIX) && !selected) fallback();
}, [contributions.loading, fallback, section, selected]);
useEffect(() => {
const open = (event: Event) => {
const detail = (event as CustomEvent<OpenPluginViewDetail>).detail;
const page = pages.find((item) => item.viewId === detail?.viewId);
if (!page) return;
activeTabStore.setActiveTabId('vault');
select(page.key);
};
window.addEventListener(OPEN_PLUGIN_VIEW_EVENT, open);
return () => window.removeEventListener(OPEN_PLUGIN_VIEW_EVENT, open);
}, [pages, select]);
return { pages, selected, active, select, fallback, contributions, context: CONTEXT };
}
+1 -1
View File
@@ -82,7 +82,7 @@ export function usePluginViewLifecycle({
const mountRef = useRef<HTMLDivElement>(null);
const resolvedActiveView = useMemo(() => viewContributions.snapshot.plugins
.flatMap((plugin) => plugin.views.map((view) => ({ plugin, view })))
.find(({ view }) => view.id === effectiveRequested?.viewId && view.visible) ?? null,
.find(({ view }) => view.id === effectiveRequested?.viewId && view.visible && !['vault-page', 'terminal-sidebar'].includes(view.location)) ?? null,
[effectiveRequested?.viewId, viewContributions.snapshot.plugins]);
const stableActiveViewRef = useRef<PluginViewSnapshotSelection<ResolvedPluginView> | null>(null);
const activeView = resolvePluginViewSnapshotSelection({
+5 -2
View File
@@ -1,3 +1,4 @@
import { usePluginThemeRuntime } from './usePluginThemeRuntime';
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type SetStateAction } from 'react';
import { runThemeTransition, type ThemeTransitionMode } from './themeTransition';
@@ -217,6 +218,7 @@ import {
} from './appearanceSync';
export const useSettingsState = (options: { enableSettingsSync?: boolean; enableSystemEffects?: boolean } = {}) => {
const pluginThemeRevision = usePluginThemeRuntime();
const enableSettingsSync = options.enableSettingsSync !== false;
const enableSystemEffects = options.enableSystemEffects !== false;
const initialCustomKeyBindingsRecord =
@@ -1212,7 +1214,7 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
notifySettingsChanged(STORAGE_KEY_COLOR, customAccentRecord);
}
}
}, [theme, resolvedTheme, lightUiThemeId, darkUiThemeId, accentMode, customAccent, customAccentRecord, notifySettingsChanged]);
}, [theme, resolvedTheme, lightUiThemeId, darkUiThemeId, accentMode, customAccent, customAccentRecord, notifySettingsChanged, pluginThemeRevision]);
// Listen for OS color scheme changes to keep systemPreference in sync
useEffect(() => {
@@ -1979,9 +1981,10 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
}, [setTerminalSettings]);
const applyAppTheme = useCallback(() => {
void pluginThemeRevision; // Invalidate the closure when host theme data changes.
const tokens = getUiThemeById(resolvedTheme, resolvedTheme === 'dark' ? darkUiThemeId : lightUiThemeId).tokens;
applyThemeTokens(theme, resolvedTheme, tokens, accentMode, customAccent);
}, [theme, resolvedTheme, lightUiThemeId, darkUiThemeId, accentMode, customAccent]);
}, [theme, resolvedTheme, lightUiThemeId, darkUiThemeId, accentMode, customAccent, pluginThemeRevision]);
const setEditorWordWrap = useCallback((enabled: boolean) => {
setEditorWordWrapState(enabled);
@@ -0,0 +1,58 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import path from 'node:path';
import test from 'node:test';
import React from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { JSDOM } from 'jsdom';
import { compile } from '@tailwindcss/node';
import postcss from 'postcss';
import HostDetailsPanel from './HostDetailsPanel';
import { Card } from './ui/card';
import { TooltipProvider } from './ui/tooltip';
import { PluginPermissionPanel } from './settings/tabs/PluginPermissionPanel';
import { I18nProvider } from '../application/i18n/I18nProvider';
test('real Host form cards, inputs and Save consume scoped security aliases without freezing an ordinary card', async () => {
const markup = renderToStaticMarkup(<I18nProvider locale="en"><TooltipProvider>
<HostDetailsPanel availableKeys={[]} identities={[]} groups={[]} terminalThemeId="default" terminalFontSize={14} onSave={() => assert.fail('test never saves a host')} onCancel={() => {}} />
<PluginPermissionPanel pluginId="com.example.synthetic" onChanged={async () => {}} />
<Card id="ordinary-card">Outside the security surface</Card>
</TooltipProvider></I18nProvider>);
const dom = new JSDOM(`<!doctype html><html data-plugin-theme-active><body>${markup}</body></html>`);
try {
const panel = dom.window.document.querySelector('[data-section="host-details-panel"]')!;
assert.ok(panel, 'exercise the actual HostDetailsPanel and AsidePanel');
assert.ok(panel.querySelector('.bg-card'), 'General/Address/Credentials use real Card components');
assert.ok(panel.querySelector('input.bg-background'), 'exercise actual Input utilities');
assert.ok(panel.querySelector('button.bg-primary'), 'exercise the actual Save button');
const permissions = dom.window.document.querySelector('[data-host-security-surface]')!;
assert.ok(permissions.querySelector('[role="status"]'), 'exercise the real grant-panel security root without loading or changing any grants');
const source = await readFile(new URL('../index.css', import.meta.url), 'utf8');
const compiler = await compile(source, {base: path.resolve('.'), onDependency() {}});
const classes = [...new Set([...dom.window.document.querySelectorAll('[class]')].flatMap(el => [...el.classList]))];
const css = compiler.build(classes);
const parsed = postcss.parse(css);
const rules: postcss.Rule[] = [];
parsed.walkRules(rule => { rules.push(rule); });
const rule = rules.find(node => node.selector.startsWith(':root[data-plugin-theme-active]'));
assert.ok(rule && rule.type === 'rule');
const matches = [...dom.window.document.querySelectorAll(rule.selector)];
assert.ok(matches.includes(panel));
assert.ok(matches.includes(permissions));
assert.ok(!matches.includes(dom.window.document.getElementById('ordinary-card')!), 'security selector stays within the existing marked surfaces');
const declarations = new Map(rule.nodes.filter(node => node.type === 'decl').map(node => [node.prop, node]));
for (const name of ['background','foreground','card','card-foreground','popover','popover-foreground','primary','primary-foreground','secondary','secondary-foreground','muted','muted-foreground','accent','accent-foreground','destructive','destructive-foreground','border','input','ring']) {
const declaration = declarations.get(`--color-${name}`);
assert.equal(declaration?.value, `hsl(var(--host-security-${name}))`, `Tailwind alias ${name} cannot inherit the plugin value`);
assert.equal(declaration.important, true);
}
for (const name of ['card','background','primary']) {
const utility = rules.find(node => node.selector === `.bg-${name}`);
assert.ok(utility && utility.type === 'rule');
assert.ok(utility.nodes.some(node => node.type === 'decl' && node.prop === 'background-color' && node.value === `var(--color-${name})`), 'verify what the actual compiled utility consumes');
}
const mutedText = rules.find(node => node.selector === '.text-muted-foreground');
assert.ok(mutedText?.nodes.some(node => node.type === 'decl' && node.prop === 'color' && node.value === 'var(--color-muted-foreground)'));
} finally { dom.window.close(); }
});
+1
View File
@@ -882,6 +882,7 @@ echo $3 >> "$FILE"`);
{/* Slide-out Panel */}
{panel.type !== "closed" && (
<AsidePanel
dataSection="keychain-security-panel"
open={true}
onClose={closePanel}
title={panelTitle}
+128
View File
@@ -0,0 +1,128 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { JSDOM } from 'jsdom';
test('terminal extension pages use actual split panes, focus and session ownership without sharing session data', async () => {
const dom = new JSDOM('<!doctype html><html><body><div id="root"></div></body></html>', { pretendToBeVisual: true, url: 'http://localhost' });
const previous = new Map<string, PropertyDescriptor | undefined>();
const install = (key: string, value: unknown) => {
previous.set(key, Object.getOwnPropertyDescriptor(globalThis, key)); Object.defineProperty(globalThis, key, { configurable: true, writable: true, value });
};
for (const key of ['window', 'document', 'navigator', 'HTMLElement', 'HTMLInputElement', 'Element', 'SVGElement', 'Node', 'NodeFilter', 'MutationObserver', 'CustomEvent', 'Event', 'KeyboardEvent', 'MouseEvent']) {
install(key, key === 'window' ? dom.window : dom.window[key as keyof typeof dom.window]);
}
install('PointerEvent', dom.window.MouseEvent);
install('getComputedStyle', dom.window.getComputedStyle.bind(dom.window));
install('requestAnimationFrame', dom.window.requestAnimationFrame.bind(dom.window)); install('cancelAnimationFrame', dom.window.cancelAnimationFrame.bind(dom.window)); install('IS_REACT_ACT_ENVIRONMENT', true);
let rectX = 200;
dom.window.HTMLElement.prototype.getBoundingClientRect = () => new dom.window.DOMRect(rectX, 100, 400, 300);
const { default: React, act, useCallback, useRef, useState } = await import('react');
const { createRoot } = await import('react-dom/client');
const { I18nProvider } = await import('../application/i18n/I18nProvider');
const { activeTabStore } = await import('../application/state/activeTabStore');
const { sidePanelLiveStore, SIDE_PANEL_INACTIVE_LIVE_SNAPSHOT } = await import('../application/state/sidePanelLiveStore');
const { useTerminalSidePanelLayoutState } = await import('../application/state/useTerminalSidePanelLayoutState');
const { usePluginTerminalSidebar } = await import('../application/state/usePluginTerminalSidebar');
const { PluginTerminalSidebarLauncher, PluginTerminalSidebarPageHost } = await import('./plugins/PluginTerminalSidebar');
const { SidePanelLayoutTree } = await import('./terminalLayer/TerminalLayerSidePanelSection');
const { PersistentSidePanelPortal } = await import('./terminalLayer/terminalLayerSidePanelSlots');
const { collectSidePanelPanes } = await import('../domain/sidePanelLayout');
type Tool = import('../domain/sidePanelLayout').SidePanelTool;
let selectedSession = 'synthetic-session-a';
const getSession = () => selectedSession;
const a = { id: 'com.example.sidebar.first', title: 'First page', location: 'terminal-sidebar', entry: 'page.html', visible: true };
const b = { ...a, id: 'com.example.sidebar.second', title: 'Second page' };
let snapshot = { locale: 'en', plugins: [{ id: 'com.example.sidebar', displayName: 'Synthetic plugin', version: '1.0.0', commands: [], keybindings: [], menus: [], settings: [], views: [a, b] }] };
const changed = new Set<() => void>(), focusListeners = new Set<(event: { instanceId: string }) => void>();
const opens: NetcattyPluginViewOpenRequest[] = [], closes: string[] = [], focus: string[] = [], bounds: Array<{ instanceId: string; x: number }> = [];
const visible = new Map<string, boolean>();
Object.assign(dom.window, { netcatty: {
async getPluginRuntimeStatus() { return { available: true }; }, async getPluginContributions() { return snapshot; },
onPluginContributionsChanged(fn: () => void) { changed.add(fn); return () => { changed.delete(fn); }; },
onPluginViewClosed() { return () => {}; },
onPluginViewFocused(fn: (event: { instanceId: string }) => void) { focusListeners.add(fn); return () => { focusListeners.delete(fn); }; },
async openPluginView(payload: NetcattyPluginViewOpenRequest) { opens.push(payload); return { instanceId: payload.instanceId! }; },
async closePluginView(id: string) { closes.push(id); },
async setPluginViewBounds(id: string, rect: { x: number }) { bounds.push({ instanceId: id, x: rect.x }); },
async setPluginViewVisibility(id: string, value: boolean) { visible.set(id, value); },
async focusPluginView(event: { instanceId: string }) { focus.push(event.instanceId); for (const fn of focusListeners) fn(event); },
} });
let split!: () => void;
function Fixture() {
const state = useTerminalSidePanelLayoutState(); const latest = useRef(state); latest.current = state;
const model = usePluginTerminalSidebar({ layouts: state.sidePanelLayouts, getSession, isTerminalTab: (id) => id === 'workspace-synthetic' || id === 'synthetic-local-terminal',
select: (tool) => state.selectSidePanelToolForTab(activeTabStore.getActiveTabId(), tool),
withdraw: (tabId, paneId, tool) => {
const layout = state.sidePanelLayoutsRef.current.get(tabId);
if (layout && collectSidePanelPanes(layout.root).some((pane) => pane.id === paneId && pane.tool === tool)) state.closePane(tabId, paneId);
} });
const [hosts, setHosts] = useState(new Map<Tool, HTMLElement>()); const parking = useRef<HTMLDivElement>(null);
const register = useCallback((tool: Tool, host: HTMLElement | null) => setHosts((prior) => {
if ((prior.get(tool) ?? null) === host) return prior;
const next = new Map(prior); if (host) next.set(tool, host); else next.delete(tool); return next;
}), []);
const focusPane = useCallback((id: string) => latest.current.focusPane(activeTabStore.getActiveTabId(), id), []);
const closePane = useCallback((id: string) => { latest.current.closePane(activeTabStore.getActiveTabId(), id); }, []);
const resize = useCallback(() => {}, []);
const layout = model.tabId ? state.sidePanelLayouts.get(model.tabId) : null;
const panes = layout ? collectSidePanelPanes(layout.root) : [];
split = () => state.splitPane('workspace-synthetic', `plugin-view:${b.id}`, 'vertical', { paneId: 'second-pane', splitId: 'plugin-split' }, 600);
return <><PluginTerminalSidebarLauncher sessionId={selectedSession} />
{layout && <SidePanelLayoutTree node={layout.root} layout={layout} paneCount={panes.length} labels={new Map(model.pages.map((page) => [page.tool, page.title]))}
onClose={closePane} onFocus={focusPane} onHostChange={register} onResize={resize} separator="#777" accent="#4488cc" closePaneLabel="Close pane" resizeLabel="Resize" />}
<div ref={parking} hidden aria-hidden="true" />
{model.sessionId && panes.map((pane) => {
const page = model.pages.find((item) => item.tool === pane.tool); const target = hosts.get(pane.tool) ?? parking.current;
if (!page || !target) return null;
return <PersistentSidePanelPortal key={`${pane.id}:${model.sessionId}`} portalKey={`plugin-${pane.id}`} target={target}>
<PluginTerminalSidebarPageHost page={page} tabId={model.tabId!} sessionId={model.sessionId} getSession={getSession} adapter={model.contributions}
focusOnReveal={layout?.focusedPaneId === pane.id} focused={() => focusPane(pane.id)} unavailable={() => closePane(pane.id)} />
</PersistentSidePanelPortal>;
})}</>;
}
const root = createRoot(document.getElementById('root')!); const settle = () => new Promise((resolve) => setTimeout(resolve, 40));
try {
activeTabStore.setActiveTabId('workspace-synthetic'); sidePanelLiveStore.update({ ...SIDE_PANEL_INACTIVE_LIVE_SNAPSHOT, focusedSessionId: selectedSession });
await act(async () => { root.render(<I18nProvider locale="en"><Fixture /></I18nProvider>); await settle(); });
await act(async () => { document.querySelector<HTMLButtonElement>('button[aria-label="Extension pages"]')!.click(); await settle(); });
const firstButton = [...document.querySelectorAll('button')].find((button) => button.textContent?.includes('First page'))!;
await act(async () => { firstButton.click(); await settle(); });
assert.equal(document.querySelectorAll('[data-section="terminal-side-panel-pane"]').length, 1);
assert.ok(document.querySelector('[data-pane-tool] [data-plugin-view-slot="terminal-sidebar"]'));
assert.equal(opens.length, 1); assert.deepEqual(opens[0].context, { 'netcatty.surface': 'terminal-sidebar' });
await act(async () => { split(); await settle(); });
assert.equal(document.querySelectorAll('[data-section="terminal-side-panel-pane"]').length, 2);
assert.equal(opens.length, 2);
const firstId = opens.find((item) => item.viewId === a.id)!.instanceId!;
await act(async () => { for (const fn of focusListeners) fn({ instanceId: firstId }); await settle(); });
assert.equal(document.querySelector(`[data-pane-tool="plugin-view:${a.id}"]`)?.getAttribute('data-focused'), 'true');
const dialog = document.createElement('div'); dialog.setAttribute('role', 'dialog'); dialog.setAttribute('aria-modal', 'true');
await act(async () => { document.body.append(dialog); await settle(); });
assert.ok(opens.every((item) => visible.get(item.instanceId!) === false));
focus.length = 0;
await act(async () => { dialog.remove(); await settle(); });
assert.ok(focus.length > 0); assert.ok(focus.every((id) => id === firstId), 'restoring panes focuses only the host-selected pane');
const old = opens.map((item) => item.instanceId!);
await act(async () => { selectedSession = 'synthetic-session-b'; sidePanelLiveStore.update({ ...SIDE_PANEL_INACTIVE_LIVE_SNAPSHOT, focusedSessionId: selectedSession });
assert.ok(old.every((id) => closes.includes(id)), 'workspace focus cancels old native owners synchronously'); await settle(); });
assert.equal(opens.length, 4);
assert.notEqual(opens[0].scopeId, opens[2].scopeId);
assert.ok(opens.every((item) => JSON.stringify(item.context) === JSON.stringify({ 'netcatty.surface': 'terminal-sidebar' })));
await act(async () => { rectX = 260; snapshot = { ...snapshot, plugins: [{ ...snapshot.plugins[0], views: [{ ...a, visible: false }, b] }] }; for (const fn of changed) fn(); await settle(); });
assert.equal(document.querySelectorAll('[data-section="terminal-side-panel-pane"]').length, 1);
assert.equal(document.querySelector('[data-pane-tool]')?.getAttribute('data-pane-tool'), `plugin-view:${b.id}`);
assert.ok(bounds.some((item) => item.x === 260), 'portal relocation updates native geometry');
const last = opens.at(-1)!.instanceId!;
await act(async () => { activeTabStore.setActiveTabId('vault'); assert.ok(closes.includes(last)); await settle(); });
assert.equal(document.querySelector('[data-plugin-terminal-sidebar]'), null);
await act(async () => { selectedSession = 'synthetic-local-terminal'; activeTabStore.setActiveTabId(selectedSession); await settle(); });
await act(async () => { document.querySelector<HTMLButtonElement>('button[aria-label="Extension pages"]')!.click(); await settle(); });
const localPage = [...document.querySelectorAll('button')].find((button) => button.textContent?.includes('Second page'))!;
await act(async () => { localPage.click(); await settle(); });
assert.ok(document.querySelector('[data-plugin-view-slot="terminal-sidebar"]'), 'standalone Local Terminal is a valid navigation owner');
assert.deepEqual(opens.at(-1)!.context, { 'netcatty.surface': 'terminal-sidebar' });
} finally {
await act(async () => { root.unmount(); await settle(); }); dom.window.close();
for (const [key, descriptor] of previous) { if (descriptor) Object.defineProperty(globalThis, key, descriptor); else Reflect.deleteProperty(globalThis, key); }
}
});
+101
View File
@@ -0,0 +1,101 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { JSDOM } from 'jsdom';
test('Vault navigation mounts a real content slot, yields to host dialogs, and withdraws stale pages', async () => {
const dom = new JSDOM('<!doctype html><html><body><div id="root"></div></body></html>', { pretendToBeVisual: true, url: 'http://localhost' });
const previous = new Map<string, PropertyDescriptor | undefined>();
for (const key of ['window', 'document', 'navigator', 'HTMLElement', 'Element', 'Node', 'MutationObserver', 'CustomEvent', 'Event']) {
previous.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
Object.defineProperty(globalThis, key, { configurable: true, writable: true, value: key === 'window' ? dom.window : dom.window[key as keyof Window] });
}
for (const [key, value] of Object.entries({ getComputedStyle: dom.window.getComputedStyle.bind(dom.window),
requestAnimationFrame: dom.window.requestAnimationFrame.bind(dom.window), cancelAnimationFrame: dom.window.cancelAnimationFrame.bind(dom.window), IS_REACT_ACT_ENVIRONMENT: true })) {
previous.set(key, Object.getOwnPropertyDescriptor(globalThis, key)); Object.defineProperty(globalThis, key, { configurable: true, writable: true, value });
}
dom.window.HTMLElement.prototype.getBoundingClientRect = () => ({ x: 200, y: 100, width: 640, height: 480, top: 100, left: 200, right: 840, bottom: 580, toJSON() {} });
const { default: React, act, useState } = await import('react');
const { createRoot } = await import('react-dom/client');
const { usePluginVaultPages } = await import('../application/state/usePluginVaultPages');
const { PluginVaultNavigation, PluginVaultPage } = await import('./plugins/PluginVaultPages');
const { activeTabStore } = await import('../application/state/activeTabStore');
const { requestOpenPluginView } = await import('../application/state/usePluginViewLifecycle');
const { I18nProvider } = await import('../application/i18n/I18nProvider');
const page = { id: 'com.example.page.main', title: 'Synthetic page', location: 'vault-page', entry: 'page.html', visible: true };
let snapshot = { locale: 'en', plugins: [{ id: 'com.example.page', version: '1.0.0', displayName: 'Synthetic plugin', commands: [], keybindings: [], menus: [], settings: [], views: [page] }] };
let changed: (() => void) | undefined;
const listeners = new Set<(event: NetcattyPluginViewClosedEvent) => void>();
const opens: NetcattyPluginViewOpenRequest[] = [], closes: string[] = [], visibility: boolean[] = [];
let defer: ((value: { instanceId: string }) => void) | undefined;
let deferOpen = false;
let delayLayout = false;
const rejectOldLayout: Array<(error: Error) => void> = [];
const layoutResult = () => delayLayout ? new Promise<void>((_resolve, reject) => { rejectOldLayout.push(reject); }) : Promise.resolve();
Object.assign(dom.window, { netcatty: {
async getPluginRuntimeStatus() { return { available: true }; },
async getPluginContributions() { return snapshot; },
onPluginContributionsChanged(fn: () => void) { changed = fn; return () => { changed = undefined; }; },
onPluginViewClosed(fn: (event: NetcattyPluginViewClosedEvent) => void) { listeners.add(fn); return () => { listeners.delete(fn); }; },
async openPluginView(payload: NetcattyPluginViewOpenRequest) { opens.push(payload); return deferOpen ? new Promise<{ instanceId: string }>((resolve) => { defer = resolve; }) : { instanceId: payload.instanceId! }; },
async closePluginView(id: string) { closes.push(id); }, async setPluginViewBounds() { await layoutResult(); },
async setPluginViewVisibility(_id: string, visible: boolean) { visibility.push(visible); await layoutResult(); },
} });
let selectSection!: (section: 'hosts' | `plugin-vault:${string}`) => void;
function Fixture() {
const [section, select] = useState<'hosts' | `plugin-vault:${string}`>('hosts'); selectSection = select;
const model = usePluginVaultPages({ locale: 'en', section, select });
return <><aside><button data-plugin-vault-fallback onClick={() => select('hosts')}>Hosts</button><PluginVaultNavigation model={model} collapsed={false} /></aside>
<main data-section="vault-main">{model.selected ? <PluginVaultPage key={model.selected.key} model={model} /> : <p>Built-in hosts</p>}</main></>;
}
const root = createRoot(document.getElementById('root')!);
const settle = () => new Promise((resolve) => setTimeout(resolve, 35));
const clickPage = async () => { await act(async () => { document.querySelector<HTMLButtonElement>('button[aria-label="Synthetic page — Synthetic plugin"]')!.click(); await settle(); }); };
try {
activeTabStore.setActiveTabId('vault');
await act(async () => { root.render(<I18nProvider locale="en"><Fixture /></I18nProvider>); await settle(); });
assert.equal(opens.length, 0, 'catalog enumeration does not activate the plugin');
await clickPage();
assert.ok(document.querySelector('[data-section="vault-main"] [data-plugin-view-slot="vault-page"]'));
assert.equal(opens.length, 1);
assert.deepEqual(opens[0].context, { 'netcatty.surface': 'vault-page' }, 'no Vault/session data accompanies navigation');
assert.equal(visibility.at(-1), true);
const dialog = document.createElement('div'); dialog.setAttribute('role', 'dialog'); dialog.setAttribute('aria-modal', 'true');
await act(async () => { document.body.append(dialog); await settle(); });
assert.equal(visibility.at(-1), false, 'native content yields to host security/settings dialogs');
await act(async () => { dialog.remove(); await settle(); });
assert.equal(visibility.at(-1), true);
await act(async () => { activeTabStore.setActiveTabId('terminal:synthetic'); assert.ok(closes.includes(opens[0].instanceId!)); await settle(); });
await act(async () => { activeTabStore.setActiveTabId('vault'); await settle(); });
assert.equal(opens.length, 2);
await act(async () => { snapshot = { ...snapshot, plugins: [] }; changed?.(); await settle(); });
await act(async () => { await settle(); });
assert.ok(closes.includes(opens[1].instanceId!));
assert.equal(document.querySelector('[data-plugin-vault-page]'), null);
assert.equal(document.activeElement?.textContent, 'Hosts');
await act(async () => { selectSection('plugin-vault:missing'); await settle(); });
assert.match(document.querySelector('main')!.textContent!, /Built-in hosts/);
await act(async () => { snapshot = { ...snapshot, plugins: [{ id: 'com.example.page', version: '1.0.0', displayName: 'Synthetic plugin', commands: [], keybindings: [], menus: [], settings: [], views: [page] }] }; changed?.(); await settle(); });
deferOpen = true;
await act(async () => { requestOpenPluginView({ viewId: page.id }); await settle(); });
assert.equal(opens.length, 3);
await act(async () => { selectSection('hosts'); await settle(); });
assert.ok(closes.includes(opens[2].instanceId!), 'cancel reserved instance before its open response');
await act(async () => { defer!({ instanceId: opens[2].instanceId! }); await settle(); });
assert.equal(document.querySelector('[data-plugin-vault-page]'), null, 'late open cannot restore withdrawn page');
assert.ok(closes.filter((id) => id === opens[2].instanceId).length >= 2, 'late native response is also closed');
deferOpen = false;
const second = { ...page, id: 'com.example.page.second', title: 'Second page' };
await act(async () => { snapshot = { ...snapshot, plugins: [{ ...snapshot.plugins[0], views: [page, second] }] }; changed?.(); await settle(); });
delayLayout = true;
await clickPage();
assert.ok(rejectOldLayout.length >= 2, 'old page has both bounds and visibility IPC in flight');
await act(async () => { selectSection('hosts'); await settle(); });
delayLayout = false;
await act(async () => { document.querySelector<HTMLButtonElement>('button[aria-label="Second page — Synthetic plugin"]')!.click(); await settle(); });
await act(async () => { for (const reject of rejectOldLayout) reject(new Error('old page layout failed')); await settle(); });
assert.equal(document.querySelector('[data-plugin-vault-page]')?.getAttribute('aria-label'), 'Second page', 'old bounds/visibility failures cannot withdraw the newly selected page');
} finally {
await act(async () => { root.unmount(); await settle(); }); dom.window.close();
for (const [key, descriptor] of previous) { if (descriptor) Object.defineProperty(globalThis, key, descriptor); else Reflect.deleteProperty(globalThis, key); }
}
});
+6 -1
View File
@@ -1,3 +1,5 @@
import { useAppThemeStyle } from "../application/app/useAppThemeStyle";
import { selectPluginThemeTokens } from "../application/state/pluginContributionEnvironment";
/**
* Settings Page - Standalone settings window content
* This component is rendered in a separate Electron window
@@ -322,6 +324,9 @@ const SettingsSyncTabWithVault: React.FC<{ onSettingsApplied?: () => void }> = (
};
const SettingsPageContent: React.FC<{ settings: SettingsState; appLock?: AppLockState }> = ({ settings, appLock }) => {
// This publisher also runs in detached Settings: never broadcast a local preview.
const committedAppThemeStyle = useAppThemeStyle(false);
const pluginThemeTokens = useMemo(() => selectPluginThemeTokens(committedAppThemeStyle as Record<string, unknown>), [committedAppThemeStyle]);
const { t } = useI18n();
const { request, clearFocus, openSearch } = useSettingsFocus();
const { notifyRendererReady, closeSettingsWindow, onWindowCommandCloseRequested } = useWindowControls();
@@ -715,7 +720,7 @@ const SettingsPageContent: React.FC<{ settings: SettingsState; appLock?: AppLock
</Tabs>
</div>
<ExternalMcpApprovalsHost />
<PluginContributionHost locale={settings.uiLanguage} theme={settings.resolvedTheme} />
<PluginContributionHost locale={settings.uiLanguage} theme={settings.resolvedTheme} themeTokens={pluginThemeTokens} />
</>
);
};
+14
View File
@@ -123,6 +123,7 @@ import {
import { classifyDistroId, hostRestrictsExtraSshChannels, shouldProbeSessionCwd } from '../domain/host';
import {
collectSidePanelPanes,
isPluginSidePanelTool,
sidePanelLayoutHasTool,
type SidePanelLayout,
type SidePanelSplitDirection,
@@ -1933,6 +1934,16 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
resizeSidePanelSplitForTab(tabId, splitId, sizes);
}, [resizeSidePanelSplitForTab]);
const handleWithdrawPluginSidebarPane = useCallback((tabId: string, paneId: string, expectedTool: SidePanelTab) => {
if (!isPluginSidePanelTool(expectedTool)) return;
const layout = sidePanelLayoutsRef.current.get(tabId);
const pane = layout && collectSidePanelPanes(layout.root).find((item) => item.id === paneId);
if (pane?.tool !== expectedTool) return;
setMagnifiedPane((current) => current?.tabId === tabId && current.target.kind === 'side-panel'
&& current.target.paneId === paneId ? null : current);
closeSidePanelPaneForTab(tabId, paneId);
}, [closeSidePanelPaneForTab, sidePanelLayoutsRef]);
// Toggle SFTP from activity bar header
const handleToggleSftpFromBar = useCallback(() => {
handleSwitchSidePanelTab('sftp');
@@ -2617,6 +2628,9 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
handleOpenAI,
handleOpenSystem,
handleOpenNotes,
getActiveTerminalSessionId,
handleSwitchSidePanelTab,
handleWithdrawPluginSidebarPane,
handleOpenBrowser,
handleFocusSidePanelPane,
handleMagnifySidePanelPane,
+6 -2
View File
@@ -161,6 +161,7 @@ import { VaultViewLayout } from "./vault/VaultViewLayout";
import { useVaultHostCollections } from "./vault/useVaultHostCollections";
import { useVaultImportHandlers } from "../application/state/useVaultImportHandlers";
import { useVaultGroupDragHandlers } from "./vault/useVaultGroupDragHandlers";
import { usePluginVaultPages } from "../application/state/usePluginVaultPages";
const LazyProtocolSelectDialog = lazy(() => import("./ProtocolSelectDialog"));
const LazyConnectionLogsManager = lazy(() => import("./ConnectionLogsManager"));
@@ -178,7 +179,8 @@ export type VaultSection =
| "notes"
| "port"
| "knownhosts"
| "logs";
| "logs"
| `plugin-vault:${string}`;
const haveSameHostOrderResult = (previous: Host[], next: Host[]) => {
if (previous.length !== next.length) return false;
@@ -365,11 +367,12 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
onVaultFocusRequestHandled,
terminalSettings,
}) => {
const { t } = useI18n();
const { t, locale } = useI18n();
const rootRef = useRef<HTMLDivElement>(null);
const hostsRef = useRef(hosts);
hostsRef.current = hosts;
const [currentSection, setCurrentSection] = useState<VaultSection>("hosts");
const pluginVault = usePluginVaultPages({ locale, section: currentSection, select: setCurrentSection });
const [search, setSearch] = useState("");
const [selectedGroupPath, setSelectedGroupPath] = useState<string | null>(
null,
@@ -1522,6 +1525,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
ContextMenuTrigger,
Copy,
currentSection,
pluginVault,
customGroups,
deleteGroupPath,
deleteGroupPaths,
@@ -0,0 +1,56 @@
import { useMemo } from 'react';
import { Blocks } from 'lucide-react';
import { useI18n } from '../../application/i18n/I18nProvider';
import { activeTabStore } from '../../application/state/activeTabStore';
import { subscribeSidePanelLiveSnapshot } from '../../application/state/sidePanelLiveStore';
import { useEmbeddedPluginView } from '../../application/state/useEmbeddedPluginView';
import { usePluginContributions } from '../../application/state/usePluginContributions';
import { collectPluginTerminalSidebarPages, PLUGIN_TERMINAL_SIDEBAR_CONTEXT, requestOpenPluginTerminalSidebar,
type PluginTerminalSidebarPage } from '../../application/state/usePluginTerminalSidebar';
import { PluginContributionIcon } from './PluginContributionIcon';
import { Button } from '../ui/button';
import { Popover, PopoverClose, PopoverContent, PopoverTrigger } from '../ui/popover';
export function PluginTerminalSidebarLauncher({ sessionId }: { sessionId: string }) {
const { locale, t } = useI18n();
const contributions = usePluginContributions({ locale, context: PLUGIN_TERMINAL_SIDEBAR_CONTEXT });
const pages = collectPluginTerminalSidebarPages(contributions.snapshot.plugins);
if (!pages.length) return null;
return <Popover><PopoverTrigger asChild><Button variant="ghost" size="icon" className="h-6 w-6" aria-label={t('plugins.sidebar.extensions')}><Blocks size={14} /></Button></PopoverTrigger>
<PopoverContent align="end" className="max-h-80 w-64 overflow-y-auto p-1">
<p className="px-2 py-1 text-xs text-muted-foreground">{t('plugins.sidebar.extensions')}</p>
{pages.map((page) => <PopoverClose asChild key={page.tool}><button type="button" className="flex w-full items-center gap-2 rounded px-2 py-2 text-left text-sm hover:bg-accent"
onClick={() => requestOpenPluginTerminalSidebar(page.viewId, sessionId)}>
<PluginContributionIcon pluginId={page.pluginId} icon={page.icon} /><span className="min-w-0"><span className="block truncate">{page.title}</span><span className="block truncate text-xs text-muted-foreground">{page.pluginName}</span></span>
</button></PopoverClose>)}
</PopoverContent></Popover>;
}
export function PluginTerminalSidebarPageHost({ page, tabId, sessionId, adapter, getSession, unavailable, focused, focusOnReveal }: {
page: PluginTerminalSidebarPage;
tabId: string;
sessionId: string;
adapter: ReturnType<typeof usePluginContributions>;
getSession(): string | null;
unavailable(): void;
focused(): void;
focusOnReveal: boolean;
}) {
const { t } = useI18n();
const subscribeHidden = useMemo(() => (hide: () => void) => {
const stopTab = activeTabStore.subscribeSync((id) => { if (id !== tabId) hide(); });
const stopSession = subscribeSidePanelLiveSnapshot(true, () => { if (getSession() !== sessionId) hide(); });
return () => { stopTab(); stopSession(); };
}, [getSession, sessionId, tabId]);
const { mountRef, loading } = useEmbeddedPluginView({ viewId: page.viewId, version: page.version,
slot: `terminal-sidebar:${tabId}:${sessionId}`, active: true, context: PLUGIN_TERMINAL_SIDEBAR_CONTEXT,
adapter, subscribeHidden, unavailable, focused, focusOnReveal });
// The native page never receives these internal ownership IDs. Its state is
// scoped by the host; this does not authorize reading the terminal session.
return <section className="flex h-full min-h-0 flex-col overflow-hidden" aria-label={`${page.title} — ${page.pluginName}`} data-plugin-terminal-sidebar>
<header className="flex h-7 shrink-0 items-center gap-2 border-b border-border px-2 text-xs"><PluginContributionIcon pluginId={page.pluginId} icon={page.icon} size={12} /><span className="truncate">{page.title} · {page.pluginName}</span></header>
<div ref={mountRef} className="relative min-h-0 flex-1 overflow-hidden" data-plugin-view-slot="terminal-sidebar">
{loading && <p role="status" className="p-3 text-xs text-muted-foreground">{t('plugins.sidebar.loading')}</p>}
</div>
</section>;
}
+44
View File
@@ -0,0 +1,44 @@
import { activeTabStore } from '../../application/state/activeTabStore';
import { useI18n } from '../../application/i18n/I18nProvider';
import { useEmbeddedPluginView } from '../../application/state/useEmbeddedPluginView';
import type { usePluginVaultPages } from '../../application/state/usePluginVaultPages';
import { PluginContributionIcon } from './PluginContributionIcon';
import { Button } from '../ui/button';
type VaultPages = ReturnType<typeof usePluginVaultPages>;
const subscribeHidden = (hide: () => void) => activeTabStore.subscribeSync((id) => { if (id !== 'vault') hide(); });
export function PluginVaultNavigation({ model, collapsed }: { model: VaultPages; collapsed: boolean }) {
const { t } = useI18n();
if (!model.pages.length) return null;
return <div role="group" aria-label={t('plugins.vault.extensions')} className="mt-3 space-y-1 border-t border-border/50 pt-3">
{!collapsed && <p className="px-2 text-xs text-muted-foreground">{t('plugins.vault.extensions')}</p>}
{model.pages.map((page) => <Button key={page.key} type="button"
variant={model.selected?.key === page.key ? 'secondary' : 'ghost'}
className={`w-full ${collapsed ? 'justify-center px-0' : 'justify-start gap-3'}`}
title={`${page.title} — ${page.pluginName}`}
aria-label={`${page.title} — ${page.pluginName}`}
aria-current={model.selected?.key === page.key ? 'page' : undefined}
onClick={() => model.select(page.key)}>
<PluginContributionIcon pluginId={page.pluginId} icon={page.icon} className="shrink-0" />
{!collapsed && <span className="truncate">{page.title}</span>}
</Button>)}
</div>;
}
export function PluginVaultPage({ model }: { model: VaultPages }) {
const { t } = useI18n();
const page = model.selected!;
const { mountRef, loading } = useEmbeddedPluginView({ viewId: page.viewId, version: page.version, slot: 'vault-page',
active: model.active, context: model.context, adapter: model.contributions, subscribeHidden, unavailable: model.fallback });
return <section className="flex min-h-0 flex-1 flex-col" aria-label={page.title} data-plugin-vault-page={page.key}>
<header className="app-no-drag flex h-12 shrink-0 items-center justify-between gap-3 border-b border-border px-4">
<div className="min-w-0"><h2 className="truncate text-sm font-medium">{page.title}</h2>
<p className="truncate text-xs text-muted-foreground">{page.pluginName} · {page.version}</p></div>
<Button variant="ghost" onClick={model.fallback}>{t('plugins.vault.back')}</Button>
</header>
<div ref={mountRef} className="relative min-h-0 flex-1 overflow-hidden" data-plugin-view-slot="vault-page">
{loading && <p role="status" className="p-4 text-sm text-muted-foreground">{t('plugins.vault.loading')}</p>}
</div>
</section>;
}
@@ -52,7 +52,7 @@ test('information previews on hover/focus, pins on click and Escape returns focu
const entry = { id: 'com.example.guide', name: 'Guide', author: 'Example', version: '1.0.0', license: 'MIT', repository: 'example/plugin', commit: 'a'.repeat(40), description: 'Synthetic metadata',
manifest: { id: 'com.example.guide', name: 'Guide', publisher: 'Example', main: { browser: 'index.js' }, engines: { netcatty: '*', api: '0.1.0-internal' } },
} as NetcattyPluginCatalogEntry;
const catalog = { entries: [entry], source: 'registry', listStatus: 'ready', listError: null, readme: '# Guide', readmeStatus: 'ready', readmeError: null, error: null, busy: false, retryList() {}, retryReadme() {}, async install() {} } as ReturnType<typeof import('../../../application/state/usePluginCatalog').usePluginCatalog>;
const catalog = { entries: [entry], source: 'registry', sourceBinding: null, listStatus: 'ready', listError: null, readme: '# Guide', readmeStatus: 'ready', readmeError: null, error: null, busy: false, retryList() {}, retryReadme() {}, async install() {} } as ReturnType<typeof import('../../../application/state/usePluginCatalog').usePluginCatalog>;
function Browser() {
const [selected, setSelected] = useState<string | null>(null);
const [toolbar, setToolbar] = useState<HTMLDivElement | null>(null);
@@ -104,8 +104,28 @@ test('information previews on hover/focus, pins on click and Escape returns focu
await act(async () => { healthy.click(); await settle(); });
assert.match(document.querySelector('[role="dialog"]')?.textContent ?? '', /Updates are manual/);
await act(async () => { document.querySelector('[role="dialog"]')!.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true })); await settle(); });
const thirdParty: NetcattyPluginSource = { id: 'third-party:synthetic', kind: 'third-party', name: 'Independent source', origin: 'https://example.com' };
await act(async () => { root.render(<I18nProvider locale="en"><PluginMarketplaceContent marketSource={thirdParty} catalog={{ ...catalog, entries: [], listError: 'ECONNREFUSED', source: 'cached' }} installed={[]} selected={null} onSelect={() => {}} /></I18nProvider>); await settle(); });
const thirdStatus = document.querySelector<HTMLButtonElement>('button[aria-label$="Notices: 1"]')!;
await act(async () => { thirdStatus.focus(); await settle(); });
const thirdPreview = document.querySelector('[role="tooltip"]')?.textContent ?? '';
assert.match(thirdPreview, /provided by the selected third party/);
assert.match(thirdPreview, /If a previously validated catalog exists for this source/);
assert.doesNotMatch(thirdPreview, /bundled examples|Curated open-source|First-party/);
assert.equal(document.querySelector('[role="tooltip"] button'), null);
await act(async () => { thirdStatus.click(); await settle(); });
assert.match(document.querySelector('[role="dialog"]')?.textContent ?? '', /ECONNREFUSED/);
assert.match(document.querySelector('[role="dialog"]')?.textContent ?? '', /Retry/);
assert.doesNotMatch(document.querySelector('[role="dialog"]')?.textContent ?? '', /bundled examples|Curated open-source/);
await act(async () => { document.querySelector('[role="dialog"]')!.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true })); await settle(); });
await act(async () => { root.render(<I18nProvider locale="en"><PluginMarketplaceContent marketSource={thirdParty} catalog={catalog} installed={[]} selected={entry.id} onSelect={() => {}} /></I18nProvider>); await settle(); });
const sourceInfo = document.querySelector<HTMLButtonElement>('button[aria-label="Source example"]')!;
await act(async () => { sourceInfo.click(); await settle(); });
assert.match(document.querySelector('[role="dialog"]')?.textContent ?? '', /Source-only entry from this provider/);
assert.doesNotMatch(document.querySelector('[role="dialog"]')?.textContent ?? '', /First-party/);
await act(async () => { document.querySelector('[role="dialog"]')!.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true })); await settle(); });
} finally {
await act(async () => root.unmount());
await act(async () => { root.unmount(); await settle(); });
dom.window.close();
for (const [key, descriptor] of previous) {
if (descriptor) Object.defineProperty(globalThis, key, descriptor); else Reflect.deleteProperty(globalThis, key);
@@ -28,7 +28,7 @@ const entries = ['Alpha', 'Beta'].map((name) => ({
manifest: { manifestVersion: 1, id: `com.example.${name.toLowerCase()}`, name, version: '1.0.0', publisher: 'Example', engines: { netcatty: '>=1.0.0', api: '0.1.0-internal' }, main: { browser: 'index.js' }, permissions: { required: ['settings.read'] } },
})) as NetcattyPluginCatalogEntry[];
const catalog = {
entries, source: 'registry', listStatus: 'ready', listError: null, readme: '# Author documentation', readmeStatus: 'ready', readmeError: null,
entries, source: 'registry', sourceBinding: null, listStatus: 'ready', listError: null, readme: '# Author documentation', readmeStatus: 'ready', readmeError: null,
error: null, busy: false, retryList() {}, retryReadme() {}, async install() {},
} as ReturnType<typeof usePluginCatalog>;
@@ -42,7 +42,7 @@ test('catalog keeps search during selection/back, switches detail, and displays
function Harness() {
const [selected, setSelected] = useState<string | null>(null);
return <I18nProvider locale="en"><PluginMarketplaceContent catalog={catalog} selected={selected} onSelect={setSelected}
installed={[{ id: entries[0].id, activeVersion: '0.9.0', enabled: false, runtime: { lastError: 'Incompatible with this host' } } as NetcattyInstalledPlugin]} /></I18nProvider>;
installed={[{ id: entries[0].id, activeVersion: '0.9.0', enabled: false, source: { id: 'official', kind: 'official', name: 'Official', origin: 'https://plugins.netcatty.app' }, runtime: { lastError: 'Incompatible with this host' } } as NetcattyInstalledPlugin]} /></I18nProvider>;
}
try {
await act(async () => { renderer = create(<Harness />); });
@@ -158,7 +158,21 @@ test('replacing an installed plugin discloses that its running version will stop
const downloadable = { ...entries[0], artifact: { url: 'https://github.com/example/plugin/releases/download/v1/plugin.ncpkg', sha256: 'a'.repeat(64) } };
const html = renderToStaticMarkup(<I18nProvider locale="en"><PluginMarketplaceContent
catalog={{ ...catalog, entries: [downloadable] }} selected={downloadable.id} onSelect={() => {}}
installed={[{ id: downloadable.id, activeVersion: '0.9.0', enabled: true } as NetcattyInstalledPlugin]} /></I18nProvider>);
installed={[{ id: downloadable.id, activeVersion: '0.9.0', enabled: true, source: { id: 'official', kind: 'official', name: 'Official', origin: 'https://plugins.netcatty.app' } } as NetcattyInstalledPlugin]} /></I18nProvider>);
assert.match(html, /<p[^>]*>This replaces and stops the installed version\./);
assert.ok(html.indexOf('This replaces and stops') < html.indexOf('Install this version (disabled)'));
});
test('a mirror cannot claim another source installation or offer cross-source replacement', () => {
const mirror = { id: 'third-party:mirror', kind: 'third-party' as const, name: 'Netcatty', origin: 'https://mirror.example.com' };
const existing = { id: entries[0].id, activeVersion: '1.0.0', enabled: true, source: { id: 'official', kind: 'official', name: 'Official', origin: 'https://plugins.netcatty.app' } } as NetcattyInstalledPlugin;
const list = renderToStaticMarkup(<I18nProvider locale="en"><PluginMarketplaceContent catalog={catalog}
marketSource={mirror} selected={null} onSelect={() => {}} installed={[existing]} /></I18nProvider>);
assert.doesNotMatch(list, />Installed</);
const downloadable = { ...entries[0], artifact: { url: `${mirror.origin}/assets/${'a'.repeat(64)}.ncpkg`, sha256: 'a'.repeat(64) } };
const detail = renderToStaticMarkup(<I18nProvider locale="en"><PluginMarketplaceContent catalog={{ ...catalog, entries: [downloadable] }}
marketSource={mirror} selected={downloadable.id} onSelect={() => {}} installed={[existing]} /></I18nProvider>);
assert.match(detail, /installed from another source/);
assert.match(detail, /https:\/\/plugins.netcatty.app/);
assert.match(detail, /disabled=""[^>]*>Install this version/);
});
+24 -8
View File
@@ -1,5 +1,6 @@
import { createPortal } from 'react-dom';
import { PluginStatus } from './PluginStatus';
import { Dialog, DialogContent, DialogDescription, DialogTitle } from '../../ui/dialog';
import { PluginInfo } from './PluginInfo';
import React, { useRef, useState } from 'react';
import { PluginBrandIcon } from '../../plugins/PluginBrandIcon';
@@ -11,14 +12,15 @@ import { Button } from '../../ui/button';
import { PluginPackageDetails } from './PluginPackageManager';
import { PluginReadme } from './PluginReadme';
export function PluginMarketplace({ installed = [], toolbarContainer, noVault }: { noVault?: boolean; installed?: NetcattyInstalledPlugin[]; toolbarContainer?: HTMLElement | null }) {
export function PluginMarketplace({ installed = [], toolbarContainer, noVault, source }: { source?: NetcattyPluginSource; noVault?: boolean; installed?: NetcattyInstalledPlugin[]; toolbarContainer?: HTMLElement | null }) {
const [selected, setSelected] = useState<string | null>(null);
const catalog = usePluginCatalog(selected);
return <PluginMarketplaceContent catalog={catalog} installed={installed} selected={selected} onSelect={setSelected} toolbarContainer={toolbarContainer} noVault={noVault} />;
const catalog = usePluginCatalog(selected, source?.id);
return <PluginMarketplaceContent catalog={catalog} marketSource={source} installed={installed} selected={selected} onSelect={setSelected} toolbarContainer={toolbarContainer} noVault={noVault} />;
}
// The browser owns only presentation state; installation and README policy stay in the existing hook.
export function PluginMarketplaceContent({ catalog, installed, selected, onSelect, toolbarContainer, noVault }: {
export function PluginMarketplaceContent({ catalog, installed, selected, onSelect, toolbarContainer, noVault, marketSource }: {
marketSource?: NetcattyPluginSource;
noVault?: boolean;
toolbarContainer?: HTMLElement | null;
catalog: ReturnType<typeof usePluginCatalog>;
@@ -28,13 +30,19 @@ export function PluginMarketplaceContent({ catalog, installed, selected, onSelec
}) {
const { t } = useI18n();
const [search, setSearch] = useState('');
const [confirmInstall, setConfirmInstall] = useState(false);
const [clearPreviousSource, setClearPreviousSource] = useState(false);
const listRef = useRef<HTMLDivElement>(null);
const detailRef = useRef<HTMLHeadingElement>(null);
const installButtonRef = useRef<HTMLButtonElement>(null);
const lastSelection = useRef<string | null>(null);
const pageScroll = useRef(0);
const entry = catalog.entries.find((item) => item.id === selected);
const filtered = filterPluginCatalog(catalog.entries, search);
const installedEntry = installed.find((item) => item.id === entry?.id);
const sourceId = marketSource?.id ?? 'official';
const sameSource = (plugin: NetcattyInstalledPlugin) => (plugin.source?.id ?? 'local') === sourceId;
const installedEntry = installed.find((item) => item.id === entry?.id && sameSource(item));
const conflictingInstall = installed.find((item) => item.id === entry?.id && !sameSource(item));
const choose = (id: string) => {
const pane = listRef.current?.closest<HTMLElement>('[data-settings-scroll-pane]');
pageScroll.current = pane?.scrollTop ?? 0;
@@ -59,7 +67,7 @@ export function PluginMarketplaceContent({ catalog, installed, selected, onSelec
};
const refresh = !entry && <Button variant="outline" size="sm" disabled={catalog.listStatus === 'loading'} onClick={catalog.retryList}>{t('common.refresh')}</Button>;
return <section className="plugin-marketplace space-y-4" aria-label={t('settings.plugins.market.title')}>
{toolbarContainer ? createPortal(<><PluginStatus noVault={noVault} offlineError={catalog.listError} retry={catalog.retryList} />{refresh}</>, toolbarContainer) : <div className="flex items-center gap-2"><PluginStatus noVault={noVault} offlineError={catalog.listError} retry={catalog.retryList} />{refresh}</div>}
{toolbarContainer ? createPortal(<><PluginStatus sourceKind={marketSource?.kind} noVault={noVault} offlineError={catalog.listError} retry={catalog.retryList} />{refresh}</>, toolbarContainer) : <div className="flex items-center gap-2"><PluginStatus sourceKind={marketSource?.kind} noVault={noVault} offlineError={catalog.listError} retry={catalog.retryList} />{refresh}</div>}
{catalog.error && <p role="alert" className="text-xs text-destructive">{catalog.error}</p>}
<div className="plugin-marketplace-browser">
{!entry && <div className="plugin-marketplace-catalog space-y-3" ref={listRef}>
@@ -70,7 +78,7 @@ export function PluginMarketplaceContent({ catalog, installed, selected, onSelec
{catalog.listStatus === 'error' && <div role="alert" className="space-y-2 text-xs text-destructive"><p>{catalog.listError}</p><Button variant="outline" size="sm" onClick={catalog.retryList}>{t('settings.plugins.market.retry')}</Button></div>}
{catalog.listStatus === 'ready' && catalog.entries.length === 0 && <p className="text-xs text-muted-foreground">{t('settings.plugins.market.empty')}</p>}
<div className="plugin-marketplace-grid">{filtered.map((item) => {
const isInstalled = installed.some((plugin) => plugin.id === item.id);
const isInstalled = installed.some((plugin) => plugin.id === item.id && sameSource(plugin));
return <button key={item.id} type="button" data-plugin-id={item.id} aria-current={selected === item.id ? 'true' : undefined} onClick={() => choose(item.id)}
className="plugin-card min-w-0 flex min-h-[200px] flex-col gap-3 rounded-lg bg-card p-4 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
<div className="flex items-center gap-3"><span className="flex h-8 w-8 shrink-0 items-center justify-center"><PluginBrandIcon entry={item} size={32} className="text-muted-foreground" /></span><span className="min-w-0 break-words text-sm font-semibold leading-5">{item.name}</span></div>
@@ -89,7 +97,8 @@ export function PluginMarketplaceContent({ catalog, installed, selected, onSelec
{installedEntry?.runtime?.lastError && <p role="status" className="break-words text-xs text-destructive">{t('settings.plugins.manager.lastError')}: {installedEntry.runtime.lastError}</p>}
<p className="text-sm leading-6 text-muted-foreground">{entry.description}</p>
<a className="block break-all text-xs text-primary underline underline-offset-4" href={`https://github.com/${entry.repository}/tree/${entry.commit}`} target="_blank" rel="noopener noreferrer">{entry.repository} @ {entry.commit.slice(0, 12)}</a>
{entry.artifact ? <div className="space-y-2"><p className="text-xs text-muted-foreground">{t('settings.plugins.market.installNote')}</p>{catalog.listError && <p role="status" className="text-xs text-muted-foreground">{t('settings.plugins.market.offlineShort')}</p>}<Button size="sm" disabled={catalog.busy || Boolean(catalog.listError)} onClick={() => void catalog.install(entry.id)}>{t('settings.plugins.market.install')}</Button></div> : <div className="flex items-center gap-1 text-xs text-muted-foreground"><span>{t('settings.plugins.market.sourceBadge')}</span><PluginInfo label={t('settings.plugins.market.sourceBadge')}>{t('settings.plugins.market.sourceOnly')}</PluginInfo></div>}
{conflictingInstall && <p role="status" className="text-xs text-muted-foreground">{t('settings.plugins.sources.conflict')} {conflictingInstall.source?.origin ?? t('settings.plugins.sources.local')}</p>}
{entry.artifact ? <div className="space-y-2"><p className="text-xs text-muted-foreground">{t('settings.plugins.market.installNote')}</p>{catalog.listError && <p role="status" className="text-xs text-muted-foreground">{t('settings.plugins.market.offlineShort')}</p>}<Button ref={installButtonRef} size="sm" disabled={catalog.busy || Boolean(catalog.listError) || Boolean(conflictingInstall)} onClick={() => { if (marketSource?.kind === 'third-party' || (catalog.sourceBinding && catalog.sourceBinding.id !== (marketSource?.id ?? 'official'))) { setClearPreviousSource(false); setConfirmInstall(true); } else void catalog.install(entry.id); }}>{t('settings.plugins.market.install')}</Button></div> : <div className="flex items-center gap-1 text-xs text-muted-foreground"><span>{t('settings.plugins.market.sourceBadge')}</span><PluginInfo label={t('settings.plugins.market.sourceBadge')}>{t(marketSource?.kind === 'third-party' ? 'settings.plugins.sources.sourceOnly' : 'settings.plugins.market.sourceOnly')}</PluginInfo></div>}
<section><h4 className="mb-3 text-sm font-semibold">{t('settings.plugins.market.manifest')}</h4><PluginPackageDetails plugin={{ id: entry.id, activeVersion: entry.version, enabled: false, manifest: entry.manifest, archiveSha256: entry.artifact?.sha256, runtime: { status: 'stopped', kind: null, lastError: null, quarantinedAt: null } }} /></section>
<div className="min-w-0 border-t pt-4"><div className="flex items-center gap-1"><h4 className="text-sm font-semibold">README</h4><PluginInfo label={t('settings.plugins.market.readmeInfo')}>{t('settings.plugins.market.readmeNote')}</PluginInfo></div>{catalog.readmeStatus === 'loading' && <p className="mt-3 text-xs text-muted-foreground">{t('settings.plugins.loading')}</p>}
{catalog.readmeStatus === 'error' && <div role="alert" className="mt-3 space-y-2 text-xs text-destructive"><p>{catalog.readmeError}</p><Button variant="outline" size="sm" onClick={catalog.retryReadme}>{t('settings.plugins.market.retry')}</Button></div>}
@@ -97,6 +106,13 @@ export function PluginMarketplaceContent({ catalog, installed, selected, onSelec
</div>
</article>}
</div>
<Dialog open={confirmInstall && Boolean(entry)} onOpenChange={setConfirmInstall}><DialogContent className="max-h-[85vh] overflow-y-auto" onCloseAutoFocus={(event) => { event.preventDefault(); installButtonRef.current?.focus(); }}>
<DialogTitle>{t('settings.plugins.sources.installConfirm')}</DialogTitle><DialogDescription>{t('settings.plugins.sources.risk')}</DialogDescription>
<p className="break-all text-sm font-medium">{marketSource?.origin}</p>
{entry && <PluginPackageDetails plugin={{ id: entry.id, activeVersion: entry.version, enabled: false, manifest: entry.manifest, source: marketSource, archiveSha256: entry.artifact?.sha256, runtime: { status: 'stopped', kind: null, lastError: null, quarantinedAt: null } }} />}
{catalog.sourceBinding && catalog.sourceBinding.id !== (marketSource?.id ?? 'official') && <label className="flex items-start gap-2 text-xs"><input type="checkbox" checked={clearPreviousSource} onChange={(event) => setClearPreviousSource(event.target.checked)} />{t('settings.plugins.sources.clearPrivate')}<span className="break-all">{catalog.sourceBinding.origin ?? t('settings.plugins.sources.local')}</span></label>}
<Button disabled={catalog.busy || Boolean(catalog.listError) || Boolean(catalog.sourceBinding && catalog.sourceBinding.id !== (marketSource?.id ?? 'official') && !clearPreviousSource)} onClick={() => { if (entry) void catalog.install(entry.id, marketSource?.id, clearPreviousSource).then(() => setConfirmInstall(false)); }}>{t('settings.plugins.market.install')}</Button>
</DialogContent></Dialog>
</section>;
}
@@ -28,7 +28,7 @@ export function PluginPackageDetails({ plugin }: { plugin: NetcattyInstalledPlug
<dt className="text-muted-foreground">API</dt><dd>{manifest.engines.api}</dd>
<dt className="text-muted-foreground">SHA-256</dt><dd className="break-all font-mono">{plugin.archiveSha256 ?? '-'}</dd>
</dl>
<p className={!policy.browserOnly ? 'text-destructive' : 'text-muted-foreground'}>{t(`settings.plugins.manager.${runtimeLabel}`)}</p>
<p className={!policy.browserOnly && !policy.dataOnly ? 'text-destructive' : 'text-muted-foreground'}>{policy.dataOnly ? t('settings.plugins.manager.dataTheme') : t(`settings.plugins.manager.${runtimeLabel}`)}</p>
<div className="space-y-2">
<h4 className="font-medium">{t('settings.plugins.manager.permissions')}</h4>
<p className="text-xs text-muted-foreground">{t('settings.plugins.manager.permissionNote')}</p>
@@ -53,6 +53,7 @@ export function PluginPackageManager({ manager, onSelectionChange }: { manager:
const { t, resolvedLocale } = useI18n();
const [selected, setSelected] = useState<string | null>(null);
const [search, setSearch] = useState('');
const [sourceFilter, setSourceFilter] = useState('');
const rootRef = useRef<HTMLElement>(null);
const scrollTop = useRef(0);
const lastSelection = useRef<string | null>(null);
@@ -72,11 +73,13 @@ export function PluginPackageManager({ manager, onSelectionChange }: { manager:
else Array.from(rootRef.current?.querySelectorAll<HTMLButtonElement>('[data-plugin-card]') ?? []).find((button) => button.dataset.pluginCard === lastSelection.current)?.focus({ preventScroll: true });
});
};
const visible = selectedPlugin ? [selectedPlugin] : manager.plugins.filter((plugin) => [nameOf(plugin), plugin.manifest?.publisher, plugin.manifest?.description, plugin.id].some((value) => JSON.stringify(value ?? '').toLocaleLowerCase().includes(search.trim().toLocaleLowerCase())));
const visible = selectedPlugin ? [selectedPlugin] : manager.plugins.filter((plugin) => (!sourceFilter || (plugin.source?.id ?? 'local') === sourceFilter) && [nameOf(plugin), plugin.manifest?.publisher, plugin.manifest?.description, plugin.id].some((value) => JSON.stringify(value ?? '').toLocaleLowerCase().includes(search.trim().toLocaleLowerCase())));
const [removing, setRemoving] = useState<string | null>(null);
const [clearSource, setClearSource] = useState(false);
if (!manager.available) return null;
return <section ref={rootRef} className="space-y-4" aria-label={t('settings.plugins.manager.installed')}>
{selectedPlugin && <Button variant="ghost" size="sm" onClick={() => choose(null)}><ArrowLeft size={14} className="mr-2" />{t('settings.plugins.manager.installed')}</Button>}
{!selectedPlugin && manager.plugins.length > 0 && <select aria-label={t('settings.plugins.sources.source')} value={sourceFilter} onChange={(event) => setSourceFilter(event.target.value)} className="h-9 rounded-md border border-input bg-background px-3 text-sm"><option value="">{t('settings.plugins.sources.all')}</option>{[...new Map(manager.plugins.map((plugin) => [plugin.source?.id ?? 'local', plugin.source])).entries()].map(([id, source]) => <option key={id} value={id}>{source?.origin ?? t('settings.plugins.sources.local')}</option>)}</select>}
{!selectedPlugin && manager.plugins.length > 0 && <Input value={search} onChange={(event) => setSearch(event.target.value)} placeholder={t('settings.plugins.market.search')} aria-label={t('settings.plugins.market.search')} />}
{!selectedPlugin && manager.plugins.length > 0 && visible.length === 0 && <p className="p-6 text-center text-sm text-muted-foreground">{t('settings.plugins.market.noResults')}</p>}
{manager.error && <p role="alert" className="text-sm text-destructive">{manager.error}</p>}
@@ -99,6 +102,7 @@ export function PluginPackageManager({ manager, onSelectionChange }: { manager:
{t(manager.statusKnown === false ? 'settings.plugins.permissions.statusUnknown' : plugin.enabled ? 'settings.plugins.manager.enabled' : 'settings.plugins.manager.disabled')}
</Button>
</div>
<p className="break-all text-xs text-muted-foreground">{t('settings.plugins.sources.source')}: {t(plugin.source?.kind === 'official' ? 'settings.plugins.sources.official' : plugin.source?.kind === 'third-party' ? 'settings.plugins.sources.thirdparty' : 'settings.plugins.sources.local')}{plugin.source?.origin ? ` · ${plugin.source.origin}` : ''}</p>
{plugin.manifest?.publisher && <p className="break-words text-xs text-muted-foreground">{plugin.manifest.publisher}</p>}
<p className={`break-words text-sm leading-5 text-muted-foreground ${selectedPlugin ? 'whitespace-pre-wrap' : 'line-clamp-3'}`}>{descriptionOf(plugin) || t('settings.plugins.manager.noDescription')}</p>
{!selectedPlugin ? <div className="mt-auto flex flex-wrap items-center justify-between gap-2 pt-1">
@@ -111,9 +115,10 @@ export function PluginPackageManager({ manager, onSelectionChange }: { manager:
<PluginPermissionPanel key={plugin.id} pluginId={plugin.id} onChanged={async () => { await manager.refresh(); }} />
{removing === plugin.id ? <div className="space-y-2 rounded-lg border border-destructive/30 p-3">
<p className="text-xs">{t('settings.plugins.manager.removeNote')}</p>
<Button variant="destructive" size="sm" disabled={manager.busy} onClick={() => { void manager.uninstall(plugin.id); setRemoving(null); }}>{t('settings.plugins.manager.remove')}</Button>
<label className="flex items-start gap-2 text-xs"><input type="checkbox" checked={clearSource} onChange={(event) => setClearSource(event.target.checked)} />{t('settings.plugins.sources.clearPrivate')}</label>
<Button variant="destructive" size="sm" disabled={manager.busy} onClick={() => { void manager.uninstall(plugin.id, clearSource); setRemoving(null); }}>{t('settings.plugins.manager.remove')}</Button>
<Button variant="ghost" size="sm" onClick={() => setRemoving(null)}>{t('common.cancel')}</Button>
</div> : <Button variant="ghost" size="sm" disabled={manager.busy} onClick={() => setRemoving(plugin.id)}>{t('settings.plugins.manager.remove')}</Button>}
</div> : <Button variant="ghost" size="sm" disabled={manager.busy} onClick={() => { setClearSource(false); setRemoving(plugin.id); }}>{t('settings.plugins.manager.remove')}</Button>}
</>}
</article>)}
</div>
@@ -56,7 +56,7 @@ export function PluginPermissionPanel({ pluginId, onChanged }: {
pending.current = false; setBusy(false);
}
};
return <section className="space-y-3 border-t border-border/60 pt-4 text-sm" aria-label={t('settings.plugins.permissions.title')}>
return <section data-host-security-surface="" className="space-y-3 border-t border-border/60 pt-4 text-sm" aria-label={t('settings.plugins.permissions.title')}>
<div className="flex flex-wrap items-center justify-between gap-2">
<h4 className="font-medium">{t('settings.plugins.permissions.title')}</h4>
<Button variant="ghost" size="sm" disabled={busy} onClick={() => void refresh()}>{t('settings.plugins.permissions.refresh')}</Button>
+3 -3
View File
@@ -3,12 +3,12 @@ import { useI18n } from '../../../application/i18n/I18nProvider';
import { Button } from '../../ui/button';
import { PluginInfo } from './PluginInfo';
export function PluginStatus({ noVault, offlineError, retry }: { noVault?: boolean; offlineError?: string | null; retry?: () => void }) {
export function PluginStatus({ noVault, offlineError, retry, sourceKind }: { sourceKind?: NetcattyPluginSource['kind']; noVault?: boolean; offlineError?: string | null; retry?: () => void }) {
const { t } = useI18n();
const count = Number(Boolean(noVault)) + Number(Boolean(offlineError));
const explanation = <>{noVault && <p>{t('settings.plugins.manager.noVault')}</p>}{offlineError && <><p>{t('settings.plugins.market.offline')}</p><p className="break-words">{offlineError}</p></>}</>;
const explanation = <>{noVault && <p>{t('settings.plugins.manager.noVault')}</p>}{offlineError && <><p>{t(sourceKind === 'third-party' ? 'settings.plugins.sources.offline' : 'settings.plugins.market.offline')}</p><p className="break-words">{offlineError}</p></>}</>;
const label = count ? `${t('settings.plugins.info')} · ${t('settings.plugins.status', { count })}` : t('settings.plugins.info');
const help = <><p>{t('settings.plugins.manager.preview')}</p><p>{t('settings.plugins.market.note')}</p></>;
const help = <><p>{t('settings.plugins.manager.preview')}</p><p>{t(sourceKind === 'third-party' ? 'settings.plugins.sources.note' : 'settings.plugins.market.note')}</p></>;
const preview = <>{count > 0 && <p>{t('settings.plugins.status', { count })}</p>}{explanation}{help}</>;
return <PluginInfo label={label} badgeCount={count} previewContent={preview}>{explanation}{offlineError && retry && <Button variant="outline" size="sm" onClick={retry}>{t('settings.plugins.market.retry')}</Button>}{count > 0 && <hr className="border-border/60" />}{help}</PluginInfo>;
}
@@ -0,0 +1,230 @@
import assert from "node:assert/strict";
import test from "node:test";
import { JSDOM } from "jsdom";
import type {
ThemePackCatalog,
ThemePackEntry,
} from "@netcatty/plugin-contract";
test("host theme picker previews without persistence, applies, restores, and cancels stale owners safely", async () => {
const dom = new JSDOM(
'<!doctype html><html><body><div id="root"></div></body></html>',
{ url: "http://localhost", pretendToBeVisual: true },
);
const previous = new Map<string, PropertyDescriptor | undefined>();
for (const key of [
"window",
"document",
"navigator",
"HTMLElement",
"Element",
"Node",
"Event",
"MouseEvent",
"IS_REACT_ACT_ENVIRONMENT",
]) {
previous.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
Object.defineProperty(globalThis, key, {
configurable: true,
writable: true,
value:
key === "IS_REACT_ACT_ENVIRONMENT"
? true
: key === "window"
? dom.window
: dom.window[key as keyof typeof dom.window],
});
}
const { default: React, act, useLayoutEffect } = await import("react");
const { createRoot } = await import("react-dom/client");
const { I18nProvider } = await import(
"../../../application/i18n/I18nProvider"
);
const { PluginThemePicker } = await import("./PluginThemePicker");
const { pluginThemeRegistry: registry } = await import(
"../../../infrastructure/config/pluginThemeRegistry"
);
const { usePluginThemeRevision } = await import(
"../../../application/state/usePluginThemeRuntime"
);
const { getUiThemeById } = await import(
"../../../infrastructure/config/uiThemes"
);
const { applyThemeTokens } = await import(
"../../../application/state/settingsStateDefaults"
);
const entry: ThemePackEntry = {
ref: {
pluginId: "com.example.theme",
themeId: "com.example.theme.forest",
version: "1.0.0",
digest: "a".repeat(64),
variant: "light",
},
label: "Forest",
pluginName: "Synthetic Theme",
dataOnly: true,
palette: { formatVersion: 1, base: "light", colors: { accent: "#166534" } },
};
let writes = 0,
restores = 0;
let catalog: ThemePackCatalog = {
selection: {
formatVersion: 1,
revision: registry.getCatalog().selection.revision + 1,
light: null,
dark: null,
},
entries: [entry],
diagnostics: [],
};
registry.setCatalog(catalog);
Object.assign(dom.window, {
netcatty: {
async applyPluginTheme(request: {
expectedRevision: number;
choice: ThemePackCatalog["selection"]["light"];
}) {
assert.equal(request.expectedRevision, catalog.selection.revision);
writes++;
catalog = {
...catalog,
selection: {
...catalog.selection,
revision: catalog.selection.revision + 1,
light: request.choice,
},
};
registry.setCatalog(catalog);
return catalog;
},
},
});
function Fixture() {
const revision = usePluginThemeRevision();
useLayoutEffect(
() =>
applyThemeTokens(
"light",
"light",
getUiThemeById("light", "snow").tokens,
"theme",
"",
),
[revision],
);
return (
<I18nProvider locale="en">
<PluginThemePicker mode="light" onRestore={() => restores++} />
</I18nProvider>
);
}
const root = createRoot(document.getElementById("root")!);
const button = (label: string) =>
[...document.querySelectorAll<HTMLButtonElement>("button")].find(
(b) => b.textContent === label,
)!;
try {
await act(async () => root.render(<Fixture />));
const original =
document.documentElement.style.getPropertyValue("--primary");
await act(async () => {
const select = document.querySelector("select")!;
select.value = `${entry.ref.pluginId}/${entry.ref.themeId}`;
select.dispatchEvent(new Event("change", { bubbles: true }));
});
await act(async () => button("Preview").click());
assert.equal(writes, 0);
assert.equal(
getUiThemeById("light", "snow", false).tokens.primary,
original,
"local preview does not enter the global plugin view environment",
);
assert.notEqual(
document.documentElement.style.getPropertyValue("--primary"),
original,
);
assert.ok(
document.documentElement.hasAttribute("data-plugin-theme-active"),
);
assert.ok(
document.documentElement.style.getPropertyValue(
"--host-security-primary",
),
);
await act(async () => button("Cancel preview").click());
assert.equal(
document.documentElement.style.getPropertyValue("--primary"),
original,
);
assert.equal(writes, 0);
await act(async () => {
button("Preview").click();
button("Apply").click();
});
assert.equal(writes, 1);
assert.equal(catalog.selection.light?.ref.themeId, entry.ref.themeId);
await act(async () => button("Restore default").click());
assert.equal(writes, 2);
assert.equal(restores, 1);
assert.equal(catalog.selection.light, null);
await act(async () => {
registry.startPreview("old-owner", entry);
registry.startPreview("new-owner", entry);
registry.cancelPreview("old-owner");
});
assert.notEqual(
document.documentElement.style.getPropertyValue("--primary"),
original,
);
await act(async () =>
registry.setCatalog({
...catalog,
selection: {
...catalog.selection,
revision: catalog.selection.revision + 1,
},
entries: [],
}),
);
assert.equal(
document.documentElement.style.getPropertyValue("--primary"),
original,
"package withdrawal cancels previews",
);
await act(async () =>
registry.setCatalog({
...catalog,
selection: {
...catalog.selection,
revision: catalog.selection.revision + 2,
light: { ref: entry.ref },
},
entries: [],
}),
);
assert.equal(getUiThemeById("light", "snow").id, "host-plugin-fallback");
await act(async () => registry.setHighContrast(true));
assert.equal(getUiThemeById("light", "snow").id, "host-high-contrast");
} finally {
await act(async () => {
root.unmount();
registry.setHighContrast(false);
registry.setCatalog({
...catalog,
selection: {
formatVersion: 1,
revision: registry.getCatalog().selection.revision + 1,
light: null,
dark: null,
},
entries: [],
});
});
dom.window.close();
for (const [key, descriptor] of previous) {
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
else Reflect.deleteProperty(globalThis, key);
}
}
});
@@ -0,0 +1,166 @@
import { useEffect, useId, useState } from "react";
import { useI18n } from "../../../application/i18n/I18nProvider";
import {
applyPluginThemeSelection,
usePluginThemeRevision,
} from "../../../application/state/usePluginThemeRuntime";
import { pluginThemeRegistry } from "../../../infrastructure/config/pluginThemeRegistry";
import { Button } from "../../ui/button";
export function PluginThemePicker({
mode,
onRestore,
}: {
mode: "light" | "dark";
onRestore: () => void;
}) {
const { t } = useI18n();
usePluginThemeRevision();
const owner = useId();
const [selected, setSelected] = useState(""),
[pending, setPending] = useState(false),
[error, setError] = useState("");
const catalog = pluginThemeRegistry.getCatalog();
const entries = catalog.entries.filter((e) => e.palette.base === mode);
const applied = catalog.selection[mode];
const appliedAvailable =
applied &&
entries.some(
(e) =>
e.ref.pluginId === applied.ref.pluginId &&
e.ref.themeId === applied.ref.themeId &&
e.ref.version === applied.ref.version &&
e.ref.digest === applied.ref.digest,
);
const entry = entries.find(
(e) => `${e.ref.pluginId}/${e.ref.themeId}` === selected,
);
useEffect(
() => () => pluginThemeRegistry.cancelPreview(owner),
[owner, mode],
);
async function apply(restore = false) {
if (pending || (!restore && !entry)) return;
setPending(true);
setError("");
try {
await applyPluginThemeSelection(
mode,
restore ? null : { ref: entry!.ref },
);
pluginThemeRegistry.cancelPreview(owner);
if (restore) {
setSelected("");
onRestore();
}
} catch (e) {
setError(
e instanceof Error ? e.message : t("settings.plugins.themes.failed"),
);
} finally {
setPending(false);
}
}
return (
<section
className="space-y-3 py-3"
aria-label={t("settings.plugins.themes.title")}
>
<div>
<h4 className="text-sm font-medium">
{t("settings.plugins.themes.title")}
</h4>
<p className="text-xs text-muted-foreground">
{t("settings.plugins.themes.description")}
</p>
</div>
{entries.length === 0 ? (
<p className="text-xs text-muted-foreground">
{t("settings.plugins.themes.empty")}
</p>
) : (
<select
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
aria-label={t("settings.plugins.themes.choose")}
value={entry ? selected : ""}
disabled={pending}
onChange={(e) => {
pluginThemeRegistry.cancelPreview(owner);
setSelected(e.target.value);
setError("");
}}
>
<option value="">{t("settings.plugins.themes.choose")}</option>
{entries.map((e) => (
<option
key={`${e.ref.pluginId}/${e.ref.themeId}`}
value={`${e.ref.pluginId}/${e.ref.themeId}`}
>
{e.label} — {e.pluginName} {e.ref.version}
</option>
))}
</select>
)}
{catalog.selection[mode] && (
<p className="text-xs text-muted-foreground">
{t(
appliedAvailable
? "settings.plugins.themes.applied"
: "settings.plugins.themes.unavailable",
)}
: {catalog.selection[mode]!.ref.themeId} ·{" "}
{catalog.selection[mode]!.ref.version}
</p>
)}
<div className="flex flex-wrap gap-2">
<Button
size="sm"
variant="outline"
disabled={!entry || pending}
onClick={() => {
if (entry) pluginThemeRegistry.startPreview(owner, entry);
}}
>
{t("settings.plugins.themes.preview")}
</Button>
<Button
size="sm"
disabled={!entry || pending}
onClick={() => {
void apply();
}}
>
{t("settings.plugins.themes.apply")}
</Button>
<Button
size="sm"
variant="ghost"
disabled={pending}
onClick={() => pluginThemeRegistry.cancelPreview(owner)}
>
{t("settings.plugins.themes.cancel")}
</Button>
<Button
size="sm"
variant="outline"
disabled={pending}
onClick={() => {
void apply(true);
}}
>
{t("settings.plugins.themes.restore")}
</Button>
</div>
{catalog.diagnostics.length > 0 && (
<p className="text-xs text-muted-foreground">
{t("settings.plugins.themes.invalid")}
</p>
)}
{error && (
<p role="alert" className="text-xs text-destructive">
{error}
</p>
)}
</section>
);
}
@@ -0,0 +1,55 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { JSDOM } from 'jsdom';
test('source management requires consent and Escape restores either opener inside the plugin window', async () => {
const dom = new JSDOM('<!doctype html><html><body><div id="root"></div></body></html>', { pretendToBeVisual: true, url: 'http://localhost' });
const previous = new Map<string, PropertyDescriptor | undefined>();
const install = (key: string, value: unknown) => {
previous.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
Object.defineProperty(globalThis, key, { configurable: true, writable: true, value });
};
for (const key of ['window', 'document', 'navigator', 'HTMLElement', 'HTMLInputElement', 'Element', 'Node', 'NodeFilter', 'MutationObserver', 'CustomEvent', 'Event', 'KeyboardEvent', 'MouseEvent']) {
install(key, key === 'window' ? dom.window : dom.window[key as keyof typeof dom.window]);
}
install('getComputedStyle', dom.window.getComputedStyle.bind(dom.window));
install('requestAnimationFrame', dom.window.requestAnimationFrame.bind(dom.window));
install('cancelAnimationFrame', dom.window.cancelAnimationFrame.bind(dom.window));
install('IS_REACT_ACT_ENVIRONMENT', true);
// Install a synthetic preload surface on the isolated DOM window.
Object.defineProperty(dom.window, 'netcatty', { value: { listPluginSources: async () => [] }, configurable: true });
const { default: React, act } = await import('react');
const { createRoot } = await import('react-dom/client');
const { PluginThirdPartyMarket } = await import('./PluginThirdPartyMarket');
const { PluginManagementDialog } = await import('./PluginManagementDialog');
const { I18nProvider } = await import('../../../application/i18n/I18nProvider');
const root = createRoot(document.getElementById('root')!);
const settle = () => new Promise((resolve) => setTimeout(resolve, 60));
try {
await act(async () => root.render(<I18nProvider locale="en"><PluginManagementDialog><PluginThirdPartyMarket installed={[]} /></PluginManagementDialog></I18nProvider>));
await act(async () => { document.querySelector<HTMLButtonElement>('button')!.click(); await settle(); });
for (const label of ['Manage sources', 'Add source']) {
const opener = [...document.querySelectorAll<HTMLButtonElement>('button')].find((button) => button.textContent === label)!;
assert.ok(opener);
await act(async () => { opener.focus(); opener.click(); await settle(); });
assert.equal(document.querySelectorAll('[role="dialog"]').length, 2);
const dialogs = document.querySelectorAll<HTMLElement>('[role="dialog"]');
const dialog = dialogs[dialogs.length - 1];
assert.match(dialog.textContent ?? '', /real domain/);
assert.equal(dialog.querySelector<HTMLInputElement>('input[type="checkbox"]')?.checked, false);
assert.equal(dialog.querySelector<HTMLButtonElement>('button[type="submit"]')?.disabled, true);
await act(async () => { dialog.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true })); await settle(); });
assert.equal(document.querySelectorAll('[role="dialog"]').length, 1);
assert.equal(document.activeElement, opener);
}
} finally {
// Radix schedules unmount autofocus on a timer. Drain it while the DOM's
// Event constructor is still installed (Node 22 has a distinct global Event).
await act(async () => { root.unmount(); await settle(); });
dom.window.close();
for (const [key, descriptor] of previous) {
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
else Reflect.deleteProperty(globalThis, key);
}
}
});
@@ -0,0 +1,59 @@
import React, { useRef, useState } from 'react';
import { usePluginSources } from '../../../application/state/usePluginSources';
import { useI18n } from '../../../application/i18n/I18nProvider';
import { Button } from '../../ui/button';
import { Input } from '../../ui/input';
import { Dialog, DialogContent, DialogDescription, DialogTitle } from '../../ui/dialog';
import { PluginMarketplace } from './PluginMarketplace';
export function PluginThirdPartyMarket({ installed, toolbarContainer, noVault }: {
installed: NetcattyInstalledPlugin[]; toolbarContainer?: HTMLElement | null; noVault?: boolean;
}) {
const { t } = useI18n();
const model = usePluginSources();
const [selected, setSelected] = useState('');
const [visited, setVisited] = useState<string[]>([]);
const [managing, setManaging] = useState(false);
const opener = useRef<HTMLButtonElement | null>(null);
const openManager = (event: React.MouseEvent<HTMLButtonElement>) => { opener.current = event.currentTarget; setManaging(true); };
const [name, setName] = useState('');
const [address, setAddress] = useState('');
const [accepted, setAccepted] = useState(false);
const [removing, setRemoving] = useState<string | null>(null);
const [disablePlugins, setDisablePlugins] = useState(false);
const sources = model.sources.filter((source) => source.kind === 'third-party');
const source = sources.find((item) => item.id === selected && item.enabled);
let domain = '';
try { domain = new URL(address).host; } catch { /* Host validates before saving. */ }
return <div className="space-y-4">
<div className="flex flex-wrap items-center gap-3">
<select aria-label={t('settings.plugins.sources.select')} value={selected} onChange={(event) => { setSelected(event.target.value); setVisited((items) => [...new Set([...items, event.target.value])]); }} className="h-9 min-w-0 rounded-md border border-input bg-background px-3 text-sm">
<option value="">{t('settings.plugins.sources.select')}</option>
{sources.filter((item) => item.enabled).map((item) => <option key={item.id} value={item.id}>{item.name} — {new URL(item.origin!).hostname}</option>)}
</select>
<Button variant="outline" size="sm" onClick={openManager}>{t('settings.plugins.sources.manage')}</Button>
</div>
{!source && <div className="py-8 text-center text-sm text-muted-foreground"><p>{t('settings.plugins.sources.empty')}</p><Button className="mt-3" variant="outline" onClick={openManager}>{t('settings.plugins.sources.add')}</Button></div>}
{/* Keep each visited source mounted to preserve its own selection/search/scroll. */}
{sources.filter((item) => item.enabled && (item.id === selected || visited.includes(item.id))).map((item) => {
return <div key={item.id} hidden={item.id !== source?.id}><PluginMarketplace source={item} installed={installed} toolbarContainer={item.id === source?.id ? toolbarContainer : undefined} noVault={noVault} /></div>;
})}
<Dialog open={managing} onOpenChange={setManaging}><DialogContent className="max-h-[85vh] overflow-y-auto" onCloseAutoFocus={(event) => { event.preventDefault(); opener.current?.focus(); }}>
<DialogTitle>{t('settings.plugins.sources.manage')}</DialogTitle><DialogDescription>{t('settings.plugins.sources.risk')}</DialogDescription>
{model.error && <p role="alert" className="text-sm text-destructive">{model.error}</p>}
<div className="space-y-3">{sources.map((item) => <div key={item.id} className="space-y-2 rounded-lg bg-muted/30 p-3">
<Input aria-label={t('settings.plugins.sources.name')} defaultValue={item.name} onBlur={(event) => { if (event.target.value !== item.name) void model.update(item, event.target.value, Boolean(item.enabled)); }} disabled={model.busy} />
<p className="break-all text-xs text-muted-foreground">{item.origin}</p>
<div className="flex items-center justify-between"><label className="flex items-center gap-2 text-sm"><input type="checkbox" checked={Boolean(item.enabled)} disabled={model.busy} onChange={(event) => void model.update(item, item.name, event.target.checked)} />{t('settings.plugins.manager.enabled')}</label><Button variant="ghost" size="sm" disabled={model.busy} onClick={() => { setRemoving(item.id); setDisablePlugins(false); }}>{t('settings.plugins.sources.remove')}</Button></div>
{removing === item.id && <div className="space-y-2 text-sm"><p>{t('settings.plugins.sources.removeNote')}</p><label className="flex items-center gap-2"><input type="checkbox" checked={disablePlugins} onChange={(event) => setDisablePlugins(event.target.checked)} />{t('settings.plugins.sources.disablePlugins')}</label><Button size="sm" variant="destructive" disabled={model.busy} onClick={() => void model.remove(item.id, disablePlugins).then((ok) => { if (ok) setRemoving(null); })}>{t('settings.plugins.sources.remove')}</Button></div>}
</div>)}</div>
<form className="space-y-3" onSubmit={(event) => { event.preventDefault(); if (accepted) void model.add(name, address).then((ok) => { if (ok) { setName(''); setAddress(''); setAccepted(false); } }); }}>
<Input required value={name} onChange={(event) => setName(event.target.value)} placeholder={t('settings.plugins.sources.name')} aria-label={t('settings.plugins.sources.name')} />
<Input required type="url" value={address} onChange={(event) => { setAddress(event.target.value); setAccepted(false); }} placeholder="https://plugins.example.com" aria-label={t('settings.plugins.sources.address')} />
{domain && <p className="break-all text-sm font-medium">{domain}</p>}
<label className="flex items-start gap-2 text-xs"><input type="checkbox" checked={accepted} onChange={(event) => setAccepted(event.target.checked)} />{t('settings.plugins.sources.accept')}</label>
<Button type="submit" disabled={model.busy || !accepted}>{t('settings.plugins.sources.add')}</Button>
</form>
</DialogContent></Dialog>
</div>;
}
@@ -1,3 +1,6 @@
import { PluginThemePicker } from './PluginThemePicker';
import { applyPluginThemeSelection, usePluginThemeRevision } from '../../../application/state/usePluginThemeRuntime';
import { pluginThemeRegistry } from '../../../infrastructure/config/pluginThemeRegistry';
import React, { memo, useCallback, useMemo, useState } from "react";
import { applyCustomCssToDocument } from "../../../lib/customCss";
import { DebouncedTextarea } from "../DebouncedTextarea";
@@ -70,6 +73,8 @@ function SettingsAppearanceTab(props: {
setAppIconVariant: (variant: AppIconVariant) => void;
}) {
const { t } = useI18n();
usePluginThemeRevision();
const [pluginThemeError, setPluginThemeError] = useState('');
const availableUIFonts = useAvailableUIFonts();
// Note code fonts come from the monospace-only store (fontStore); the note
// body font follows the UI font setting instead.
@@ -231,8 +236,12 @@ function SettingsAppearanceTab(props: {
);
const visibleUiThemes = resolvedTheme === "dark" ? DARK_UI_THEMES : LIGHT_UI_THEMES;
const visibleUiThemeId = resolvedTheme === "dark" ? darkUiThemeId : lightUiThemeId;
const setVisibleUiThemeId = resolvedTheme === "dark" ? setDarkUiThemeId : setLightUiThemeId;
const visibleUiThemeId = pluginThemeRegistry.isActive(resolvedTheme) ? "" : resolvedTheme === "dark" ? darkUiThemeId : lightUiThemeId;
const setVisibleUiThemeId = (id: string) => {
void applyPluginThemeSelection(resolvedTheme, null).then(() => {
(resolvedTheme === "dark" ? setDarkUiThemeId : setLightUiThemeId)(id);setPluginThemeError('');
}).catch((error: unknown) => setPluginThemeError(error instanceof Error ? error.message : t('settings.plugins.themes.failed')));
};
return (
<SettingsTabContent value="appearance">
@@ -339,6 +348,8 @@ function SettingsAppearanceTab(props: {
{renderThemeSwatches(visibleUiThemes, visibleUiThemeId, setVisibleUiThemeId)}
</div>
</SettingsAnchor>
<PluginThemePicker mode={resolvedTheme} onRestore={() => (resolvedTheme === "dark" ? setDarkUiThemeId : setLightUiThemeId)(resolvedTheme === "dark" ? "midnight" : "snow")} />
{pluginThemeError && <p role="alert" className="text-xs text-destructive">{pluginThemeError}</p>}
<SettingRow
anchorId="appearance-accent-mode"
label={t("settings.appearance.accentColor.mode")}
@@ -1,3 +1,4 @@
import { PluginThirdPartyMarket } from './PluginThirdPartyMarket';
import { usePluginManager } from '../../../application/state/usePluginManager';
import { PluginManagementDialog } from './PluginManagementDialog';
import { PluginMarketplace } from './PluginMarketplace';
@@ -289,7 +290,7 @@ function PluginManagementContent({ handoff }: { handoff: (action: () => void) =>
const manager = usePluginManager();
const [catalogToolbar, setCatalogToolbar] = useState<HTMLDivElement | null>(null);
const [selectedInstalled, setSelectedInstalled] = useState<string | null>(null);
const [pluginView, setPluginView] = useState<'installed' | 'marketplace'>('installed');
const [pluginView, setPluginView] = useState<'installed' | 'marketplace' | 'thirdparty'>('installed');
const availableFonts = useAvailableFonts();
const scopeCatalog = usePluginSettingScopeCatalog();
const [scopeIds, setScopeIds] = useState<Partial<Record<NetcattyPluginSettingScopeKind, string>>>({});
@@ -310,11 +311,12 @@ function PluginManagementContent({ handoff }: { handoff: (action: () => void) =>
return (
<div className="space-y-5">
<Tabs value={pluginView} onValueChange={(value) => { setSelectedInstalled(null); setPluginView(value as 'installed' | 'marketplace'); }} className="space-y-5">
<Tabs value={pluginView} onValueChange={(value) => { setSelectedInstalled(null); setPluginView(value as 'installed' | 'marketplace' | 'thirdparty'); }} className="space-y-5">
<div className="flex flex-wrap items-center justify-between gap-3">
<TabsList className="h-auto flex-wrap justify-start bg-muted/50">
<TabsTrigger value="installed">{t('settings.plugins.manager.installed')} <span className="ml-2 text-muted-foreground">{manager.plugins.length}</span></TabsTrigger>
<TabsTrigger value="marketplace">{t('settings.plugins.market.title')}</TabsTrigger>
<TabsTrigger value="marketplace">{t('settings.plugins.sources.official')}</TabsTrigger>
<TabsTrigger value="thirdparty">{t('settings.plugins.sources.thirdparty')}</TabsTrigger>
</TabsList>
<div className="flex items-center gap-2">
{pluginView === 'installed' && <PluginStatus noVault={manager.credentialStorageAvailable === false} />}
@@ -322,7 +324,8 @@ function PluginManagementContent({ handoff }: { handoff: (action: () => void) =>
<Button variant="outline" size="sm" disabled={!manager.available || manager.busy} onClick={() => { setPluginView('installed'); void manager.importPackage(); }}><FolderOpen size={14} className="mr-2" />{t('settings.plugins.manager.import')}</Button>
</div>
</div>
<TabsContent value="marketplace" className="m-0 space-y-6"><PluginMarketplace installed={manager.plugins} toolbarContainer={catalogToolbar} noVault={manager.credentialStorageAvailable === false} /></TabsContent>
<TabsContent forceMount hidden={pluginView !== 'marketplace'} value="marketplace" className="m-0 space-y-6"><PluginMarketplace installed={manager.plugins} toolbarContainer={pluginView === 'marketplace' ? catalogToolbar : undefined} noVault={manager.credentialStorageAvailable === false} /></TabsContent>
<TabsContent forceMount hidden={pluginView !== 'thirdparty'} value="thirdparty" className="m-0 space-y-6"><PluginThirdPartyMarket installed={manager.plugins} toolbarContainer={pluginView === 'thirdparty' ? catalogToolbar : undefined} noVault={manager.credentialStorageAvailable === false} /></TabsContent>
<TabsContent value="installed" className="m-0 space-y-6">
<PluginPackageManager manager={manager} onSelectionChange={setSelectedInstalled} />
{contextualScopes.size > 0 && (
@@ -30,7 +30,7 @@ export const TerminalHostKeyVerification: React.FC<TerminalHostKeyVerificationPr
const Icon = isChanged ? AlertTriangle : Fingerprint;
return (
<div className="space-y-3 animate-in fade-in-0 slide-in-from-bottom-1 duration-200">
<div data-host-security-surface="" className="space-y-3 animate-in fade-in-0 slide-in-from-bottom-1 duration-200">
<div
className={cn(
"rounded-xl border px-3 py-2.5",
+2
View File
@@ -44,6 +44,7 @@ import { collectOwnedPluginMenus, comparePluginMenus, usePluginContributions } f
import { buildTerminalPluginContributionContext } from '../../application/state/pluginContributionContexts';
import { PluginContributionIcon } from '../plugins/PluginContributionIcon';
import { isPluginHostProtocol } from '../../domain/pluginConnection';
import { PluginTerminalSidebarLauncher } from '../plugins/PluginTerminalSidebar';
export const TERMINAL_TOOLBAR_ITEM_IDS = [
'highlight',
@@ -1158,6 +1159,7 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
)}
</ToolbarCustomizeContextMenu>
{!compactToolbar && <PluginTerminalSidebarLauncher sessionId={sessionId} />}
{pluginToolbarMenus.map((menu) => (
<Tooltip key={menu.id}>
<TooltipTrigger asChild>
@@ -9,7 +9,7 @@ import React, { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef,
import { createPortal } from 'react-dom';
import * as SelectPrimitive from '@radix-ui/react-select';
import { useActiveTabId } from '../../application/state/activeTabStore';
import { activeTabStore, useActiveTabId } from '../../application/state/activeTabStore';
import {
getSidePanelLiveSnapshot,
subscribeSidePanelLiveSnapshot,
@@ -48,6 +48,7 @@ import {
MAX_SIDE_PANEL_PANES,
canSplitSidePanelPaneAtSize,
collectSidePanelPanes,
isPluginSidePanelTool,
getFocusedSidePanelPane,
getSidePanelNodeMinimumPixels,
getSidePanelSplitResizeBounds,
@@ -60,7 +61,10 @@ import {
type SidePanelSplitNode,
} from '../../domain/sidePanelLayout';
import { terminalLayerSidePanelStableCtxEqual } from './terminalLayerViewMemo';
import { SidePanelMountedContent } from './terminalLayerSidePanelSlots';
import { PersistentSidePanelPortal, SidePanelMountedContent } from './terminalLayerSidePanelSlots';
import { usePluginTerminalSidebar } from '../../application/state/usePluginTerminalSidebar';
import { PluginTerminalSidebarPageHost } from '../plugins/PluginTerminalSidebar';
import { PluginContributionIcon } from '../plugins/PluginContributionIcon';
import { getPaneMagnificationShortcutLabel } from '../../domain/paneMagnification';
const MemoizedSidePanelMountedContent = memo(
@@ -358,7 +362,7 @@ function SidePanelSplitView({
);
}
function SidePanelLayoutTree({
export function SidePanelLayoutTree({
node,
layout,
paneCount,
@@ -568,6 +572,9 @@ function hasMountedSidePanelContent(ctx: SidePanelContext): boolean {
&& Array.from((sidePanelOpenTabs as Map<string, SidePanelTab>).values()).includes('history');
const anyNotesOpen = sidePanelOpenTabs instanceof Map
&& Array.from((sidePanelOpenTabs as Map<string, SidePanelTab>).values()).includes('notes');
const anyPluginOpen = ctx.sidePanelLayouts instanceof Map
&& [...(ctx.sidePanelLayouts as Map<string, SidePanelLayout>).values()]
.some((layout) => collectSidePanelPanes(layout.root).some((pane) => isPluginSidePanelTool(pane.tool)));
return !(
browserMountedTabIds.length === 0
@@ -579,10 +586,16 @@ function hasMountedSidePanelContent(ctx: SidePanelContext): boolean {
&& themeMountedTabIds.length === 0
&& !anyHistoryOpen
&& !anyNotesOpen
&& !anyPluginOpen
);
}
function TerminalLayerSidePanelSectionInner({ ctx }: { ctx: SidePanelContext }) {
const pluginSidebar = usePluginTerminalSidebar({ layouts: ctx.sidePanelLayouts ?? new Map(),
getSession: ctx.getActiveTerminalSessionId ?? (() => null),
isTerminalTab: (tabId) => (ctx.sessions ?? []).some((session: { id: string }) => session.id === tabId) || !!ctx.workspaceById?.has(tabId),
select: (tool) => ctx.handleSwitchSidePanelTab?.(tool),
withdraw: (tabId, paneId, tool) => ctx.handleWithdrawPluginSidebarPane?.(tabId, paneId, tool) });
if (!hasMountedSidePanelContent(ctx)) {
// Keep AI scope maintenance alive even with zero mounted tool panels so
// merge/dissolve handoff still runs when the user closed AI before the
@@ -604,7 +617,7 @@ function TerminalLayerSidePanelSectionInner({ ctx }: { ctx: SidePanelContext })
</AISidePanelStateRoot>
);
}
return <TerminalLayerSidePanelInner ctx={ctx} />;
return <TerminalLayerSidePanelInner ctx={ctx} pluginSidebar={pluginSidebar} />;
}
/** Skip chrome rebuilds when only live/workspace-focus ticks change. */
@@ -614,7 +627,7 @@ export const TerminalLayerSidePanelSection = memo(
);
TerminalLayerSidePanelSection.displayName = 'TerminalLayerSidePanelSection';
function TerminalLayerSidePanelInner({ ctx }: { ctx: SidePanelContext }) {
function TerminalLayerSidePanelInner({ ctx, pluginSidebar }: { ctx: SidePanelContext; pluginSidebar: ReturnType<typeof usePluginTerminalSidebar> }) {
const activeTabId = useActiveTabId();
const sidePanelOpenTabs = ctx.sidePanelOpenTabs as Map<string, SidePanelTab>;
const sidePanelLayouts = ctx.sidePanelLayouts as Map<string, SidePanelLayout>;
@@ -630,6 +643,7 @@ function TerminalLayerSidePanelInner({ ctx }: { ctx: SidePanelContext }) {
handleOpenAI,
handleOpenHistory,
handleOpenNotes,
handleSwitchSidePanelTab,
handleOpenBrowser,
handleOpenScripts,
handleOpenSystem,
@@ -1046,6 +1060,9 @@ function TerminalLayerSidePanelInner({ ctx }: { ctx: SidePanelContext }) {
{ id: 'system' as const, label: t('terminal.layer.system'), icon: <Activity size={15} />, onClick: handleOpenSystem },
{ id: 'notes' as const, label: t('terminal.layer.notes'), icon: <NotebookText size={15} />, onClick: handleOpenNotes },
{ id: 'ai' as const, label: t('terminal.layer.aiChat'), icon: <MessageSquare size={15} />, onClick: handleOpenAI },
...pluginSidebar.pages.map((page) => ({ id: page.tool, label: `${page.title} · ${page.pluginName}`,
icon: <PluginContributionIcon pluginId={page.pluginId} icon={page.icon} size={15} />,
onClick: () => handleSwitchSidePanelTab?.(page.tool) })),
], [
handleOpenAI,
handleOpenHistory,
@@ -1056,6 +1073,8 @@ function TerminalLayerSidePanelInner({ ctx }: { ctx: SidePanelContext }) {
handleOpenTheme,
handleToggleSftpFromBar,
t,
handleSwitchSidePanelTab,
pluginSidebar.pages,
]);
const sidePanelTabItemById = useMemo(
() => new Map(sidePanelTabItems.map((item) => [item.id, item])),
@@ -1491,6 +1510,19 @@ function TerminalLayerSidePanelInner({ ctx }: { ctx: SidePanelContext }) {
paneHosts={panePortalHosts}
parkingHost={parkingHost}
/>
{activeTabId && pluginSidebar.sessionId && activeSidePanelPanes.map((pane) => {
const page = pluginSidebar.pages.find((item) => item.tool === pane.tool);
const target = panePortalHosts.get(pane.tool) ?? parkingHost;
if (!page || !target) return null;
const sessionId = pluginSidebar.sessionId!;
return <PersistentSidePanelPortal key={`${activeTabId}:${pane.id}:${sessionId}`} portalKey={`plugin-${pane.id}`} target={target}>
<PluginTerminalSidebarPageHost page={page} tabId={activeTabId} sessionId={sessionId}
focusOnReveal={activeSidePanelLayout?.focusedPaneId === pane.id}
adapter={pluginSidebar.contributions} getSession={ctx.getActiveTerminalSessionId}
unavailable={() => ctx.handleWithdrawPluginSidebarPane?.(activeTabId, pane.id, page.tool)}
focused={() => { if (activeTabStore.getActiveTabId() === activeTabId) ctx.handleFocusSidePanelPane?.(pane.id); }} />
</PersistentSidePanelPortal>;
})}
</div>
</div>
</div>
@@ -32,6 +32,13 @@ test('terminal layer bridge passes vault open callbacks into the side panel cont
assert.match(source, /onOpenVaultSectionFromChat: s\.onOpenVaultSectionFromChat/);
});
test('production terminal layer context forwards extension sidebar owner and pane callbacks', () => {
const contextProjection = source.slice(source.indexOf('const ctx = useMemo('));
for (const callback of ['getActiveTerminalSessionId', 'handleSwitchSidePanelTab', 'handleWithdrawPluginSidebarPane']) {
assert.match(contextProjection, new RegExp(`${callback}: s\\.${callback}\\b`));
}
});
test('terminal layer bridge updates side panel live state after render', () => {
assert.match(source, /const sidePanelLiveSnapshot = useMemo<SidePanelLiveSnapshot>/);
assert.match(
@@ -489,6 +489,9 @@ export function TerminalLayerTabBridge({ stableRef }: { stableRef: StableRef })
handleFontWeightResetForFocusedSession: themeState.handleFontWeightResetForFocusedSession,
handleOpenAI: s.handleOpenAI,
handleOpenNotes: s.handleOpenNotes,
getActiveTerminalSessionId: s.getActiveTerminalSessionId,
handleSwitchSidePanelTab: s.handleSwitchSidePanelTab,
handleWithdrawPluginSidebarPane: s.handleWithdrawPluginSidebarPane,
handleOpenBrowser: s.handleOpenBrowser,
handleOpenSystem: s.handleOpenSystem,
handleOpenHistory: s.handleOpenHistory,
@@ -7,8 +7,9 @@ import { TerminalLayerWorkspaceSection } from './TerminalLayerWorkspaceSection';
import { terminalLayerViewCtxEqual } from './terminalLayerViewMemo';
import { useTerminalHostTreeLayoutWidth } from '../../application/state/terminalHostTreeStore';
import { resolveTerminalLayerSurfaceStyle } from '../terminalPaneVisibility';
import type { PluginSidebarHostActions } from '../../application/state/usePluginTerminalSidebar';
type TerminalLayerViewContext = Record<string, any>;
type TerminalLayerViewContext = Record<string, any> & PluginSidebarHostActions;
function TerminalLayerViewInner({ ctx }: { ctx: TerminalLayerViewContext }) {
const hostTreeLayoutWidth = useTerminalHostTreeLayoutWidth();
@@ -693,7 +693,7 @@ export function movePersistentPortalNode(
}
}
function PersistentSidePanelPortal({
export function PersistentSidePanelPortal({
portalKey,
target,
children,
@@ -316,6 +316,9 @@ const SIDE_PANEL_STABLE_CTX_KEYS = [
'handleOpenTheme',
'handleOpenAI',
'handleOpenNotes',
'getActiveTerminalSessionId',
'handleSwitchSidePanelTab',
'handleWithdrawPluginSidebarPane',
'handleOpenBrowser',
'handleBackFromNotes',
'handleOpenHostFromNotes',
+7 -2
View File
@@ -29,6 +29,7 @@ import { LazyLoadBoundary } from "../ui/lazy-load-boundary";
import { toast } from "../ui/toast";
import { AppWordmark } from "../AppWordmark";
import { openHostBrowser } from "../../application/hostBrowser";
import { PluginVaultNavigation, PluginVaultPage } from '../plugins/PluginVaultPages';
type VaultViewLayoutContext = Record<string, any>;
@@ -156,6 +157,7 @@ export function VaultViewLayout({ ctx }: { ctx: VaultViewLayoutContext }) {
ContextMenuTrigger,
Copy,
currentSection,
pluginVault,
customGroups,
deleteGroupPaths,
deleteGroupWithHosts,
@@ -595,12 +597,13 @@ export function VaultViewLayout({ ctx }: { ctx: VaultViewLayoutContext }) {
</div>
<div
className={cn("space-y-1", sidebarCollapsed ? "px-1.5" : "px-2.5")}
className={cn("min-h-0 flex-1 overflow-y-auto space-y-1", sidebarCollapsed ? "px-1.5" : "px-2.5")}
>
<Tooltip>
<TooltipTrigger asChild>
<RippleButton
variant={currentSection === "hosts" ? "secondary" : "ghost"}
data-plugin-vault-fallback
className={cn(
"w-full h-10",
sidebarCollapsed
@@ -805,11 +808,12 @@ export function VaultViewLayout({ ctx }: { ctx: VaultViewLayoutContext }) {
</TooltipContent>
)}
</Tooltip>
{pluginVault && <PluginVaultNavigation model={pluginVault} collapsed={sidebarCollapsed} />}
</div>
<div
className={cn(
"mt-auto pb-4 space-y-2",
"mt-auto shrink-0 pb-4 space-y-2",
sidebarCollapsed ? "px-1.5" : "px-2.5",
)}
>
@@ -864,6 +868,7 @@ export function VaultViewLayout({ ctx }: { ctx: VaultViewLayoutContext }) {
className="flex-1 min-w-0 flex flex-col min-h-0 relative"
data-section="vault-main"
>
{pluginVault?.selected && <PluginVaultPage key={pluginVault.selected.key} model={pluginVault} />}
<VaultPageHeader
className={cn(!isHostsSectionActive && "hidden")}
dataSection="vault-hosts-header"
+84 -3
View File
@@ -17,7 +17,7 @@ mean complete. Existing APIs remain available.
| Terminal providers | Decorations, links, completion, snapshots through provider requests | Session change delivery follows prior provider request and non-once grant | Preserve providers. Standalone selected-session watch planned | Hostnames, session state, endpoint identity | Host-selected session, runtime identity | Session-close and revoke cleanup, bounded event rate | Existing provider tests. Ordinary views cannot independently subscribe today |
| Current context and host picker | Host context keys are boolean state | `vault.metadata` enumeration is not generic SDK | Selected host/session DTO from an explicit whitelist | Labels and endpoint metadata are sensitive | Host-owned user-action context, selected host/session | TTL, runtime and session invalidation, redacted audit | Planned. Never reuse agent `sanitizeHostForAgent` as plugin DTO |
| Terminal selection and input draft | Provider-supplied snapshots, completion suggestions | No explicit selection-share or draft API | User shares selection, plugin proposes a visible editable draft | Terminal plaintext, accidental execution | Host action token plus exact live session/endpoint | Single-use token, size cap, no plaintext audit, active identity before commit | Planned. Reject Enter/control characters for draft insertion. No full history or direct shell |
| Native interactions and clipboard | Native permission dialogs; new browser SDK host choice/input and explicit copy/share workflow | Progress/toast remain unimplemented | Host-owned prompts, two-phase clipboard snapshot/share, exact copy preview | Clipboard and input may contain secrets; UI discloses granted network capability; prompt spam | `ui.interact`, clipboard direction, host-signed window/runtime/version/phase/TTL action | Admission before permission prompt, byte/rate/concurrency limits, lock/blur/close/revoke cleanup, active guard, content-free audit | [Host interaction slice](./host-interactions.md) implemented and synthetic-tested; native acceptance/review/CI pending. Progress/toast still planned. No background reads or trusted plugin `userGesture` boolean |
| Native interactions and clipboard | Native permission dialogs; new browser SDK host choice/input and explicit copy/share workflow | Progress/toast remain unimplemented | Host-owned prompts, two-phase clipboard snapshot/share, exact copy preview | Clipboard and input may contain secrets; UI discloses granted network capability; prompt spam | `ui.interact`, clipboard direction, host-signed window/runtime/version/phase/TTL action | Admission before permission prompt, byte/rate/concurrency limits, lock/blur/close/revoke cleanup, active guard, content-free audit | [Host interaction slice](./host-interactions.md) implemented and synthetic-tested. 8e4dd0c exact test/packaging CI and independent delta review passed. Parent native compose and explicit Preview/Share/copy succeeded; a separately approved 0.1.1 compose ran about 86 seconds, beyond the old 30-second deadline and within the declared 180-second budget, with no fresh command-timeout error. All five permissions were Once; cleanup is parent-owned. New navigation slices retain separate acceptance gates. Progress/toast still planned. No background reads or trusted plugin `userGesture` boolean |
| Local filesystem | Read file, stat, overwrite existing regular file | Directory listing lacks same-handle native adapter | Keep current boundary, evaluate file picker handles | Arbitrary local file content and writes | Canonical exact path or selected file handle | Inode checks, no symlink alias, bounded bytes, active runtime | Existing filesystem tests. Directory enumeration and new arbitrary-path creation deferred until portable safe native adapter exists |
| SFTP and remote files | No generic plugin API | `sftp.read` and `sftp.write` enumerated only | Host-selected session/path/mode/TTL handles, read and explicit save/upload | Remote secrets and destructive overwrite | Live endpoint identity, exact path, read/write split | Cancel streams, byte quota, overwrite confirmation, close/revoke cleanup | Batch 2 planned. Never forward arbitrary MCP catalog or session ID alone |
| Editor and Notes | No plugin document API | No versioned snapshot or edit preview | Explicit snapshot and versioned proposed edit | Document secrets, lost updates, executable files | Selected document plus revision and action token | Version conflict check, preview, bounded bytes, revocation | Planned evaluation after shared action context. No unattended edits |
@@ -27,12 +27,12 @@ mean complete. Existing APIs remain available.
| Vault and keychain | Credential references and import preview/commit | Metadata/write enums do not grant a generic Vault SDK | Only user-selected whitelist DTOs and drafts | Notes, environmentVariables, startupCommand, keys and credentials | Selected object plus field/direction | Explicit preview, identity checks, redacted audit | Full Vault export and secret reads deferred indefinitely. No agent field-exclusion DTO reuse |
| System manager and Docker | No plugin system API | Agent/MCP adapters are separate authority | Typed read-only summaries excluding args/env/inspect | Process command lines and Docker inspect can expose secrets | Selected live session and summary kind | Bounded result, endpoint identity, cancellation | Planned evaluation. Arbitrary process/inspect forwarding deferred |
| Port forwarding | No generic plugin API | Existing agent start is write-sensitive | Selected rule status and open host form | Rule endpoints and local exposure | Selected rule plus host action | Bounded DTO, session close and revoke | Planned evaluation. Direct start deferred until binding/listener approval design |
| Themes, settings, appearance, i18n | Terminal theme/background Providers, isolated view CSS, scoped settings and environment updates | No host global theme contribution. Personal customCSS is not plugin authority | Accepted ThemePack stage: static palette registry, selector, local preview, revision-safe apply/cancel/reset, terminal linkage | Unreadable palettes and misleading security UI. Nineteen UiThemeTokens fields are not independent: accent derives primary/ring/foregrounds | User explicitly selects a theme; static contribution enablement separate from code permissions | Immutable version/hash, bounded JSON, contrast/security-surface gates, preview owner/revision and safe fallback | Planned independent implementation after current controlled workflow/navigation. No arbitrary DOM/CSS, remote assets, layout replacement or security UI customization |
| Themes, settings, appearance, i18n | Terminal theme/background Providers, isolated view CSS, scoped settings and environment updates | No host global theme contribution. Personal customCSS is not plugin authority | Accepted ThemePack stage: static palette registry, selector, local preview, revision-safe apply/cancel/reset, terminal linkage | Unreadable palettes and misleading security UI. Nineteen UiThemeTokens fields are not independent: accent derives primary/ring/foregrounds | User explicitly selects a theme; static contribution enablement separate from code permissions | Immutable version/hash, bounded JSON, contrast/security-surface gates, preview owner/revision and safe fallback | First host color layer implemented; [contract/lifecycle/evidence](./theme-packs.md). Exact-SHA CI, independent review and native acceptance pending. Static terminal linkage not yet accepted; no arbitrary DOM/CSS, remote assets, layout replacement or security UI customization |
| Transfer center | Host transfer queue exists, no plugin entry | No ownership-bound task API | Observe and cancel only plugin-created tasks | Paths and host endpoints reveal user data. Cancel may leave partial files | Own transfer handle plus source/destination authority | Shared backpressure, cancel/close cleanup, honest partial status | Planned with batch 2. Do not broadcast or cancel other tasks |
| AI, MCP, automation | Host agent catalog and harness exist separately | No generic plugin AI or tool forwarding | Selection-to-proposal workflow through the same controlled primitives | Content disclosure, account authority and model cost | Explicit content sharing plus service origin/budget | No inherited history, tools, account, or hidden cron | Controlled workflow planned. Generic host AI and visible schedules deferred to distinct product decision |
| Known hosts, app lock and global security | Host security management UI | No plugin management API | Future allowlisted security-page navigation | Host-key trust, unlock, keys and security policy affect all sessions | Host navigation action only | No mutation or sensitive DTO | Security policy mutation, private-key reads, auto-trust and unlocking deferred indefinitely |
| Plugin blob/cache | Local KV exists | No exclusive blob cache | Evaluate bounded plugin-owned cache when a real workflow needs it | Disk growth and lifecycle ambiguity | Plugin namespace, no arbitrary path | Size quota, eviction and uninstall policy required | Deferred until a concrete larger-than-KV workflow establishes value and retention policy |
| Vault and terminal navigation | `tab` mounts a sandbox page through TopTabs/QuickSwitcher; aside/panel are overlays | No Vault subpage registry or terminal side-panel plugin tool. `settings` is a detail entry, not a new settings category | Accepted independent slice: host-owned namespaced Vault pages in an Extensions group, mounted in the Vault content area through existing ViewHost; then terminal pane/split pages for the selected session | Navigation cannot disclose session contents or replace built-in/security navigation | Declared page contribution plus host selection. Content reads have independent permissions | Host order/when/focus, cancellation of late open, view cleanup on hide/update/disable/uninstall, safe invalid-link fallback | Planned after host interaction/selected-context workflow. A button that merely opens a top tab does not satisfy Vault-page acceptance. No arbitrary router/DOM API |
| Vault and terminal navigation | `tab` mounts a sandbox page through TopTabs/QuickSwitcher; aside/panel are overlays | No Vault subpage registry or terminal side-panel plugin tool. `settings` is a detail entry, not a new settings category | Accepted independent slice: host-owned namespaced Vault pages in an Extensions group, mounted in the Vault content area through existing ViewHost; then terminal pane/split pages for the selected session | Navigation cannot disclose session contents or replace built-in/security navigation | Declared page contribution plus host selection. Content reads have independent permissions | Host order/when/focus, cancellation of late open, view cleanup on hide/update/disable/uninstall, safe invalid-link fallback | [Vault page slice](./vault-pages.md) implemented and synthetic-tested: actual Vault content slot, private Scratchpad, catalog withdrawal, host occlusion, reserved-open cancellation, owner/crash/lock cleanup. Exact CI/independent/native acceptance pending. [Terminal sidebar slice](./terminal-sidebar.md) also implemented with actual pane/split mounting, owner focus and session-change cleanup; no automatic session data. Exact CI/independent/native acceptance pending for the new stack. No arbitrary router/DOM API |
| Distribution and runtimes | Browser-only public catalog, immutable packages, advanced developer gate | Signing/trust and release remain separate phase | Retain existing boundary | Package execution and publisher trust | Pinned catalog selection, hash, engine/features | Existing install transaction, quarantine and cleanup | No merge/release. Do not extend to arbitrary Node, IPC, shell, or global capabilities |
## Delivery order and stopping rule
@@ -83,3 +83,84 @@ Baseline code references: `electron/plugins/hostService.cjs`,
implementations under `electron/capabilities/catalog/` are reference material,
not plugin authority. `infrastructure/ai/vaultAgentBridgeClient.ts` is not a
plugin DTO sanitizer.
## Navigation slice checkpoint
The Vault stage adds `views.vault-pages`, the `vault-page` location, and the
existing `views` permission. It grants no Vault/session data. The ordinary
browser Scratchpad example uses only its own bounded view-state API and
explicit user input. It is not a Notes API or automatic sync feature.
The independent integration base includes #3956 UI polish and #3960 d4ca fixes;
frozen evidence heads and prior PR bases are unchanged. Related UI changes in
this stage are limited to VaultView/VaultViewLayout, new independent plugin
components/hooks, and localized strings. PluginMarketplace/PluginPackageManager
layouts are not rewritten. Native acceptance belongs to the parent with a new
fixture-specific own-views approval. Sidebar, ThemePack, selected context and
SFTP remain unfinished; this checkpoint is not overall completion.
## Terminal sidebar checkpoint
`views.terminal-sidebar` exposes one more existing sandbox view placement,
using only the existing own-views permission. The host owns navigation,
selection, split/close and focus. Its context is a surface string only; no
terminal DTO or resource authority is implied. The Session Scratchpad fixture
uses explicitly typed synthetic text and its own view state.
This stage builds on ordinary merges containing #3960 8e4dd0c and the #3971
stale-layout callback fix. Prior QA heads and PR bases are not rewritten. The
ThemePack implementation, selected-context/drafts and file handles remain
unfinished; parent review/native gates still apply to each delivered head.
## Color-pack and remaining workflow checkpoint
The first [ThemePack color layer](./theme-packs.md) now has an implemented host
catalog, data-only admission, Appearance selector, local preview, revision-safe
application/reset, missing-version fallback and host security palette boundary.
It is not native-accepted until the new exact head is independently reviewed,
built by CI and exercised through normal Settings. Terminal palette linkage is
not accepted by this contract yet, so a declared but ignored linkage cannot be
counted as coverage. Font/density/icon/background packs remain separate product
and accessibility decisions, rather than arbitrary CSS permissions.
Parent native evidence for sidebar `a8adeb86` now covers the original Local
Terminal blocker, own-views Once, save, magnify/restore, a split with empty Notes,
and side/bottom docking. `733f44c` adds only the production bridge projection
regression. The older `0c097a1` CI run `40308057623` remains **failed and
unattributed**: 9447 top-level TAP results are `ok`, the aggregate footer is
missing and the process exits 1. A newer passing run does not change that record.
The native Vault success retains its original `0c097a1` evidence label.
The next implementation stages are bounded by useful workflows:
1. **Selected terminal sharing and drafts (A).** Reuse host-signed action
contexts for an explicit selection preview/share, then let the plugin submit
a bounded input draft to the host compose surface. No Enter, raw PTY, history
enumeration or background terminal listener. First add a host-selected
session handle with a minimal from-scratch DTO and independent lifecycle
watch; navigation alone grants no content. Bind handles to actual session and
connection identity, runtime/version, owner, mode and TTL. Cover cancel,
reconnect, close, revoke and late draft results with synthetic terminals.
2. **Selected file conversion/transfer (A).** The host picker issues read or
explicit-destination write handles. Start with one file, stat/read and saveAs
or upload, not recursive traversal or arbitrary paths. Require destination
preview/overwrite confirmation, byte/concurrency limits, cancellation,
session/endpoint identity and final active checks. Observe/cancel only the
plugin's own transfer. Real native SFTP targets require parent-coordinated
concrete approval; local tests use isolated fixtures.
3. **Versioned document proposals (selected B).** Reuse snapshot/handle and host
review primitives for one explicitly shared editor document or Note. Apply
only an expected-version patch to its buffer after a visible diff; saving a
remote file remains a separate file operation. Reuse the draft path for a
selected snippet or new snippet template; do not run scripts or add onConnect
bindings. Cover edits after snapshot, detached ownership, closure and revoke.
4. **Final reassessment.** Recheck the matrix by actual workflow and eight gates.
Read-only system aggregates, selected port-rule status and bounded navigation
may be added only where the shared primitives leave a concrete useful gap.
Full process arguments/env/inspect, tunnel start, arbitrary AI/MCP tools,
background jobs, general KV sync and raw high-privilege access remain deferred
or retained boundaries. Do not invent new C work to prolong the task.
These stages remain unfinished work, not API-name coverage. Exact schemas and
ordering will follow the existing brokers/adapters rather than a parallel RPC
stack or one namespace per product module.
+10
View File
@@ -630,3 +630,13 @@ negotiated API version and runtime initialization uses and verifies that exact
version. Host version bounds remain independently enforced; unknown required
features still fail closed. No old manifest is rewritten and no permission grant
is migrated by this revision.
## Declarative host color contributions
`appearance.theme-packs` adds `contributes.themes` and generated palette/reference
schemas. A pure package uses `main: { "data": "theme" }`, contributes only themes
and omits permissions/activation/companions. It has no executable runtime. Normal
admission accepts this data-only form alongside browser-only code packages; Node
and arbitrary IPC remain advanced/unsupported public boundaries. Selection is a
trusted host Appearance action, not an SDK setter or `appearance.write` grant.
See [Theme packs](./theme-packs.md) for limits, preview, persistence and fallback.
+5
View File
@@ -300,3 +300,8 @@ installs plugins but fails to start them outside the repository checkout.
separate `npm run test:plugin-runtime:electron` smoke launches both a real
sandboxed BrowserWindow plugin and a real utilityProcess plugin, verifies
bidirectional storage RPC, and checks the recorded runtime ownership.
Static `appearance.theme-packs` packages are an explicit non-executable admission
form: `main.data = "theme"`, theme-only contributions and no permissions,
activation events or companions. The runtime supervisor never starts them. This
exception does not admit Node code or arbitrary host access in normal Settings.
+101
View File
@@ -0,0 +1,101 @@
# Market sources (unreleased)
The plugin manager has Installed, Official marketplace and Third-party marketplace
pages. Listing a plugin in the official market does not certify it or establish
that Netcatty maintains it. Remote manifests, publisher names, source names and
README content cannot create a host-issued official badge. This revision does not
issue maintainer badges. README text and images remain author content.
## Add a source
Enter a public HTTPS origin, for example `https://plugins.example.com`, or that
origin's `/v1/snapshot.json`. Names can change; identity is derived by the host
from the canonical origin. Credentials, query strings, fragments, custom ports,
IP literals and local hostnames are rejected. The initial third-party transport
requires public IPv4 DNS records, pins the checked address to the TLS connection,
and rejects private/reserved destinations. IPv6-only sources are not supported
in this revision. No cookies, login tokens, redirects or GitHub fallback are used.
Third-party sources begin empty. A failed request never substitutes the official
catalog. Each source has its own in-memory last-known-good snapshot and selection
tokens; an offline snapshot is browse-only. A process restart requires a fresh
third-party fetch. The official source remains fixed to `plugins.netcatty.app`
and retains its bundled source examples when unavailable.
## Publish a static source
Use the existing snapshot protocol in the registry project's
`docs/STATIC-DISTRIBUTION.md`. Every source
serves `/v1/snapshot.json`, pointing to hash/size-bound catalog, index and delivery
JSON under `/snapshots/<delivery-sha256>/`. All resources remain on the source's
origin under content-addressed `/assets/` paths. Whole-JSON budgets, schema,
manifest, compatibility, browser-runtime admission and archive validation still
apply. Third-party source consent is not permission consent.
Delivery v1 is unchanged and source-only. Delivery **v2** has the same fields and
strict schema as v1, with `schemaVersion: 2` and one extension: each plugin's
`artifact` can be null or a package reference:
```json
{"path":"/assets/<64-lowercase-hex-sha256>.ncpkg","sha256":"<same-sha256>","size":12345}
```
Packages are at most 32 MiB. The catalog entry must contain
`artifact: {url: "https://plugins.example.com/assets/<same-sha256>.ncpkg", sha256}`.
The index's current, non-withdrawn version must exactly equal the catalog entry
and use `distribution: "release"`. Source-only entries retain null delivery
artifacts and `distribution: "source-only"`. Avatars remain null; remote README
embeds are not loaded. README and PNG/WebP references retain the v1 bounds.
Existing official bootstrap entries are not changed or made installable.
Before every install, refresh and validate the entire snapshot again. A changed
selection, withdrawal, unavailable source, altered hash or size prevents install;
there is no fallback to another source or latest release. Packages install disabled.
## Ownership and changing source
A public plugin ID can have only one installed source. The package's host install
metadata and durable database binding record that source, including across
recovery and uninstall. Cross-source install or update is rejected, even when
version and package bytes match. Local imports are a separate source.
Private settings, view state, persistent and in-memory grants, secrets, and cloud
sidecars are namespaced by host-derived source identity. Every source, including local imports, uses an opaque encoded owner; public plugin IDs are never storage-owner aliases in sync payloads. Before each sync entry and after every awaited contribution update/reset, the host rechecks that owner against the live source and contributions. Hydration captures its owner before yielding and never remaps a public ID for a later write. KV keys are qualified
inside the existing per-plugin FK. Code, permissions and source configuration do
not participate in cloud sync. Old-source cloud settings may remain orphaned;
they cannot hydrate the same plugin ID from a different source. Generic KV still
is not cloud-synced. No host/Vault/input migration changes are made.
To change source, uninstall and explicitly select **clear this source's private
data, grants and secrets**. The host serializes shutdown, revocation, private-file
cleanup and binding reset. This is destructive and is never selected by default.
Then install from the new source and review permissions again. An uninstall that
retains data deliberately retains the binding and continues to reject other sources.
Removing a market keeps its installed plugins and data. Updates stop; optionally
disable its plugins. No other market silently takes over. Disabling a source only
stops browsing/install/update access; it does not silently revoke existing plugins.
## Validation limits
Tests use synthetic source responses and first-party packages. Passing tests is
not a sandbox certification. Native UI, real third-party hosting, network
availability and multi-device encrypted-sync acceptance must be reported
separately. No real credentials or untrusted community code are needed for tests.
## Source-owner integration
Use `PluginDatabase.getActivePlugin(context.pluginId)` to require a current host
installation, then `database.privateOwner(plugin.id)` for an opaque data partition.
`plugin.source` is host-owned `{id, kind, name, origin}`.
`getRetainedPluginSource(pluginId)` returns a retained uninstall binding or null.
`publicPluginId(owner)` resolves only an encoded owner matching the current source;
it rejects raw public IDs and stale owners. Never accept owner/source from a plugin
request or invent a `pluginId` fallback. Capture owner/source at async operation
start and revalidate before committing; follow runtime lifecycle cancellation.
Plugin database schema 7 atomically converts existing raw local private rows and
KV keys to the same encoded-owner model without deleting them. This is a one-time
conversion, not a parallel runtime compatibility path. A conflicting conversion
fails without publishing schema 7. Old raw-ID cloud entries remain orphaned and
cannot hydrate an installed plugin. Host/Vault/input schemas are unchanged.
+74 -46
View File
@@ -1,56 +1,84 @@
# Official registry client
# Official static distribution client
The Community page refreshes the fixed endpoint
`https://raw.githubusercontent.com/binaricat/netcatty-plugins/main/catalog.json`
in the main process. The renderer cannot choose a registry URL. This endpoint
must be published separately; client implementation and synthetic tests do not
prove the repository or an online artifact is available.
Community starts at the fixed main-process endpoint
`https://plugins.netcatty.app/v1/snapshot.json`. The renderer cannot choose an
endpoint. All automatic catalog, README and potential package reads allow only
that exact HTTPS origin: no credentials, query, fragment, noncanonical URL or
redirect. No GitHub API/raw/avatar/release fallback exists. Explicit user-opened
source links may still open GitHub in an external browser.
## Catalog contract
## Snapshot contract
`catalog.json` is an array of at most 1,000 current entries. Each entry supplies
`id`, `version`, `name`, `description`, `author`, `repository` (`owner/repository`),
`commit` (full 40-character SHA), `license`, `readme` (relative Markdown path),
and `manifest`. Optional `artifact` contains `url` and SHA-256 `sha256`.
Unknown catalog-entry and artifact fields are rejected. The whole JSON value,
including manifest defaults, uses the existing 128-level / 100,000-value budget
and finite-number validation. The existing manifest schema, browser-only distribution policy, identity/version
and license checks apply. Source-only entries remain build-and-import examples.
No download counts, ratings, or timestamps are invented. Brand previews remain
bundled and reviewed; arbitrary author image URLs are never loaded.
This client implements the version-1 contract from
`binaricat/netcatty-plugins@7bfe23e5627e16ba63806de6dded5b2667c02984`.
The source catalog/index remain unchanged; **no `readmeAsset` field is added**.
Reads are anonymous HTTPS, redirect-free for the registry, bounded to 2 MiB,
and time out after 30 seconds. All entries must validate, safely clone, and generate every selection token before
atomically replacing the current snapshot. Invalid JSON/schema, network failure, and oversized responses
leave the previous good snapshot intact. Errors and Retry remain visible. The Community header also has Refresh, including
when the last refresh succeeded; it is disabled while a request is pending.
The last good catalog is retained **in memory for this application session**;
a fresh offline launch uses bundled examples. There is no new disk cache,
database migration, cloud sync record, or credential requirement.
The pointer contains `schemaVersion: 1`, `registryCommit`, `snapshotId`, and
`catalog`, `index`, `delivery` references, each `{path, sha256, size}`. Paths must
be `/snapshots/<snapshotId>/<name>.json`; `snapshotId` equals the raw delivery
SHA-256. The host checks the pointer before requesting its three documents,
then checks raw byte counts/hashes, JSON budgets, schemas and cross-document
identity. The three documents must all validate before replacing any cache.
## Selection and installation
Delivery contains `schemaVersion`, `registryCommit`, `catalogSha256`,
`indexSha256`, `sourceLockSha256`, `builderSha256` and `plugins`. Each item matches
one exact catalog `id/version/repository/commit` tuple and supplies `readme`,
`iconLight`, `iconDark`, `avatar`, `artifact`. Asset references are
`{path, sha256, size}`, with `/assets/<sha256>.md` for README and `.png`/`.webp`
for brand icons. This v1 publishing schema requires **avatar and artifact to
be null**. The client rejects unsupported delivery metadata rather than
inventing installable packages or deriving a GitHub avatar URL.
Each returned entry has a host-generated `selectionToken` covering its complete
JSON contents. This is a concurrency check, **not a signature or trust proof**.
README requests must still match that selection and fetch its pinned source
commit. A changed entry requires refresh and renewed review.
Index is validated with its pinned schema. Plugin/version identities must be
unique; each catalog entry must exactly equal the index's current, non-withdrawn,
source-only entry. Withdrawal does not disable, uninstall or delete any existing
plugin: this is catalog admission, not a remote installed-code kill switch.
A manual install must first refresh the official catalog successfully and match
the selected token again. Failed refresh is browse-only, even if the cached
entry has an artifact. Withdrawal, version changes, permission changes, README
changes, or artifact hash changes cannot silently replace the selected package.
Archive download then uses the existing approved-host, size, exact SHA-256,
manifest and package validation pipeline. Installation leaves the plugin disabled;
existing authorization gates still apply on activation/use.
Schemas are vendored under `electron/plugins/distribution/`, with provenance and
fixtures described there. All schema references resolve locally; schema validation
does not fetch GitHub or netcatty.com.
`index.json` release history and withdrawal records are not consumed by this
first client. Removing an entry does **not** disable, uninstall, update, or delete
data from an already installed plugin. There are no background code updates.
## Bounds, cache and selection
## Verification boundary
Pointer reads are bounded to 16 KiB, each JSON document to 2 MiB, and each README
to 256 KiB. Reads time out after 30 seconds. Whole JSON values retain the existing
128-level / 100,000-value / finite-number boundary. Catalogs contain at most 1,000
entries. Existing manifest identity, license and browser-only policies remain.
Service regressions use synthetic network responses and package bytes; the
existing package manager tests cover archive admission. No third-party plugin,
real credential, real sync account, or live registry write is used. Native QA
must separately verify the visible offline/retry state and eventual real catalog
availability once the repository is published.
The host prepares private entries, private delivery resources and all public
selection tokens before one synchronous cache publication. Parse, integrity,
schema, clone, tuple or network failures preserve the last complete good snapshot.
The cache remains **in memory for this app session**; a fresh offline launch lists
bundled examples. No new disk database, sync record or credential is introduced.
A host-generated selection token binds the complete entry and its delivery item.
This prevents silent selection changes; it is **not a signature**. README requests
capture the matched static reference and verify its exact byte count and digest.
Missing/offline snapshots show an error/Retry; they never synthesize a raw GitHub URL.
README HTML and remote images remain inert in the existing safe renderer.
The current v1 distribution cannot install packages. A later publishing/client
schema revision must jointly introduce reviewed package mirroring, keep source
manifest/hash admission, and recheck the current pointer and fresh root withdrawal
ledger before any install. A historical snapshot alone cannot establish availability.
The existing lower-level package downloader accepts only official static URLs and
exact SHA-256 bytes; it does not relax those future gates or auto-enable code.
## Network inventory
| Path | Automatic source | Failure behavior |
| --- | --- | --- |
| Pointer / manual refresh | Fixed `/v1/snapshot.json` | Complete session cache, or bundled source-only entries |
| Catalog/index/delivery | Pointer-bound same-origin immutable paths | Reject partial or mixed snapshots |
| README details | Exact delivery `/assets/<hash>.md` | Visible error/Retry, no upstream fallback |
| Uninstalled brand icons | Existing reviewed bundled previews | Default icon; delivery icon metadata validated but not fetched by UI |
| Installed brand icons | Host-owned local package decoder | Default icon; no activation/network request |
| Author avatars | Not implemented; delivery v1 requires null | Existing local presentation; no avatar lookup |
| Package artifacts | Not published or admitted by delivery v1 | Source-only; no download action fabricated |
| Source/README links | Explicit user-opened external HTTPS link | May open GitHub; no background dependency |
The real PR3 bootstrap is retained as a test fixture with its original bytes and
hashes. Tests also verify its brand-icon asset bytes; this does not claim a new
remote icon renderer. The static publisher is not a proxy. Client tests do not
prove deployment, mainland reachability or successful native CDN access. Native
QA must independently verify the published endpoint and visible failure state.
+74
View File
@@ -0,0 +1,74 @@
# Terminal sidebar pages
The `terminal-sidebar` view location requires `views.terminal-sidebar` and the
existing `views` permission. This adds navigation and plugin-owned content to
the existing terminal pane/split system. It is not a session-data capability,
Provider subscription, terminal history reader or command execution API.
## Host-owned navigation
An Extension pages menu in the normal terminal toolbar lists visible pages
with their plugin source. Selecting one switches the existing focused pane;
selecting an already-open tool focuses that pane. Existing split menus include
the same contributions and enforce unique tools and the eight-pane limit.
Host-generated `plugin-view:<namespaced-view-id>` keys cannot replace built-in
tools. Declared order and ID determine listing order; existing host docking,
split resizing, magnification and close controls remain authoritative.
The manager's internal Open event can select a page only when an open terminal
is already selected. Otherwise the host asks the user to select a terminal; it
does not connect or open a saved host. Launcher events target the session from
the actual host terminal toolbar and are rechecked against current selection.
These are internal host UI events, not public SDK router methods or trusted
plugin `userGesture` flags.
## Session ownership without data sharing
The renderer binds each page to the actual terminal tab/workspace and current
focused child session. It passes only `netcatty.surface: terminal-sidebar` as
the view context. Internal ownership IDs are used for host state scoping, not
returned to the page or treated as resource grants. A future selected-session
data API must establish its own live endpoint authority and permissions.
Changing terminal tabs or workspace focus closes the old native view before
the new one can receive focus. Session removal, pane close, withdrawal of a
visible contribution, disable/update/uninstall and runtime loss remove the
affected page. Unknown saved routes are pruned without opening them; restoring
a layout never grants the page or any session-data permission. State buckets
are for the plugin's own UI data, not an isolation boundary against that same
plugin's already-authorized own-state API. Ordinary KV does not become synced.
PersistentSidePanelPortal moves a stable mount between the actual pane,
magnification host and hidden parking host. There is no fallback native view
over the entire terminal layer. Native geometry follows host DOM relocation,
resize and scrolling; duplicate geometry updates are suppressed. Hidden/inert
or parked mounts cannot reveal a native view.
## Focus and security surfaces
The existing sandbox ViewHost, immutable package/protocol checks, own-command
and view-state boundaries are reused. Embedded pages start hidden. Host
dialogs and overlapping portals hide them, and main-process App Lock closes
them. Escape is host-owned and closes the plugin pane.
Native WebContents focus is reported only to its actual owner's trusted
renderer using a canonical instance-ID event. It updates the selected pane;
it does not expose an Electron object to the plugin. Revealing multiple panes
does not focus all of them: only the host-selected pane can request focus.
The main process verifies actual owner, visibility, foreground and unlocked
state before honoring that request. A plugin has no SDK method to invoke the
host focus bridge. No session or page text is added to focus/audit logs.
## Example and evidence
`com.netcatty.session-scratchpad` 0.1.0 asks only for its own `views`. It saves,
reopens and clears explicitly typed synthetic text. Package instructions and
minimal native actions are in its README. Repository tests use real split
tree, layout state, persistent portal, launcher and embedded-view components;
host tests cover owner-bound focus, hidden/locked refusal and withdrawal.
Feature and permission admission, limits, old APIs and previous Vault/command
fixes are checked on the same stack. Independent review, exact-SHA CI and
parent native QA remain separate acceptance gates recorded on the Draft PR.
ThemePack, selected context/terminal drafts and SFTP handles remain subsequent
bounded stages. Adding a sidebar page does not count as implementing them.
+159
View File
@@ -0,0 +1,159 @@
# Host color theme packs
Theme packs contribute validated light and dark host colors. They cannot inject
CSS, access host DOM, change layout, load remote resources, replace security
controls, or apply themselves. The feature is `appearance.theme-packs`.
## Install and select
Use normal Settings > Plugins to install and enable a package. In Appearance,
choose a plugin theme for the current light or dark mode, then select Preview or
Apply. Preview changes only the current window and writes no preference. Cancel,
closing Appearance, changing mode, package withdrawal, or a newer committed
revision ends that preview. Apply saves a host-owned, device-local choice and
refreshes other application windows. Restore default clears the plugin choice
for that mode and selects Snow or Midnight. Selecting a built-in swatch also
clears that mode's plugin choice. Failures remain visible and are not reported as
successful applications.
The current view environment is globally broadcast. To keep temporary previews
local, it publishes **committed** theme tokens only. Plugin pages follow an
applied theme through their existing environment subscription; they do not
receive another window's temporary preview. This does not add an owner-local
view environment API.
## Data-only package
A pure theme uses `main: { "data": "theme" }`, contributes only `themes`, and
omits permissions, activation events and companions. Its code runtime is never
started, including by the runtime supervisor's startup or explicit-start path.
Browser code plugins may also declare themes; choosing a theme does not start a
runtime or grant their functional permissions. Only enabled packages appear.
```json
{
"features": { "required": ["appearance.theme-packs"] },
"main": { "data": "theme" },
"contributes": {
"themes": [{
"id": "com.example.colors.forest",
"label": "Forest",
"variant": "light",
"entry": "forest.json"
}]
}
}
```
This fragment belongs in an otherwise complete manifest. Contribution IDs use
the package namespace. Palette paths are package-relative JSON files, included
and checked by the normal CLI/package store. Hosts lacking the required feature
reject compatibility deterministically.
```json
{ "formatVersion": 1, "base": "light", "colors": { "accent": "#166534" } }
```
Colors merge onto a fixed accessible light/dark host base. Only opaque `#RRGGBB`
values and canonical semantic keys are accepted. The 15 editable keys cover
background/foreground, card and popover pairs, secondary and muted pairs, accent,
destructive and its foreground, border and input. Primary, ring and the accent
foregrounds are derived together, matching the existing theme conversion.
Nineteen output fields are not nineteen independent controls.
Each package has at most 32 themes; each palette is at most 64 KiB; the catalog
and validated cache contain at most 256 entries. Schema and contrast checks run
before catalog publication. Text pairs and state colors require at least 4.5:1;
control borders and focus information require at least 3:1 against their
surfaces. Invalid palettes produce bounded, content-free diagnostics and cannot
be previewed or applied. Files are read from verified immutable package roots,
with bounded reads and a no-follow final file open.
## Ownership, recovery and security appearance
The host stores selections separately from plugin KV, settings sidecars and
cloud sync. A reference binds plugin ID, theme ID, exact version, archive digest
and variant. Application uses a revision check plus an active-package check just
before the atomic preference/audit transaction. An audit write failure rolls
back the selection. Audit records contain revision/action metadata, not palette
or user content.
Disable, uninstall, update, missing entries and integrity failures withdraw the
palette. A saved missing reference remains visible as unavailable while the
same-mode safe host palette is used; a changed package is not automatically
applied. Preview owner and committed revision checks prevent a late cancellation
from replacing another preview or a newer application.
Host dialogs, App Lock, grant/revoke, host details, keychain panels, host-key
prompts and the credential picker use fixed host security variables while a
plugin theme is active. The pack controls no font, icon, opacity, CSS selector,
button text or ordering. System forced-colors/high-contrast preferences use a
fixed host high-contrast fallback. Personal accent, fonts, terminal choices and
accessibility preferences retain their authority. Existing personal custom CSS
remains a user setting and is not exposed to plugins.
This first layer changes host semantic colors. Static terminal palette linkage,
custom high-contrast variants, fonts, density, icons and backgrounds remain
separate bounded follow-ups; their fields are not silently accepted here.
## Example and validation
`examples/plugins/forest-theme-pack` is a four-file data package with light and
dark Forest accents. It requests no permissions and contains no JavaScript.
The starter, Vault Scratchpad and Session Scratchpad pages now subscribe to the
existing seven public HSL tokens through `theme.js`, dispose on page hide, and
use responsive host-colored controls. No new styling RPC is needed.
Local evidence includes normal CLI validation/packing, schema/admission and
no-runtime checks; host catalog/identity/size/contrast/CAS/migration/atomic-audit
checks; picker preview/apply/cancel/reset and stale-owner/withdrawal/high-contrast
checks; and view-template initial/live environment/disposal checks. Exact-SHA
CI, independent review and parent native multi-window/security-surface acceptance
remain separate delivery gates. Local production builds do not establish them.
### First review / CI delta
The first review found that muted text was validated only against its namesake
muted surface, although host descriptions and statuses also use it on background,
card and popover surfaces. The validator now checks generic and muted text on
all neutral surfaces that reuse those tokens. The white-on-white counterexample
is rejected both by the pure validator and the host catalog. Forest light/dark
palettes remain valid.
The first exact-head test run `40317690002` failed with 15187 passing tests,
one failure and 50 skips. Its single failure was the unchanged TERM-SSHD
concurrent failure-release test expecting a particular invocation to reach OPEN
first. Both calls perform asynchronous local-file preparation. The test now
requires exactly one explicit Permission denied result, one successful result
with matching bytes, and no rejected concurrent handle; it retains concurrency
without assuming filesystem completion order. No transfer implementation changed.
The test file's original SHA256 was identical in e5bd294c, 733f44c and fdeeaf74:
`8b200d9f6a05ac5cd91fe4e0a95ea36a770d22267a6a0162b4c9fc927beb52a7`.
This is source identity and a source-based ordering diagnosis, not a claim that a
baseline runtime failure was reproduced locally. The updated test needs the new
exact-head CI result. That same first run passed the added Vite browser-import
smoke, resolving the earlier local temp-directory limitation for that head.
### Native security-color delta
Parent native acceptance at f868 verified zero-permission installation/enabling,
light/dark Preview and Cancel, Apply across main/Settings windows, Restore
default, restart persistence, and Disable fallback with the unavailable notice.
The isolated schema-3 profile was not touched. These core results remain valid.
Native QA also found Forest-colored inner New Host cards/inputs/Save and some
grant-panel text inside an otherwise fixed security container. The cause was
Tailwind's derived `--color-*` aliases: changing only raw semantic tokens at the
boundary does not recompute aliases already resolved on an ancestor. The same
existing security rule now fixes both raw tokens and those color aliases. The
selector and protected regions are unchanged; ordinary application cards remain
theme-controlled. No host, credential or grant operation changed.
The regression renders the actual HostDetailsPanel (including General, Address,
Credentials cards, real Inputs and Save), plus the real grant-panel root. It
compiles the production Tailwind source, verifies which aliases those utilities
consume, checks the fixed declarations, and verifies an ordinary card is outside
the security selector. This is component/compiled-CSS coverage, not a claim of
native computed-color acceptance. The parent's native color delta remains the
final visual check. A local Chromium probe aborted because its SUID sandbox was
not configured; sandboxing was not disabled to retry.
+80
View File
@@ -0,0 +1,80 @@
# Vault extension pages
`views.vault-pages` adds the `vault-page` view location. A package must require
the feature and declare the existing `views` permission. Hosts without the
feature reject the package deterministically. Existing view locations and API
versions keep their behavior; there is no public plugin router or new Vault
read/write permission.
## User workflow
Visible, enabled contributions appear in Vault's **Extensions** group. The
host generates a namespaced route from the declared view ID, orders by declared
order and ID, localizes the label, and resolves the declared icon through the
existing safe icon pipeline. Built-in navigation and security entry points
remain host-owned. Collapsed navigation retains source-labelled accessible
buttons.
Selecting a page mounts its existing sandbox WebContentsView in the Vault main
content area. It does not create a top tab, use an aside overlay, open a
connection, or share host/session/Vault records. The trusted host context
contains only `netcatty.surface: vault-page`. The manager's existing Open action
can select the same page through an internal host event; this is not exposed
as a generic SDK navigation capability.
The `vault-scratchpad` browser example lets the user type synthetic text, save
it in the plugin's own view state, return to Hosts, reopen, and clear it. It
does not create a Vault Note or enable automatic cloud sync. The fixture asks
for only `views`; granting it does not approve another plugin or data access.
## Ownership and lifecycle
The host owns the route and validates the selected contribution against the
current visible catalog. Missing IDs, disable/uninstall/update, a false `when`,
failed activation or a host close return to Hosts. Focus restoration checks
the current section and active tab so an old fallback cannot steal focus after
a newer selection. Catalog listing does not activate a plugin.
The embedded host reuses PluginViewHost, its ephemeral session, CSP, no-Node
preload, protocol validation, own-command checks, and bounded own view state.
No DOM or Electron object enters the SDK. A window-route-and-slot scope separates
this page's state from other view placements; actual BrowserWindow ownership is
validated separately and does not rely on that state key. This initial embedded slice
closes on navigation away even if `retainContextWhenHidden` is declared.
Each opening reserves an instance ID owned by its actual BrowserWindow before
asynchronous activation. Close can cancel that reservation before native
construction. Owner closure, shutdown and renderer cleanup prevent a late open
from attaching; late responses are also closed by the renderer lifecycle
controller. Current plugin version/runtime and `when` are checked before
attachment and on subsequent contribution changes. Existing owner checks apply
to bounds, visibility, close and view-state IPC.
Host reload/renderer exit and a view renderer crash close the associated native
page. Plain Escape in a Vault page is handled by the host and returns to Hosts;
it does not dispatch a plugin command or expose a general navigation API.
Native child views sit above React portals. A Vault page starts hidden; the
trusted renderer reveals it only after checking the mount and host dialogs,
menus, popovers, overlay panels and lock surfaces. Overlapping host surfaces
hide the page. App Lock additionally closes all hosted plugin views and
cancels pending opens in the main process; showing a view while locked fails.
The shared manifest bound remains 128 views per package. Only one Vault page is
mounted per owner; listing and routing do not create a background view queue.
View-state JSON size limits and existing runtime/permission quotas remain in
force. No page text or state is added to audit logs by this adapter.
## Validation status
Source tests cover normal catalog selection, the actual embedded content slot,
host-modal occlusion, synchronous tab-away cleanup, withdrawn and invalid
routes, internal host selection, delayed open cancellation, feature/permission
admission, host ownership, `when` withdrawal and App Lock. Production build,
aggregate host tests and precise-SHA CI are reported on the accompanying Draft
PR. Parent native acceptance must use the packed browser example through
normal Settings and record its exact source SHA/package hash. Unit tests do
not substitute for that native acceptance.
Terminal sidebar pages and selected-session DTOs remain separate stages. This
page location does not make those capabilities available.
+13 -4
View File
@@ -3,6 +3,14 @@
// Shared by host placement, release admission, and the installed-package UI.
// A Node entrypoint does not change default placement, but is not admitted to
// the normal browser-only product path.
function isStaticThemePack(manifest) {
return manifest?.main?.data === "theme" && Object.keys(manifest.main).length === 1
&& Array.isArray(manifest.contributes?.themes) && manifest.contributes.themes.length > 0
&& Object.keys(manifest.contributes).every((key) => key === "themes")
&& !Object.keys(manifest.permissions ?? {}).length && !manifest.activationEvents?.length
&& !manifest.companionExecutables?.length
&& manifest.features?.required?.includes("appearance.theme-packs");
}
function describePluginRuntime(manifest) {
const providers = manifest?.contributes?.providers ?? [];
const permissions = [...(manifest?.permissions?.required ?? []), ...(manifest?.permissions?.optional ?? [])];
@@ -18,19 +26,20 @@ function describePluginRuntime(manifest) {
const hasNode = Boolean(manifest?.main?.node);
const defaultRuntimeKind = hasBrowser && !hasCompanions && !hasInterceptors ? "browser" : "utility";
return {
defaultRuntimeKind,
defaultRuntimeKind: isStaticThemePack(manifest) ? "data" : defaultRuntimeKind,
dataOnly: isStaticThemePack(manifest),
hasNode,
declaresAdvanced,
browserOnly: hasBrowser && !hasNode && !hasCompanions && !hasInterceptors && !declaresAdvanced,
browserOnly: !manifest?.main?.data && hasBrowser && !hasNode && !hasCompanions && !hasInterceptors && !declaresAdvanced,
};
}
function assertBrowserOnlyPlugin(manifest) {
if (!describePluginRuntime(manifest).browserOnly) {
if (!describePluginRuntime(manifest).browserOnly && !isStaticThemePack(manifest)) {
const error = new Error("This package requires advanced developer mode; normal plugin management accepts browser-only packages");
error.code = "PLUGIN_UNSUPPORTED_RUNTIME";
throw error;
}
}
module.exports = { describePluginRuntime, assertBrowserOnlyPlugin };
module.exports = { describePluginRuntime, assertBrowserOnlyPlugin, isStaticThemePack };
+6
View File
@@ -19,3 +19,9 @@ test('normal release admission rejects advanced declarations and privileged prov
assert.equal(describePluginRuntime(manifest).defaultRuntimeKind, 'utility');
assert.throws(() => assertBrowserOnlyPlugin(manifest), /developer mode/);
});
test('normal admission accepts only explicit non-executable theme-only data packages',()=>{
const manifest={main:{data:'theme'},features:{required:['appearance.theme-packs']},contributes:{themes:[{id:'com.example.theme.light'}]}};
assert.doesNotThrow(()=>assertBrowserOnlyPlugin(manifest));assert.equal(describePluginRuntime(manifest).dataOnly,true);assert.equal(describePluginRuntime(manifest).defaultRuntimeKind,'data');
for(const change of [{main:{data:'theme',browser:'index.js'}},{permissions:{required:['network']}},{activationEvents:['onStartupFinished']},{contributes:{themes:[],commands:[]}}])assert.throws(()=>assertBrowserOnlyPlugin({...manifest,...change}),/developer mode/);
});
+145
View File
@@ -0,0 +1,145 @@
"use strict";
// Pure color projection. Package/wire schema validation belongs to the host.
const LIGHT = Object.freeze({
background: "#ffffff",
foreground: "#161a1d",
card: "#ffffff",
cardForeground: "#161a1d",
popover: "#ffffff",
popoverForeground: "#161a1d",
secondary: "#e5e7eb",
secondaryForeground: "#161a1d",
muted: "#f0f2f4",
mutedForeground: "#374151",
accent: "#1d4ed8",
destructive: "#b91c1c",
destructiveForeground: "#ffffff",
border: "#6b7280",
input: "#6b7280",
});
const DARK = Object.freeze({
background: "#111827",
foreground: "#f9fafb",
card: "#1f2937",
cardForeground: "#f9fafb",
popover: "#1f2937",
popoverForeground: "#f9fafb",
secondary: "#374151",
secondaryForeground: "#ffffff",
muted: "#273244",
mutedForeground: "#e5e7eb",
accent: "#93c5fd",
destructive: "#fca5a5",
destructiveForeground: "#111827",
border: "#9ca3af",
input: "#9ca3af",
});
function rgb(hex) {
return [1, 3, 5].map(
(offset) => parseInt(hex.slice(offset, offset + 2), 16) / 255,
);
}
function luminance(hex) {
return rgb(hex)
.map((v) => (v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4))
.reduce((s, v, i) => s + v * [0.2126, 0.7152, 0.0722][i], 0);
}
function contrast(a, b) {
const x = luminance(a),
y = luminance(b);
return (Math.max(x, y) + 0.05) / (Math.min(x, y) + 0.05);
}
function toHsl(hex) {
const [r, g, b] = rgb(hex),
max = Math.max(r, g, b),
min = Math.min(r, g, b),
delta = max - min,
l = (max + min) / 2;
let h = 0;
if (delta)
h =
(max === r
? ((g - b) / delta + 6) % 6
: max === g
? (b - r) / delta + 2
: (r - g) / delta + 4) * 60;
const s = delta ? delta / (1 - Math.abs(2 * l - 1)) : 0;
return `${h.toFixed(3)} ${(s * 100).toFixed(3)}% ${(l * 100).toFixed(3)}%`;
}
// A small margin covers rounding when hex colors become HSL CSS components.
function resolveThemePalette(palette) {
const colors = {
...(palette.base === "dark" ? DARK : LIGHT),
...palette.colors,
};
for (const value of Object.values(colors))
if (!/^#[0-9a-f]{6}$/i.test(value)) throw new Error("Invalid theme color");
const pairs = [
["foreground", "background"],
["cardForeground", "card"],
["popoverForeground", "popover"],
["secondaryForeground", "secondary"],
["mutedForeground", "muted"],
["destructiveForeground", "destructive"],
];
for (const [a, b] of pairs)
if (contrast(colors[a], colors[b]) < 4.51)
throw new Error("Theme text contrast is below 4.5:1");
// Host labels and descriptions reuse these text tokens across neutral
// surfaces; they do not always sit on their namesake background token.
for (const surface of ["background", "card", "popover", "secondary", "muted"])
for (const key of ["foreground", "mutedForeground"])
if (contrast(colors[key], colors[surface]) < 4.51)
throw new Error("Theme neutral-surface text contrast is below 4.5:1");
for (const surface of ["background", "card", "popover"]) {
for (const key of ["accent", "destructive"])
if (contrast(colors[key], colors[surface]) < 4.51)
throw new Error("Theme state contrast is below 4.5:1");
for (const key of ["border", "input"])
if (contrast(colors[key], colors[surface]) < 3.01)
throw new Error("Theme control contrast is below 3:1");
}
const foreground =
contrast(colors.accent, "#000000") >= contrast(colors.accent, "#ffffff")
? "#000000"
: "#ffffff";
const tokens = Object.fromEntries(
Object.entries(colors).map(([k, v]) => [k, toHsl(v)]),
);
return Object.freeze({
...tokens,
primary: tokens.accent,
primaryForeground: toHsl(foreground),
accentForeground: toHsl(foreground),
ring: tokens.accent,
});
}
function securityThemeTokens(mode, highContrast = false) {
const colors = highContrast
? mode === "dark"
? {
background: "#000000",
card: "#000000",
popover: "#000000",
foreground: "#ffffff",
cardForeground: "#ffffff",
popoverForeground: "#ffffff",
accent: "#ffff00",
border: "#ffffff",
input: "#ffffff",
}
: {
background: "#ffffff",
card: "#ffffff",
popover: "#ffffff",
foreground: "#000000",
cardForeground: "#000000",
popoverForeground: "#000000",
accent: "#000080",
border: "#000000",
input: "#000000",
}
: {};
return resolveThemePalette({ base: mode, colors });
}
module.exports = { resolveThemePalette, securityThemeTokens, contrast, toHsl };
+33
View File
@@ -0,0 +1,33 @@
const assert = require("node:assert/strict");
const test = require("node:test");
const {
resolveThemePalette,
securityThemeTokens,
contrast,
} = require("./pluginThemePack.cjs");
test("opaque accessible palettes derive coupled accent tokens and fixed high contrast fallback", () => {
for (const mode of ["light", "dark"]) {
const tokens = resolveThemePalette({ base: mode, colors: {} });
assert.equal(tokens.primary, tokens.accent);
assert.equal(tokens.ring, tokens.accent);
assert.equal(tokens.primaryForeground, tokens.accentForeground);
assert.ok(securityThemeTokens(mode, true));
}
assert.ok(contrast("#000000", "#ffffff") > 20);
assert.throws(
() =>
resolveThemePalette({ base: "light", colors: { foreground: "#ffffff" } }),
/contrast/,
);
});
test("generic and muted text stay readable on the neutral surfaces that use them", () => {
for (const colors of [
{ muted: "#000000", mutedForeground: "#ffffff" },
{ card: "#000000", cardForeground: "#ffffff" },
{ popover: "#000000", popoverForeground: "#ffffff" },
{ secondary: "#000000", secondaryForeground: "#ffffff" },
]) {
assert.throws(() => resolveThemePalette({ base: "light", colors }), /contrast/);
}
});
+7 -1
View File
@@ -1,4 +1,4 @@
export type SidePanelTool =
export type BuiltinSidePanelTool =
| 'sftp'
| 'browser'
| 'scripts'
@@ -8,6 +8,12 @@ export type SidePanelTool =
| 'system'
| 'notes';
export type SidePanelTool = BuiltinSidePanelTool | `plugin-view:${string}`;
export function isPluginSidePanelTool(value: unknown): value is `plugin-view:${string}` {
return typeof value === 'string' && value.length <= 256 && /^plugin-view:[A-Za-z0-9_.-]+$/.test(value);
}
export type SidePanelSplitDirection = 'horizontal' | 'vertical';
export type SidePanelPaneNode = {
+15
View File
@@ -90,6 +90,21 @@ test('sanitize accepts a valid saved preset and rejects malformed data', () => {
);
});
test('plugin panes keep split uniqueness and limits; saved routes grant no availability and can be pruned', () => {
const tool = 'plugin-view:com.example.plugin.page' as const;
let layout = createSidePanelLayout('notes', 'notes');
layout = splitSidePanelPane(layout, 'notes', tool, 'vertical', { paneId: 'plugin', splitId: 'root' }, 600);
const unique = splitSidePanelPane(layout, 'notes', tool, 'vertical', { paneId: 'duplicate', splitId: 'unused' }, 600);
assert.equal(collectSidePanelPanes(unique.root).length, 2);
assert.equal(getFocusedSidePanelPane(unique).tool, tool);
assert.ok(createWorkspaceLayoutPreset(layout));
assert.equal(withoutUnavailableSidePanelTools(layout, (item) => item === 'notes')?.root.type, 'pane');
for (let i = 0; i < 10; i++) layout = splitSidePanelPane(layout, getFocusedSidePanelPane(layout).id,
`plugin-view:com.example.plugin.page${i}`, 'horizontal', { paneId: `p${i}`, splitId: `s${i}` }, 600);
assert.equal(collectSidePanelPanes(layout.root).length, 8);
assert.equal(sanitizeWorkspaceLayoutPreset({ version: 1, layout: { root: { id: 'invalid', type: 'pane', tool: 'plugin-view:../outside' }, focusedPaneId: 'invalid' } }), null);
});
test('sanitize rejects duplicate node ids', () => {
assert.equal(
sanitizeWorkspaceLayoutPreset({
+2 -1
View File
@@ -3,6 +3,7 @@ import {
closeSidePanelPane,
collectSidePanelPanes,
getFocusedSidePanelPane,
isPluginSidePanelTool,
type SidePanelLayout,
type SidePanelLayoutNode,
type SidePanelTool,
@@ -32,7 +33,7 @@ const SIDE_PANEL_TOOLS: ReadonlySet<SidePanelTool> = new Set([
]);
function isSidePanelTool(value: unknown): value is SidePanelTool {
return typeof value === 'string' && SIDE_PANEL_TOOLS.has(value as SidePanelTool);
return typeof value === 'string' && (SIDE_PANEL_TOOLS.has(value as SidePanelTool) || isPluginSidePanelTool(value));
}
function sanitizeSidePanelLayoutNode(
+1
View File
@@ -70,6 +70,7 @@ module.exports = {
'domain/terminalPromptSecurity.shared.cjs',
'domain/pluginRuntimePolicy.cjs',
'domain/pluginPermissionDescriptions.cjs',
'domain/pluginThemePack.cjs',
// Main-process SSH, SFTP, and port-forward bridges share this matcher.
// TypeScript under domain/ is excluded below, so the .cjs must be listed.
'domain/systemManager/containerRuntime.shared.cjs',
@@ -311,7 +311,7 @@ test("TERM-SSHD queued cancellation releases its turn without closing the owner
assert.equal(transferBridge._getSessionFastDownloadWaiterCountForTests(fixture.sftp), 0);
});
test("TERM-SSHD failed transfer releases the physical channel for the next alias", async (t) => {
test("TERM-SSHD failed transfer releases the physical channel for the next alias", { timeout: 5_000 }, async (t) => {
const fixture = await createSequentialTransferFixture(t);
const originalOpen = fixture.sftp.open;
let rejectFirst = true;
@@ -321,10 +321,16 @@ test("TERM-SSHD failed transfer releases the physical channel for the next alias
args.at(-1)(Object.assign(new Error("Permission denied"), { code: 3 }));
} else originalOpen(...args);
};
const [first, next] = await Promise.all([fixture.start("download"), fixture.start("download", "alias")]);
assert.match(first.result.error || "", /Permission denied/);
assert.equal(next.result.error, undefined);
assert.deepEqual(await fs.readFile(next.targetPath), fixture.payload);
const transfers = await Promise.all([fixture.start("download"), fixture.start("download", "alias")]);
// Both calls await local file preparation before acquiring the shared channel.
// Either invocation may reach the injected first OPEN failure; retain the
// concurrent release check without assuming filesystem completion order.
const failed = transfers.filter((transfer) => transfer.result.error !== undefined);
const succeeded = transfers.filter((transfer) => transfer.result.error === undefined);
assert.equal(failed.length, 1);
assert.equal(succeeded.length, 1);
assert.match(failed[0].result.error, /Permission denied/);
assert.deepEqual(await fs.readFile(succeeded[0].targetPath), fixture.payload);
assert.equal(fixture.rejectedOpens(), 0);
});
+1
View File
@@ -244,6 +244,7 @@ function createBridgeRegistrar(context) {
},
resolveBrandIcon: (pluginId) => pluginHostService?.contributionIconService?.resolveBrand(pluginId),
resolveContributionIcon: (payload) => pluginHostService?.contributionIconService?.resolve(payload),
themeService: pluginHostService?.themeService,
viewHost: pluginHostService?.viewHost,
env: process.env,
isTrustedSender: createTrustedPluginBridgeSender({ devServerUrl: effectiveDevServerUrl }),
+57 -54
View File
@@ -12,25 +12,29 @@ const { assertPluginJsonValue } = require("./jsonBoundary.cjs");
const MAX_CATALOG_BYTES = 2 * 1024 * 1024;
const CATALOG_FIELDS = new Set(["id", "version", "name", "description", "author", "repository", "commit", "license", "readme", "manifest", "artifact"]);
const DOWNLOAD_HOSTS = new Set(["github.com", "raw.githubusercontent.com", "release-assets.githubusercontent.com", "objects.githubusercontent.com"]);
function assertDownloadUrl(value) {
const { OFFICIAL_SOURCE, assertSource } = require("./sourceIdentity.cjs");
const DISTRIBUTION_ORIGIN = "https://plugins.netcatty.app";
function assertDownloadUrl(value, origin = DISTRIBUTION_ORIGIN) {
if (typeof value !== "string" || /[\u0000-\u0020\u007f\\%?#]/u.test(value)) {
throw new Error("Catalog download URL is not an approved HTTPS static distribution source");
}
const url = new URL(value);
if (url.protocol !== "https:" || url.username || url.password || url.port || !DOWNLOAD_HOSTS.has(url.hostname)) {
throw new Error("Catalog download URL is not an approved HTTPS GitHub source");
if (url.origin !== origin || url.username || url.password || url.port
|| url.href !== value || url.pathname.split("/").some((part) => part === "." || part === "..")) {
throw new Error("Catalog download URL is not an approved HTTPS static distribution source");
}
return url.href;
}
function pinnedReadmeUrl(entry) {
function assertReadmeSource(entry) {
if (typeof entry.readme !== "string" || /[\u0000-\u001f\u007f\\%?#]/u.test(entry.readme)
|| entry.readme.split("/").some((segment) => !segment || segment === "." || segment === "..")) {
throw new Error("Invalid or unpinned README path");
}
const prefix = `/${entry.repository}/${entry.commit}/`;
const url = new URL(`https://raw.githubusercontent.com${prefix}${entry.readme.split("/").map(encodeURIComponent).join("/")}`);
if (!url.pathname.startsWith(prefix)) throw new Error("README URL escapes the pinned commit");
return url.href;
if (entry.repository.split("/").some((part) => part === "." || part === "..")) {
throw new Error("README source escapes the pinned commit");
}
}
function validateCatalog(entries) {
function validateCatalog(entries, origin = DISTRIBUTION_ORIGIN) {
assertPluginJsonValue(entries, { maxBytes: MAX_CATALOG_BYTES });
if (!Array.isArray(entries) || entries.length > 1000) throw new Error("Invalid plugin catalog");
const ids = new Set();
@@ -43,71 +47,68 @@ function validateCatalog(entries) {
|| entry.readme.split("/").some((p) => !p || p === "." || p === "..") || /[\\%?#\0]/u.test(entry.readme)) {
throw new Error("Invalid or unpinned plugin catalog entry");
}
pinnedReadmeUrl(entry);
assertReadmeSource(entry);
if (!validateManifest(entry.manifest)) throw new Error(`Catalog manifest is invalid: ${formatValidationErrors(validateManifest.errors)}`);
assertCatalogManifest(entry.manifest, entry);
ids.add(entry.id);
if (Object.hasOwn(entry, "artifact")) {
if (!entry.artifact || typeof entry.artifact !== "object" || Array.isArray(entry.artifact)
|| Object.keys(entry.artifact).some((key) => key !== "url" && key !== "sha256")
|| typeof entry.artifact.url !== "string" || typeof entry.artifact.sha256 !== "string") {
throw new Error("Invalid catalog artifact metadata");
for (const field of ["artifact"]) {
if (!Object.hasOwn(entry, field)) continue;
const asset = entry[field];
if (!asset || typeof asset !== "object" || Array.isArray(asset)
|| Object.keys(asset).some((key) => key !== "url" && key !== "sha256")
|| typeof asset.url !== "string" || typeof asset.sha256 !== "string") {
throw new Error(`Invalid catalog ${field} metadata`);
}
assertDownloadUrl(entry.artifact.url);
if (!/^[a-f0-9]{64}$/u.test(entry.artifact.sha256)) throw new Error("Catalog artifact requires SHA-256");
assertDownloadUrl(asset.url, origin);
if (!/^[a-f0-9]{64}$/u.test(asset.sha256)) throw new Error(`Catalog ${field} requires SHA-256`);
}
}
return entries;
}
async function fetchBounded(url, limit, fetchImpl = fetch, maxRedirects = 4) {
async function fetchBounded(url, limit, fetchImpl = fetch, origin = DISTRIBUTION_ORIGIN) {
const signal = AbortSignal.timeout(30_000);
for (let redirects = 0; redirects <= maxRedirects; redirects++) {
const response = await fetchImpl(assertDownloadUrl(url), { redirect: "manual", signal, credentials: "omit", referrerPolicy: "no-referrer" });
if ([301, 302, 303, 307, 308].includes(response.status)) {
await response.body?.cancel();
url = new URL(response.headers.get("location"), url).href;
continue;
}
if (!response.ok) { await response.body?.cancel(); throw new Error(`Catalog download failed (${response.status})`); }
if (Number(response.headers.get("content-length")) > limit) {
await response.body?.cancel(); throw new Error("Catalog download exceeds size limit");
}
const reader = response.body?.getReader();
if (!reader) throw new Error("Catalog download has no body");
const chunks = [];
let size = 0;
try {
for (;;) {
const { done, value } = await reader.read();
if (done) break;
size += value.byteLength;
if (size > limit) throw new Error("Catalog download exceeds size limit");
chunks.push(Buffer.from(value));
}
return Buffer.concat(chunks, size);
} finally { await reader.cancel(); }
const response = await fetchImpl(assertDownloadUrl(url, origin), { redirect: "manual", signal, credentials: "omit", referrerPolicy: "no-referrer" });
if ([301, 302, 303, 307, 308].includes(response.status)) {
await response.body?.cancel();
throw new Error("Catalog redirects are not allowed");
}
throw new Error("Too many catalog redirects");
if (!response.ok) { await response.body?.cancel(); throw new Error(`Catalog download failed (${response.status})`); }
if (Number(response.headers.get("content-length")) > limit) {
await response.body?.cancel(); throw new Error("Catalog download exceeds size limit");
}
const reader = response.body?.getReader();
if (!reader) throw new Error("Catalog download has no body");
const chunks = [];
let size = 0;
try {
for (;;) {
const { done, value } = await reader.read();
if (done) break;
size += value.byteLength;
if (size > limit) throw new Error("Catalog download exceeds size limit");
chunks.push(Buffer.from(value));
}
return Buffer.concat(chunks, size);
} finally { await reader.cancel(); }
}
class PluginCatalogService {
constructor(options) {
this.entries = structuredClone(validateCatalog(options.entries ?? catalog));
this.marketSource = assertSource(options.source ?? OFFICIAL_SOURCE);
this.origin = this.marketSource.origin;
this.entries = structuredClone(validateCatalog(options.entries ?? (this.marketSource.id === "official" ? catalog : []), this.origin));
this.fetch = options.fetch ?? fetch;
this.getTempFilePath = options.getTempFilePath ?? getTempFilePath;
this.manager = options.manager;
this.sourceGuard = options.sourceGuard ?? (() => {});
this.installing = false;
}
list() { return structuredClone(this.entries); }
artifactSize() { return undefined; }
get(id) {
const entry = this.entries.find((item) => item.id === id);
if (!entry) throw new Error("Plugin is not in the curated catalog");
return entry;
}
async readme(id) {
const entry = this.get(id);
const url = pinnedReadmeUrl(entry);
return (await fetchBounded(url, 256 * 1024, this.fetch, 0)).toString("utf8");
}
async install(id) {
const entry = this.get(id);
if (!entry.artifact) throw new Error("This source example has no published package; build and import locally");
@@ -115,16 +116,18 @@ class PluginCatalogService {
this.installing = true;
let directory;
try {
const bytes = await fetchBounded(entry.artifact.url, 32 * 1024 * 1024, this.fetch);
const bytes = await fetchBounded(entry.artifact.url, 32 * 1024 * 1024, this.fetch, this.origin);
const expectedSize = this.artifactSize(entry);
if (expectedSize !== undefined && bytes.byteLength !== expectedSize) throw new Error("Catalog package size mismatch");
if (createHash("sha256").update(bytes).digest("hex") !== entry.artifact.sha256) throw new Error("Catalog package SHA-256 mismatch");
directory = await fs.mkdtemp(this.getTempFilePath("plugin-catalog-"));
const archive = path.join(directory, "package.ncpkg");
await fs.writeFile(archive, bytes, { mode: 0o600, flag: "wx" });
return await this.manager.install(archive, { enable: false, catalog: entry });
return await this.manager.install(archive, { enable: false, catalog: entry, source: this.marketSource, sourceGuard: this.sourceGuard });
} finally {
this.installing = false;
if (directory) await fs.rm(directory, { recursive: true, force: true });
}
}
}
module.exports = { PluginCatalogService, assertDownloadUrl, fetchBounded, validateCatalog, pinnedReadmeUrl, MAX_CATALOG_BYTES };
module.exports = { PluginCatalogService, assertDownloadUrl, fetchBounded, validateCatalog, assertReadmeSource, MAX_CATALOG_BYTES, DISTRIBUTION_ORIGIN };
+4 -12
View File
@@ -24,25 +24,17 @@ test("curated catalog uses pinned public first-party source and rejects advanced
assert.throws(() => validateCatalog([{ ...entries[0], commit: "main" }]), /unpinned/);
});
test("README retrieval is bounded and pinned, never follows a redirect to latest", async () => {
const requested = [];
const service = new PluginCatalogService({ entries, fetch: async (url) => {
requested.push(url); return new Response("# First-party example");
} });
assert.equal(await service.readme(entries[0].id), "# First-party example");
assert.equal(requested[0], `https://raw.githubusercontent.com/${entries[0].repository}/${entries[0].commit}/${entries[0].readme}`);
service.fetch = async () => new Response(null, { status: 302, headers: { location: "https://raw.githubusercontent.com/a/b/main/README.md" } });
await assert.rejects(service.readme(entries[0].id), /redirects/);
await assert.rejects(fetchBounded("https://raw.githubusercontent.com/a/b/c", 3, async () => new Response("1234")), /size limit/);
test("static downloader is bounded and never follows redirects", async () => {
await assert.rejects(fetchBounded("https://plugins.netcatty.app/a", 3, async () => new Response("1234")), /size limit/);
await assert.rejects(fetchBounded("https://127.0.0.1/a", 10), /approved HTTPS/);
await assert.rejects(fetchBounded("https://github.com/a", 10, async () => new Response(null, { status: 302, headers: { location: "http://localhost/secret" } })), /approved HTTPS/);
await assert.rejects(fetchBounded("https://plugins.netcatty.app/a", 10, async () => new Response(null, { status: 302, headers: { location: "https://github.com/a" } })), /redirects/);
});
test("catalog install verifies the digest, shares manager installation, and never auto-enables", async (context) => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "netcatty-catalog-test-"));
context.after(() => fs.rm(root, { recursive: true, force: true }));
const bytes = Buffer.from("synthetic bytes: package validation belongs to PackageStore");
const entry = { ...structuredClone(entries[0]), artifact: { url: "https://github.com/example/repo/releases/download/v1/plugin.ncpkg", sha256: createHash("sha256").update(bytes).digest("hex") } };
const entry = { ...structuredClone(entries[0]), artifact: { url: "https://plugins.netcatty.app/packages/synthetic.ncpkg", sha256: createHash("sha256").update(bytes).digest("hex") } };
let installs = 0;
const service = new PluginCatalogService({ entries: [entry], getTempFilePath: (name) => {
assert.equal(name, "plugin-catalog-");
+159 -20
View File
@@ -5,10 +5,14 @@ const fs = require("node:fs");
const path = require("node:path");
const { DatabaseSync } = require("node:sqlite");
const SCHEMA_VERSION = 3;
const { LOCAL_SOURCE, assertSource, privateOwner } = require("./sourceIdentity.cjs");
const SCHEMA_VERSION = 7;
const MAX_SECURITY_AUDIT_DETAILS_BYTES = 16 * 1024;
const MAX_SECURITY_AUDIT_RECORDS_PER_PLUGIN = 1_000;
const REQUIRED_SCHEMA_COLUMNS = Object.freeze({
plugin_source_bindings: ["plugin_id", "source_json"],
plugin_host_preferences: ["key", "value_json", "updated_at"],
plugins: ["id", "enabled", "active_version", "installed_at", "updated_at"],
plugin_versions: ["plugin_id", "version", "manifest_json", "archive_sha256", "package_relative_path", "installed_at"],
plugin_runtime_state: ["plugin_id", "plugin_version", "status", "runtime_kind", "last_error", "quarantined_at", "updated_at"],
@@ -212,7 +216,50 @@ class PluginDatabase {
`);
});
}
if (version <= 3) this.transaction(() => this.db.exec(`
CREATE TABLE plugin_host_preferences (key TEXT PRIMARY KEY, value_json TEXT NOT NULL, updated_at INTEGER NOT NULL);
PRAGMA user_version = 4;
`));
if (version <= 4) this.transaction(() => this.db.exec(`
CREATE TABLE IF NOT EXISTS plugin_source_bindings (plugin_id TEXT PRIMARY KEY, source_json TEXT NOT NULL);
`));
this.#assertSchemaLayout();
if (version <= 5) this.transaction(() => {
// Upgraded installs and retained local private records must keep their
// provenance after uninstall. Never infer official ownership from names.
const rows = this.db.prepare(`
SELECT id AS plugin_id FROM plugins
UNION SELECT plugin_id FROM plugin_settings
UNION SELECT plugin_id FROM plugin_view_state
UNION SELECT plugin_id FROM plugin_permission_grants
UNION SELECT plugin_id FROM plugin_secrets
UNION SELECT plugin_id FROM plugin_sync_sidecars
`).all();
const insert = this.db.prepare("INSERT OR IGNORE INTO plugin_source_bindings(plugin_id,source_json) VALUES(?,?)");
for (const row of rows) {
if (/^[a-z0-9][a-z0-9.-]{1,127}$/u.test(row.plugin_id)) insert.run(row.plugin_id, JSON.stringify(LOCAL_SOURCE));
}
this.db.exec("PRAGMA user_version = 6");
});
if (version <= 6) this.transaction(() => {
// One-time conversion of existing local private rows; all sources now use
// immutable encoded owners. Never retain a raw-ID lookup fallback.
for (const table of ["plugin_settings", "plugin_view_state", "plugin_permission_grants", "plugin_secrets", "plugin_sync_sidecars"]) {
const rows = this.db.prepare(`SELECT DISTINCT plugin_id FROM ${table}`).all();
for (const { plugin_id: id } of rows) {
if (/^[a-z0-9][a-z0-9.-]{1,127}$/u.test(id)) {
this.db.prepare(`UPDATE ${table} SET plugin_id = ? WHERE plugin_id = ?`).run(privateOwner(LOCAL_SOURCE, id), id);
}
}
}
for (const row of this.db.prepare("SELECT plugin_id, key FROM plugin_kv").all()) {
if (this.getPluginSource(row.plugin_id).id === "local") {
const prefix = `${privateOwner(LOCAL_SOURCE, row.plugin_id)}:`;
if (!row.key.startsWith(prefix)) this.db.prepare("UPDATE plugin_kv SET key = ? WHERE plugin_id = ? AND key = ?").run(prefix + row.key, row.plugin_id, row.key);
}
}
this.db.exec("PRAGMA user_version = 7");
});
// Legacy-map backfill is deferred until PackageStore.recover() has finished
// (hostService seeds after package initialize). Running here would promote
// sole map candidates before recovered manifests are visible, then skip
@@ -245,15 +292,64 @@ class PluginDatabase {
}
}
getPluginSource(pluginId) {
const row = this.db.prepare("SELECT source_json FROM plugin_source_bindings WHERE plugin_id = ?").get(pluginId);
return row ? assertSource(parseJson(row.source_json, "source identity")) : { ...LOCAL_SOURCE };
}
getRetainedPluginSource(pluginId) {
const row = this.db.prepare("SELECT source_json FROM plugin_source_bindings WHERE plugin_id = ?").get(pluginId);
if (row) return assertSource(parseJson(row.source_json, "source identity"));
return this.db.prepare("SELECT id FROM plugins WHERE id = ?").get(pluginId) ? { ...LOCAL_SOURCE } : null;
}
assertInstallSource(pluginId, source = LOCAL_SOURCE) {
const trusted = assertSource(source);
const row = this.db.prepare("SELECT source_json FROM plugin_source_bindings WHERE plugin_id = ?").get(pluginId);
const existing = row ? assertSource(parseJson(row.source_json, "source identity"))
: this.getActivePlugin(pluginId) ? LOCAL_SOURCE : null;
if (existing && existing.id !== trusted.id) {
throw new Error("Plugin source differs. Uninstall and explicitly clear the old source's private data before reinstalling.");
}
return trusted;
}
privateOwner(pluginId) {
// Opaque owners enter only from host sync storage, never plugin RPC identity.
if (/^source:[a-f0-9]{64}:[a-z0-9][a-z0-9.-]{1,127}$/u.test(pluginId)) return pluginId;
const source = this.getPluginSource(pluginId);
return privateOwner(source, pluginId);
}
publicPluginId(owner) {
if (!/^source:[a-f0-9]{64}:[a-z0-9][a-z0-9.-]{1,127}$/u.test(owner)) return null;
const pluginId = owner.slice(owner.lastIndexOf(":") + 1);
return this.privateOwner(pluginId) === owner ? pluginId : null;
}
clearUninstalledSourceData(pluginId, expectedSourceId) {
if (this.getActivePlugin(pluginId)) throw new Error("Uninstall the plugin before changing its source");
if (this.getPluginSource(pluginId).id !== expectedSourceId) throw new Error("Plugin source changed; review the reset again");
const owner = this.privateOwner(pluginId);
this.transaction(() => {
for (const table of ["plugin_settings", "plugin_view_state", "plugin_permission_grants", "plugin_secrets", "plugin_sync_sidecars", "plugin_sync_provider_bindings"]) {
this.db.prepare(`DELETE FROM ${table} WHERE plugin_id = ? OR plugin_id = ?`).run(owner, pluginId);
}
this.db.prepare("DELETE FROM plugin_source_bindings WHERE plugin_id = ?").run(pluginId);
});
}
installVersion(record, options = {}) {
if (options.enable === true && options.forceDisabled === true) {
throw new TypeError("Plugin version cannot be enabled and force-disabled together");
}
const source = this.assertInstallSource(record.pluginId, record.source);
const now = this.clock();
const manifestJson = JSON.stringify(record.manifest);
const requestedEnabled = options.enable === true ? 1 : 0;
const overwriteEnabled = options.enable === true || options.forceDisabled === true;
this.transaction(() => {
this.db.prepare("INSERT INTO plugin_source_bindings(plugin_id,source_json) VALUES(?,?) ON CONFLICT(plugin_id) DO UPDATE SET source_json=excluded.source_json").run(record.pluginId, JSON.stringify(source));
this.db.prepare(`
INSERT INTO plugins(id, enabled, active_version, installed_at, updated_at)
VALUES (?, ?, ?, ?, ?)
@@ -360,6 +456,7 @@ class PluginDatabase {
#mapPlugin(row) {
return {
id: row.id,
source: this.getPluginSource(row.id),
enabled: row.enabled === 1,
activeVersion: row.active_version ?? null,
installedAt: Number(row.installed_at),
@@ -493,6 +590,8 @@ class PluginDatabase {
}
getValue(pluginId, key) {
const owner = this.privateOwner(pluginId);
if (owner !== pluginId) key = `${owner}:${key}`;
const row = this.db.prepare(
"SELECT value_json FROM plugin_kv WHERE plugin_id = ? AND key = ?",
).get(pluginId, key);
@@ -500,6 +599,8 @@ class PluginDatabase {
}
setValue(pluginId, key, value) {
const owner = this.privateOwner(pluginId);
if (owner !== pluginId) key = `${owner}:${key}`;
const serialized = JSON.stringify(value);
if (serialized === undefined) throw new TypeError("Plugin storage value must be JSON serializable");
this.db.prepare(`
@@ -512,16 +613,22 @@ class PluginDatabase {
}
deleteValue(pluginId, key) {
const owner = this.privateOwner(pluginId);
if (owner !== pluginId) key = `${owner}:${key}`;
this.db.prepare("DELETE FROM plugin_kv WHERE plugin_id = ? AND key = ?").run(pluginId, key);
}
listKeys(pluginId) {
return this.db.prepare(
"SELECT key FROM plugin_kv WHERE plugin_id = ? ORDER BY key COLLATE BINARY",
).all(pluginId).map((row) => row.key);
).all(pluginId).map((row) => row.key).filter((key) => {
const owner = this.privateOwner(pluginId);
return owner === pluginId || key.startsWith(`${owner}:`);
}).map((key) => this.privateOwner(pluginId) === pluginId ? key : key.slice(this.privateOwner(pluginId).length + 1));
}
getSetting(pluginId, settingId, scope, scopeId) {
pluginId = this.privateOwner(pluginId);
const row = this.db.prepare(`
SELECT value_json FROM plugin_settings
WHERE plugin_id = ? AND setting_id = ? AND scope = ? AND scope_id = ?
@@ -530,6 +637,7 @@ class PluginDatabase {
}
setSetting(pluginId, settingId, scope, scopeId, value, updatedAt = this.clock()) {
pluginId = this.privateOwner(pluginId);
const serialized = JSON.stringify(value);
if (serialized === undefined) throw new TypeError("Plugin setting value must be JSON serializable");
const stamp = Number.isFinite(Number(updatedAt)) ? Number(updatedAt) : this.clock();
@@ -543,6 +651,7 @@ class PluginDatabase {
}
deleteSetting(pluginId, settingId, scope, scopeId) {
pluginId = this.privateOwner(pluginId);
this.db.prepare(`
DELETE FROM plugin_settings
WHERE plugin_id = ? AND setting_id = ? AND scope = ? AND scope_id = ?
@@ -550,6 +659,7 @@ class PluginDatabase {
}
listSettings(pluginId) {
pluginId = this.privateOwner(pluginId);
return this.db.prepare(`
SELECT setting_id, scope, scope_id, value_json, updated_at
FROM plugin_settings WHERE plugin_id = ?
@@ -579,6 +689,7 @@ class PluginDatabase {
}
getSyncSidecar(pluginId, kind, key) {
pluginId = this.privateOwner(pluginId);
const row = this.db.prepare(`
SELECT value_json, updated_at FROM plugin_sync_sidecars
WHERE plugin_id = ? AND kind = ? AND key = ?
@@ -594,6 +705,7 @@ class PluginDatabase {
}
setSyncSidecar(pluginId, kind, key, value, updatedAt = this.clock()) {
pluginId = this.privateOwner(pluginId);
const serialized = JSON.stringify(value);
if (serialized === undefined) throw new TypeError("Plugin sync sidecar value must be JSON serializable");
this.db.prepare(`
@@ -606,6 +718,7 @@ class PluginDatabase {
}
deleteSyncSidecar(pluginId, kind, key) {
pluginId = this.privateOwner(pluginId);
this.db.prepare(`
DELETE FROM plugin_sync_sidecars
WHERE plugin_id = ? AND kind = ? AND key = ?
@@ -613,6 +726,7 @@ class PluginDatabase {
}
listSyncSidecars(pluginId) {
pluginId = this.privateOwner(pluginId);
return this.db.prepare(`
SELECT plugin_id, kind, CAST(key AS BLOB) AS key_bytes, value_json, updated_at
FROM plugin_sync_sidecars
@@ -667,6 +781,7 @@ class PluginDatabase {
}
getViewState(pluginId, viewId, scopeId) {
pluginId = this.privateOwner(pluginId);
const row = this.db.prepare(`
SELECT state_json FROM plugin_view_state
WHERE plugin_id = ? AND view_id = ? AND scope_id = ?
@@ -675,6 +790,7 @@ class PluginDatabase {
}
setViewState(pluginId, viewId, scopeId, state) {
pluginId = this.privateOwner(pluginId);
const serialized = JSON.stringify(state);
if (serialized === undefined) throw new TypeError("Plugin view state must be JSON serializable");
this.db.prepare(`
@@ -687,6 +803,7 @@ class PluginDatabase {
}
deleteViewState(pluginId, viewId, scopeId) {
pluginId = this.privateOwner(pluginId);
this.db.prepare(`
DELETE FROM plugin_view_state
WHERE plugin_id = ? AND view_id = ? AND scope_id = ?
@@ -699,8 +816,8 @@ class PluginDatabase {
FROM plugin_permission_grants
WHERE plugin_id = ?
ORDER BY permission COLLATE BINARY, resource COLLATE BINARY
`).all(pluginId).map((row) => ({
pluginId: row.plugin_id,
`).all(this.privateOwner(pluginId)).map((row) => ({
pluginId,
permission: row.permission,
resource: row.resource,
resourceKind: row.resource_kind,
@@ -719,7 +836,7 @@ class PluginDatabase {
declaration_hash = excluded.declaration_hash,
granted_at = excluded.granted_at
`).run(
record.pluginId,
this.privateOwner(record.pluginId),
record.permission,
record.resource,
record.resourceKind,
@@ -729,6 +846,7 @@ class PluginDatabase {
}
deletePermissionGrant(pluginId, permission, resource) {
pluginId = this.privateOwner(pluginId);
this.db.prepare(`
DELETE FROM plugin_permission_grants
WHERE plugin_id = ? AND permission = ? AND resource = ?
@@ -736,10 +854,12 @@ class PluginDatabase {
}
deleteAllPermissionGrants(pluginId) {
pluginId = this.privateOwner(pluginId);
this.db.prepare("DELETE FROM plugin_permission_grants WHERE plugin_id = ?").run(pluginId);
}
getSecretByKey(pluginId, key) {
pluginId = this.privateOwner(pluginId);
const row = this.db.prepare(`
SELECT plugin_id, key, secret_ref, ciphertext, created_at, updated_at
FROM plugin_secrets WHERE plugin_id = ? AND key = ?
@@ -748,6 +868,7 @@ class PluginDatabase {
}
getSecretByRef(pluginId, secretRef) {
pluginId = this.privateOwner(pluginId);
const row = this.db.prepare(`
SELECT plugin_id, key, secret_ref, ciphertext, created_at, updated_at
FROM plugin_secrets WHERE plugin_id = ? AND secret_ref = ?
@@ -757,7 +878,7 @@ class PluginDatabase {
#mapSecret(row) {
return {
pluginId: row.plugin_id,
pluginId: this.publicPluginId(row.plugin_id),
key: row.key,
secretRef: row.secret_ref,
ciphertext: Buffer.from(row.ciphertext),
@@ -777,7 +898,7 @@ class PluginDatabase {
ciphertext = excluded.ciphertext,
updated_at = excluded.updated_at
`).run(
record.pluginId,
this.privateOwner(record.pluginId),
record.key,
record.secretRef,
record.ciphertext,
@@ -787,12 +908,14 @@ class PluginDatabase {
}
deleteSecret(pluginId, key) {
pluginId = this.privateOwner(pluginId);
this.db.prepare("DELETE FROM plugin_secrets WHERE plugin_id = ? AND key = ?")
.run(pluginId, key);
}
/** Delete all secrets whose key equals prefix or starts with `${prefix}:`. */
deleteSecretsByKeyPrefix(pluginId, prefix) {
pluginId = this.privateOwner(pluginId);
const result = this.db.prepare(`
DELETE FROM plugin_secrets
WHERE plugin_id = ?
@@ -852,7 +975,7 @@ class PluginDatabase {
SELECT DISTINCT plugin_id
FROM plugin_secrets
WHERE key = ? OR key LIKE ? ESCAPE '\\'
`).all(prefix, `${escaped}:%`).map((row) => row.plugin_id);
`).all(prefix, `${escaped}:%`).map((row) => this.publicPluginId(row.plugin_id)).filter((id) => id !== null);
}
/**
@@ -885,7 +1008,7 @@ class PluginDatabase {
/** @type {Map<string, Array<{ pluginId: string, key: string }>>} */
const byProvider = new Map();
for (const row of legacyRows) {
const pluginId = row.plugin_id;
const pluginId = this.publicPluginId(row.plugin_id);
const key = row.key;
if (typeof pluginId !== "string" || typeof key !== "string") continue;
if (!key.startsWith(legacyPrefix)) continue;
@@ -1030,18 +1153,34 @@ class PluginDatabase {
sha256: createHash("sha256").update(detailsJson).digest("hex"),
});
}
this.transaction(() => this.#appendSecurityAudit(pluginId, event, detailsJson));
}
#appendSecurityAudit(pluginId, event, detailsJson) {
this.db.prepare(`
INSERT INTO plugin_security_audit(plugin_id, event, details_json, created_at)
VALUES (?, ?, ?, ?)
`).run(pluginId, event, detailsJson, this.clock());
this.db.prepare(`
DELETE FROM plugin_security_audit
WHERE plugin_id = ? AND id NOT IN (
SELECT id FROM plugin_security_audit
WHERE plugin_id = ? ORDER BY id DESC LIMIT ${MAX_SECURITY_AUDIT_RECORDS_PER_PLUGIN}
)
`).run(pluginId, pluginId);
}
getHostThemeSelection() {
const row = this.db.prepare("SELECT value_json FROM plugin_host_preferences WHERE key = 'theme'").get();
return row ? parseJson(row.value_json, "host theme selection") : {formatVersion: 1, revision: 0, light: null, dark: null};
}
commitHostThemeSelection(expectedRevision, selection, pluginId) {
this.transaction(() => {
this.db.prepare(`
INSERT INTO plugin_security_audit(plugin_id, event, details_json, created_at)
VALUES (?, ?, ?, ?)
`).run(pluginId, event, detailsJson, this.clock());
this.db.prepare(`
DELETE FROM plugin_security_audit
WHERE plugin_id = ? AND id NOT IN (
SELECT id FROM plugin_security_audit
WHERE plugin_id = ? ORDER BY id DESC LIMIT ${MAX_SECURITY_AUDIT_RECORDS_PER_PLUGIN}
)
`).run(pluginId, pluginId);
if (this.getHostThemeSelection().revision !== expectedRevision) throw new Error("Theme selection changed; refresh before applying");
this.db.prepare("INSERT INTO plugin_host_preferences(key,value_json,updated_at) VALUES('theme',?,?) ON CONFLICT(key) DO UPDATE SET value_json=excluded.value_json,updated_at=excluded.updated_at")
.run(JSON.stringify(selection), this.clock());
this.#appendSecurityAudit(pluginId ?? "$host.appearance", "theme-selection", JSON.stringify({revision:selection.revision, action:pluginId ? "apply" : "restore"}));
});
}
+14 -13
View File
@@ -6,6 +6,7 @@ const os = require("node:os");
const path = require("node:path");
const test = require("node:test");
const { DatabaseSync } = require("node:sqlite");
const { privateOwner, LOCAL_SOURCE } = require("./sourceIdentity.cjs");
const {
MAX_SECURITY_AUDIT_DETAILS_BYTES,
@@ -488,7 +489,7 @@ test("schema upgrade backfills bindings from legacy sync-provider-map secrets",
INSERT INTO plugin_secrets(plugin_id, key, secret_ref, ciphertext, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?)
`).run(
"com.example",
privateOwner(LOCAL_SOURCE, "com.example"),
"sync-provider-map:com.example.sync",
"ref-legacy-map",
Buffer.from("sealed"),
@@ -499,7 +500,7 @@ test("schema upgrade backfills bindings from legacy sync-provider-map secrets",
INSERT INTO plugin_secrets(plugin_id, key, secret_ref, ciphertext, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?)
`).run(
"com.example",
privateOwner(LOCAL_SOURCE, "com.example"),
"sync-credential",
"ref-cred",
Buffer.from("sealed-cred"),
@@ -532,7 +533,7 @@ test("legacy map backfill does not overwrite an existing host binding", (context
INSERT INTO plugin_secrets(plugin_id, key, secret_ref, ciphertext, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?)
`).run(
"com.example",
privateOwner(LOCAL_SOURCE, "com.example"),
"sync-provider-map:com.example.sync.foo",
"ref-stale-parent-map",
Buffer.from("sealed"),
@@ -566,7 +567,7 @@ test("legacy map backfill skips when no candidate holds sync credentials", (cont
database.db.prepare(`
INSERT INTO plugin_secrets(plugin_id, key, secret_ref, ciphertext, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?)
`).run(pluginId, key, `ref-${pluginId}`, Buffer.from("sealed"), now, now);
`).run(privateOwner(LOCAL_SOURCE, pluginId), key, `ref-${pluginId}`, Buffer.from("sealed"), now, now);
}
const promoted = database.backfillSyncProviderBindingsFromLegacySecrets();
assert.equal(promoted, 0, "map-only candidates must not bind without credentials");
@@ -589,7 +590,7 @@ test("legacy map backfill binds sole credential-backed owner when maps conflict"
database.db.prepare(`
INSERT INTO plugin_secrets(plugin_id, key, secret_ref, ciphertext, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?)
`).run(pluginId, key, `ref-${pluginId}-${key}`, Buffer.from(ciphertext), now, now);
`).run(privateOwner(LOCAL_SOURCE, pluginId), key, `ref-${pluginId}-${key}`, Buffer.from(ciphertext), now, now);
}
const promoted = database.backfillSyncProviderBindingsFromLegacySecrets();
assert.equal(promoted, 1);
@@ -618,7 +619,7 @@ test("legacy map backfill does not let longest map override credential-backed pa
database.db.prepare(`
INSERT INTO plugin_secrets(plugin_id, key, secret_ref, ciphertext, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?)
`).run(pluginId, key, `ref-${pluginId}-${key}`, Buffer.from(ciphertext), now, now);
`).run(privateOwner(LOCAL_SOURCE, pluginId), key, `ref-${pluginId}-${key}`, Buffer.from(ciphertext), now, now);
}
const promoted = database.backfillSyncProviderBindingsFromLegacySecrets();
assert.equal(promoted, 1);
@@ -648,7 +649,7 @@ test("legacy map backfill skips when multiple credential-backed candidates exist
database.db.prepare(`
INSERT INTO plugin_secrets(plugin_id, key, secret_ref, ciphertext, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?)
`).run(pluginId, key, `ref-${pluginId}-${key}`, Buffer.from(ciphertext), now, now);
`).run(privateOwner(LOCAL_SOURCE, pluginId), key, `ref-${pluginId}-${key}`, Buffer.from(ciphertext), now, now);
}
const promoted = database.backfillSyncProviderBindingsFromLegacySecrets();
assert.equal(promoted, 0, "ambiguous credential-backed candidates must not auto-bind");
@@ -672,7 +673,7 @@ test("legacy map backfill still consumes maps after unbind tombstone blocks prom
INSERT INTO plugin_secrets(plugin_id, key, secret_ref, ciphertext, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?)
`).run(
"com.example",
privateOwner(LOCAL_SOURCE, "com.example"),
"sync-provider-map:com.example.sync.foo",
"ref-tombstone-map",
Buffer.from("sealed"),
@@ -683,7 +684,7 @@ test("legacy map backfill still consumes maps after unbind tombstone blocks prom
INSERT INTO plugin_secrets(plugin_id, key, secret_ref, ciphertext, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?)
`).run(
"com.example",
privateOwner(LOCAL_SOURCE, "com.example"),
"sync-credential",
"ref-cred",
Buffer.from("secret"),
@@ -711,7 +712,7 @@ test("legacy map backfill deletes promoted map markers so unbind cannot resurrec
INSERT INTO plugin_secrets(plugin_id, key, secret_ref, ciphertext, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?)
`).run(
"com.example",
privateOwner(LOCAL_SOURCE, "com.example"),
"sync-provider-map:com.example.custom",
"ref-plugin-owned",
Buffer.from("plugin-owned-payload"),
@@ -723,7 +724,7 @@ test("legacy map backfill deletes promoted map markers so unbind cannot resurrec
INSERT INTO plugin_secrets(plugin_id, key, secret_ref, ciphertext, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?)
`).run(
"com.example",
privateOwner(LOCAL_SOURCE, "com.example"),
"sync-credential",
"ref-cred-keep",
Buffer.from("cred"),
@@ -750,7 +751,7 @@ test("legacy map backfill does not resurrect after explicit unbind tombstone", (
INSERT INTO plugin_secrets(plugin_id, key, secret_ref, ciphertext, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?)
`).run(
"com.example",
privateOwner(LOCAL_SOURCE, "com.example"),
"sync-provider-map:com.example.sync",
"ref-leftover-map",
Buffer.from("sealed"),
@@ -823,7 +824,7 @@ test("inferPluginIdForSyncProvider never guesses from credential key prefixes",
database.db.prepare(`
INSERT INTO plugin_secrets(plugin_id, key, secret_ref, ciphertext, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?)
`).run(pluginId, key, `ref-${pluginId}`, Buffer.from("x"), now, now);
`).run(privateOwner(LOCAL_SOURCE, pluginId), key, `ref-${pluginId}`, Buffer.from("x"), now, now);
}
// Parent prefix alone is not enough: a removed plugin may have owned
// com.example.backup.sync while only com.example still has credentials.
+16
View File
@@ -0,0 +1,16 @@
# Pinned static distribution schemas
`delivery.schema.json` and `index.schema.json` are unmodified UTF-8 files from
binaricat/netcatty-plugins commit 7bfe23e5627e16ba63806de6dded5b2667c02984
(PR 3). The host compiles them with the existing local plugin-contract schema;
no schema reference causes network access.
`../fixtures/static-distribution/` contains that commit's actual
`distribution/bootstrap/v1/snapshot.json`, snapshot catalog/index/delivery and
four referenced README/PNG assets. Raw-byte SHA-256 checks in
`staticDistribution.test.cjs` verify the publisher's supplied pointer and delivery
hashes; every document and asset is checked against the pointer/sidecar.
The fixture includes no executable plugin package, avatar or credentials.
Schema revisions must be coordinated with the registry publisher. In particular,
artifact/avatar are deliberately null in v1; do not expand this consumer alone.
@@ -0,0 +1,166 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "Netcatty static delivery v1 (source-only bootstrap)",
"type": "object",
"additionalProperties": false,
"required": [
"schemaVersion",
"registryCommit",
"plugins",
"catalogSha256",
"indexSha256",
"sourceLockSha256",
"builderSha256"
],
"properties": {
"schemaVersion": {
"const": 1
},
"registryCommit": {
"type": "string",
"pattern": "^[a-f0-9]{40}$"
},
"plugins": {
"type": "array",
"maxItems": 1000,
"items": {
"type": "object",
"additionalProperties": false,
"required": [
"id",
"version",
"repository",
"commit",
"readme",
"iconLight",
"iconDark",
"avatar",
"artifact"
],
"properties": {
"id": {
"type": "string"
},
"version": {
"type": "string"
},
"repository": {
"type": "string"
},
"commit": {
"type": "string",
"pattern": "^[a-f0-9]{40}$"
},
"readme": {
"type": "object",
"additionalProperties": false,
"required": [
"path",
"sha256",
"size"
],
"properties": {
"path": {
"type": "string",
"pattern": "^/assets/[a-f0-9]{64}\\.(md|png|webp|ncpkg)$"
},
"sha256": {
"type": "string",
"pattern": "^[a-f0-9]{64}$"
},
"size": {
"type": "integer",
"minimum": 0,
"maximum": 262144
}
}
},
"iconLight": {
"oneOf": [
{
"type": "null"
},
{
"type": "object",
"additionalProperties": false,
"required": [
"path",
"sha256",
"size"
],
"properties": {
"path": {
"type": "string",
"pattern": "^/assets/[a-f0-9]{64}\\.(md|png|webp|ncpkg)$"
},
"sha256": {
"type": "string",
"pattern": "^[a-f0-9]{64}$"
},
"size": {
"type": "integer",
"minimum": 0,
"maximum": 262144
}
}
}
]
},
"iconDark": {
"oneOf": [
{
"type": "null"
},
{
"type": "object",
"additionalProperties": false,
"required": [
"path",
"sha256",
"size"
],
"properties": {
"path": {
"type": "string",
"pattern": "^/assets/[a-f0-9]{64}\\.(md|png|webp|ncpkg)$"
},
"sha256": {
"type": "string",
"pattern": "^[a-f0-9]{64}$"
},
"size": {
"type": "integer",
"minimum": 0,
"maximum": 262144
}
}
}
]
},
"avatar": {
"type": "null"
},
"artifact": {
"type": "null"
}
}
}
},
"catalogSha256": {
"type": "string",
"pattern": "^[a-f0-9]{64}$"
},
"indexSha256": {
"type": "string",
"pattern": "^[a-f0-9]{64}$"
},
"sourceLockSha256": {
"type": "string",
"pattern": "^[a-f0-9]{64}$"
},
"builderSha256": {
"type": "string",
"pattern": "^[a-f0-9]{64}$"
}
}
}
@@ -0,0 +1,194 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"additionalProperties": false,
"required": [
"schemaVersion",
"plugins"
],
"properties": {
"schemaVersion": {
"const": 1
},
"plugins": {
"type": "array",
"maxItems": 1000,
"items": {
"type": "object",
"additionalProperties": false,
"required": [
"id",
"current",
"versions"
],
"properties": {
"id": {
"type": "string",
"minLength": 1,
"maxLength": 4096,
"pattern": "^[a-z0-9][a-z0-9.-]+$"
},
"current": {
"oneOf": [
{
"type": "string",
"minLength": 1,
"maxLength": 4096,
"pattern": "^(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)(-[0-9A-Za-z.-]+)?(\\+[0-9A-Za-z.-]+)?$"
},
{
"type": "null"
}
]
},
"versions": {
"type": "array",
"minItems": 1,
"maxItems": 1000,
"items": {
"type": "object",
"additionalProperties": false,
"required": [
"entry",
"distribution",
"withdrawal"
],
"properties": {
"entry": {
"type": "object",
"additionalProperties": false,
"required": [
"id",
"version",
"name",
"description",
"author",
"repository",
"commit",
"license",
"readme",
"manifest"
],
"properties": {
"id": {
"type": "string",
"minLength": 1,
"maxLength": 4096,
"pattern": "^[a-z0-9][a-z0-9.-]+$"
},
"version": {
"type": "string",
"minLength": 1,
"maxLength": 4096,
"pattern": "^(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)(-[0-9A-Za-z.-]+)?(\\+[0-9A-Za-z.-]+)?$"
},
"name": {
"type": "string",
"minLength": 1,
"maxLength": 4096
},
"description": {
"type": "string",
"minLength": 1,
"maxLength": 4096
},
"author": {
"type": "string",
"minLength": 1,
"maxLength": 4096
},
"repository": {
"type": "string",
"minLength": 1,
"maxLength": 4096,
"pattern": "^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$"
},
"commit": {
"type": "string",
"minLength": 1,
"maxLength": 4096,
"pattern": "^[a-f0-9]{40}$"
},
"license": {
"type": "string",
"minLength": 1,
"maxLength": 4096
},
"readme": {
"type": "string",
"minLength": 1,
"maxLength": 4096
},
"manifest": {
"$ref": "https://netcatty.com/schemas/plugins/0.1.0-internal.1/plugin-contract.schema.json"
},
"artifact": {
"type": "object",
"additionalProperties": false,
"required": [
"url",
"sha256"
],
"properties": {
"url": {
"type": "string",
"minLength": 1,
"maxLength": 4096
},
"sha256": {
"type": "string",
"pattern": "^[a-f0-9]{64}$"
}
}
}
}
},
"distribution": {
"enum": [
"source-only",
"release"
]
},
"tag": {
"type": "string",
"pattern": "^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$"
},
"withdrawal": {
"oneOf": [
{
"type": "null"
},
{
"type": "object",
"additionalProperties": false,
"required": [
"at",
"reason",
"advisory"
],
"properties": {
"at": {
"type": "string",
"format": "date-time"
},
"reason": {
"type": "string",
"minLength": 1,
"maxLength": 4096
},
"advisory": {
"type": "string",
"pattern": "^https://github.com/"
}
}
}
]
}
}
}
}
}
}
}
}
}
+122
View File
@@ -0,0 +1,122 @@
"use strict";
const { createHash } = require("node:crypto");
const { isDeepStrictEqual } = require("node:util");
const Ajv2020 = require("ajv/dist/2020").default;
const addFormats = require("ajv-formats").default;
const { assertPluginJsonValue } = require("./jsonBoundary.cjs");
const { fetchBounded, validateCatalog, DISTRIBUTION_ORIGIN, MAX_CATALOG_BYTES } = require("./catalogService.cjs");
const { formatValidationErrors } = require("./contractValidator.cjs");
const REGISTRY_URL = `${DISTRIBUTION_ORIGIN}/v1/snapshot.json`;
const SHA256 = /^[a-f0-9]{64}$/u;
const COMMIT = /^[a-f0-9]{40}$/u;
const ajv = new Ajv2020({ allErrors: true, strict: false, validateFormats: true });
addFormats(ajv);
ajv.addSchema(require("./generated/plugin-contract.schema.json"));
const validateIndex = ajv.compile(require("./distribution/index.schema.json"));
const deliverySchema = require("./distribution/delivery.schema.json");
const validateDelivery = ajv.compile(deliverySchema);
const deliveryV2 = structuredClone(deliverySchema);
deliveryV2.title = "Netcatty static delivery v2 (hash-bound packages)";
deliveryV2.properties.schemaVersion.const = 2;
const packageRef = structuredClone(deliveryV2.properties.plugins.items.properties.readme);
packageRef.properties.path.pattern = "^/assets/[a-f0-9]{64}\\.ncpkg$";
packageRef.properties.size.maximum = 32 * 1024 * 1024;
deliveryV2.properties.plugins.items.properties.artifact = { oneOf: [{ type: "null" }, packageRef] };
const validateDeliveryV2 = ajv.compile(deliveryV2);
const digest = (bytes) => createHash("sha256").update(bytes).digest("hex");
const tuple = (entry) => JSON.stringify([entry.id, entry.version, entry.repository, entry.commit]);
function exactKeys(value, keys) {
return value && typeof value === "object" && !Array.isArray(value)
&& Object.keys(value).length === keys.length && keys.every((key) => Object.hasOwn(value, key));
}
function assertReference(ref, path, limit) {
if (!exactKeys(ref, ["path", "sha256", "size"]) || typeof ref.sha256 !== "string" || !SHA256.test(ref.sha256)
|| ref.path !== path || !Number.isSafeInteger(ref.size) || ref.size < 0 || ref.size > limit) {
throw new Error("Invalid static snapshot resource reference");
}
}
function parseJson(bytes, label, limit = MAX_CATALOG_BYTES) {
const value = JSON.parse(bytes.toString("utf8"));
assertPluginJsonValue(value, { maxBytes: limit });
if (!value || typeof value !== "object") throw new Error(`Invalid ${label}`);
return value;
}
function assertPointer(pointer) {
if (!exactKeys(pointer, ["schemaVersion", "registryCommit", "snapshotId", "catalog", "index", "delivery"])
|| pointer.schemaVersion !== 1 || typeof pointer.registryCommit !== "string" || !COMMIT.test(pointer.registryCommit)
|| typeof pointer.snapshotId !== "string" || !SHA256.test(pointer.snapshotId)) {
throw new Error("Invalid static snapshot pointer");
}
for (const name of ["catalog", "index", "delivery"]) {
assertReference(pointer[name], `/snapshots/${pointer.snapshotId}/${name}.json`, MAX_CATALOG_BYTES);
}
if (pointer.snapshotId !== pointer.delivery.sha256) throw new Error("Snapshot identity does not match delivery hash");
}
function assertAsset(ref, extensions) {
if (!ref || typeof ref.path !== "string" || !extensions.some((extension) => ref.path === `/assets/${ref.sha256}.${extension}`)) {
throw new Error("Invalid static asset path or format");
}
assertReference(ref, ref.path, 256 * 1024);
}
async function fetchVerified(ref, fetchImpl, origin = DISTRIBUTION_ORIGIN) {
const bytes = await fetchBounded(`${origin}${ref.path}`, ref.size, fetchImpl, origin);
if (bytes.byteLength !== ref.size || digest(bytes) !== ref.sha256) throw new Error("Static resource size or SHA-256 mismatch");
return bytes;
}
function validateSet(pointer, entries, index, delivery, origin = DISTRIBUTION_ORIGIN) {
validateCatalog(entries, origin);
if (!validateIndex(index)) throw new Error(`Invalid distribution index: ${formatValidationErrors(validateIndex.errors)}`);
const validator = delivery.schemaVersion === 2 ? validateDeliveryV2 : validateDelivery;
if (!validator(delivery)) throw new Error(`Invalid static delivery: ${formatValidationErrors(validator.errors)}`);
if (delivery.registryCommit !== pointer.registryCommit || delivery.catalogSha256 !== pointer.catalog.sha256
|| delivery.indexSha256 !== pointer.index.sha256) throw new Error("Static snapshot documents do not match");
// v1 publishing deliberately refuses packages and avatars. Do not extend this
// consumer ahead of the reviewed publishing schema or invent installable code.
if (delivery.schemaVersion === 1 && entries.some((entry) => entry.artifact)) throw new Error("Static delivery v1 is source-only");
const current = new Map();
const indexIds = new Set();
for (const plugin of index.plugins) {
if (indexIds.has(plugin.id)) throw new Error("Duplicate distribution index plugin");
indexIds.add(plugin.id);
const versions = new Map();
for (const version of plugin.versions) {
if (version.entry.id !== plugin.id || versions.has(version.entry.version)) throw new Error("Invalid distribution version identity");
versions.set(version.entry.version, version);
}
if (plugin.current !== null) {
const version = versions.get(plugin.current);
if (!version || version.withdrawal !== null || (version.distribution !== (version.entry.artifact ? "release" : "source-only"))) throw new Error("Current distribution version is missing or withdrawn");
current.set(plugin.id, version.entry);
}
}
if (current.size !== entries.length || entries.some((entry) => !isDeepStrictEqual(current.get(entry.id), entry))) {
throw new Error("Catalog does not match the current non-withdrawn index");
}
const resources = new Map();
const catalogTuples = new Set(entries.map(tuple));
for (const plugin of delivery.plugins) {
const key = tuple(plugin);
if (!catalogTuples.has(key) || resources.has(key)) throw new Error("Delivery identity does not match catalog tuple");
assertAsset(plugin.readme, ["md"]);
for (const icon of [plugin.iconLight, plugin.iconDark]) if (icon !== null) assertAsset(icon, ["png", "webp"]);
const entry = entries.find((item) => tuple(item) === key);
if (plugin.artifact !== null) {
assertReference(plugin.artifact, `/assets/${plugin.artifact.sha256}.ncpkg`, 32 * 1024 * 1024);
if (!entry.artifact || entry.artifact.sha256 !== plugin.artifact.sha256 || entry.artifact.url !== `${origin}${plugin.artifact.path}`) throw new Error("Package delivery does not match catalog hash and source");
} else if (entry.artifact) throw new Error("Catalog package has no delivery resource");
resources.set(key, plugin);
}
if (resources.size !== entries.length) throw new Error("Missing delivery entry for catalog plugin");
return resources;
}
async function fetchDistribution(fetchImpl, origin = DISTRIBUTION_ORIGIN) {
const pointer = parseJson(await fetchBounded(`${origin}/v1/snapshot.json`, 16 * 1024, fetchImpl, origin), "snapshot pointer", 16 * 1024);
assertPointer(pointer);
const documents = await Promise.all(["catalog", "index", "delivery"].map(async (name) => parseJson(await fetchVerified(pointer[name], fetchImpl, origin), name)));
const [entries, index, delivery] = documents;
const resources = validateSet(pointer, entries, index, delivery, origin);
return { entries, resources, pointer };
}
module.exports = { REGISTRY_URL, fetchDistribution, fetchVerified, tuple, validateSet, assertPointer, assertAsset };
@@ -0,0 +1,53 @@
# Hello Netcatty
This package is the runnable internal example for the plugin API. Package and install it to exercise native settings,
command-palette contributions, lazy command and Provider activation,
localization, terminal completion/decoration Providers, and the runtime SDK.
From the repository root:
```bash
npm run build:plugin-packages
node packages/plugin-cli/dist/cli.js validate examples/plugins/hello-netcatty
node packages/plugin-cli/dist/cli.js compatibility examples/plugins/hello-netcatty --netcatty 0.0.0
node packages/plugin-cli/dist/cli.js pack examples/plugins/hello-netcatty --out /tmp/hello-netcatty.ncpkg
```
After installation, change **Greeting** under **Settings → Plugins**, then run
**Examples: Say Hello** from the command palette. The setting is read through
the host settings broker and the command handler is registered inside the
sandboxed plugin runtime. Type `netc` at a shell prompt to exercise the bounded
completion Provider, and print `Hello from Netcatty` to exercise the declarative
decoration Provider. Neither Provider receives raw xterm objects or sensitive
terminal input/output streams.
## Import and inspect
Launch the normal browser-only plugin build using a fresh Electron
user-data directory for demonstrations. In **Settings > Plugins**, choose
**Import .ncpkg**, select the generated archive, and open its details. Review the
manifest permissions, engine ranges and SHA-256, then choose **Enable**. Native
permission prompts still mediate activation and sensitive operations.
Importing an update is a manual action and leaves it disabled, including when
the previous version was enabled. Review the new package before enabling it.
Changing unsigned package contents changes its security principal; previous
persistent grants are not transferred to different bytes.
An incompatible application/API upgrade disables installed plugins before
publishing their contributions. The reason remains in the installed list, and
package files and local data remain available. Returning to a compatible host
requires explicit re-enable. Uninstall is different: package code and local KV
are deleted; user-owned settings, secret storage and sync sidecars are retained.
For a synthetic demonstration, use only this first-party example, change the
Greeting, invoke **Examples: Say Hello**, disable/re-enable, restart the app and
inspect the package again. Do not connect real hosts or a cloud account. The
existing greeting setting is local; declaring `sync: true` on a non-secret
setting opts it into the existing encrypted settings sidecar. Generic SDK KV
storage does not sync.
For a demonstration without terminal permissions, use `examples/plugins/starter-netcatty`.
Enabling this lazy example does not grant permissions: required permissions are
requested before its first command/provider activation, and optional permissions
when used. Never treat Enabled as a security certification or grant.
@@ -0,0 +1,37 @@
# Plugin Starter
A minimal first-party browser plugin for learning import, settings and lifecycle.
It requests `commands`, `settings.read`, `menus` and `views`: no terminal text, host credentials,
network, files, secrets or advanced Node access. It works without OS key storage.
Permission declarations are not grants; follow the app's normal mediation.
From the repository root:
```sh
npm run build:plugin-packages
node packages/plugin-cli/dist/cli.js validate examples/plugins/starter-netcatty
node packages/plugin-cli/dist/cli.js pack examples/plugins/starter-netcatty --out /tmp/starter-netcatty.ncpkg
```
Start the actual app normally with a fresh test profile; no development flag is
needed. Open **Settings > Plugins > Open plugin manager > Installed**, import the package, inspect its
permissions and enable it. Change **Greeting**, then find **Starter: Greeting Panel** (入门:问候面板)
in the command palette. Review the normal permission prompts, then click
**Show greeting** to see the exact preference in the sandboxed panel.
**Starter: Read Greeting** is also listed and refreshes an already-open panel.
The panel inserts text using `textContent`, never HTML. It does not write to
a terminal or connect to a host. Denied permissions remain denied.
Disable, restart and re-enable to verify the preference persists. Re-importing
the same package leaves it disabled for review. Uninstall removes code and local
KV; user-owned settings remain. The greeting is local only, not cloud-synced.
The separate Hello Netcatty example demonstrates terminal providers and requests
terminal permissions. Use this Starter for the default no-sensitive-access demo.
Version 0.1.2 adds light/dark PNG brand icons in `assets/`. Displaying these
images does not activate the plugin or request permissions. This example now
requires API `>=0.1.0-internal.1 <0.2.0` and `metadata.brand-icon`.
Older Starter 0.1.1 packages remain compatible with the new host.
See [brand icon author instructions](../../../docs/plugin-platform/plugin-icons.md).
@@ -0,0 +1,195 @@
[
{
"id": "com.netcatty.starter",
"version": "0.1.2",
"name": "Plugin Starter",
"description": "First-party greeting panel, local settings and commands, without terminal or secret access.",
"author": "Netcatty",
"repository": "binaricat/Netcatty",
"commit": "f4a6b205adca486d4e9cda0270e13a9ff0afd7be",
"license": "GPL-3.0-or-later",
"readme": "examples/plugins/starter-netcatty/README.md",
"manifest": {
"manifestVersion": 1,
"id": "com.netcatty.starter",
"name": "starter-netcatty",
"displayName": {
"en": "Plugin Starter",
"zh-CN": "插件入门"
},
"description": "First-party settings and command example. No terminal, network, filesystem or secret access.",
"version": "0.1.2",
"publisher": "netcatty",
"license": "GPL-3.0-or-later",
"engines": {
"netcatty": ">=0.0.0",
"api": ">=0.1.0-internal.1 <0.2.0"
},
"main": {
"browser": "dist/index.js"
},
"activationEvents": [
"onCommand:com.netcatty.starter.greet",
"onView:com.netcatty.starter.panel"
],
"permissions": {
"required": [
"commands",
"settings.read",
"menus",
"views"
],
"optional": []
},
"contributes": {
"settings": [
{
"id": "com.netcatty.starter.greeting",
"label": {
"en": "Greeting",
"zh-CN": "问候语"
},
"description": "A local preference for the example command.",
"control": "text",
"scope": "application",
"default": "Hello, synthetic world!"
}
],
"commands": [
{
"id": "com.netcatty.starter.greet",
"title": {
"en": "Starter: Read Greeting",
"zh-CN": "入门:读取问候语"
}
}
],
"menus": [
{
"command": "com.netcatty.starter.greet",
"location": "commandPalette",
"group": "examples"
}
],
"views": [
{
"id": "com.netcatty.starter.panel",
"title": {
"en": "Starter: Greeting Panel",
"zh-CN": "入门:问候面板"
},
"location": "modal",
"entry": "panel.html"
}
]
},
"features": {
"required": [
"metadata.brand-icon"
]
},
"icon": {
"kind": "package",
"light": "assets/logo-light.png",
"dark": "assets/logo-dark.png"
}
}
},
{
"id": "com.netcatty.hello",
"version": "0.1.0",
"name": "Hello Netcatty",
"description": "First-party example: greeting settings, commands and terminal providers. Build locally and import.",
"author": "Netcatty",
"repository": "binaricat/Netcatty",
"commit": "4336f8fd53a05a48d6bbc9ad3ce0ca88b121862f",
"license": "GPL-3.0-or-later",
"readme": "examples/plugins/hello-netcatty/README.md",
"manifest": {
"$schema": "../../../packages/plugin-contract/schema/plugin-contract.schema.json",
"manifestVersion": 1,
"id": "com.netcatty.hello",
"name": "hello-netcatty",
"displayName": {
"en": "Hello Netcatty",
"zh-CN": "你好,Netcatty"
},
"description": "Minimal plugin contract and SDK example.",
"version": "0.1.0",
"publisher": "netcatty",
"license": "GPL-3.0-or-later",
"engines": {
"netcatty": ">=0.0.0",
"api": ">=0.1.0-internal <0.2.0"
},
"main": {
"browser": "dist/index.js"
},
"activationEvents": [
"onCommand:com.netcatty.hello.sayHello",
"onProvider:com.netcatty.hello.completion",
"onProvider:com.netcatty.hello.decoration",
"onProvider:com.netcatty.hello.theme"
],
"permissions": {
"required": [
"commands",
"settings.read",
"menus",
"provider.terminal",
"terminal.complete",
"terminal.output",
"terminal.decorate"
],
"optional": []
},
"contributes": {
"settings": [
{
"id": "com.netcatty.hello.greeting",
"label": "Greeting",
"description": "Text used by the example command.",
"control": "text",
"scope": "application",
"default": "Hello from Netcatty"
}
],
"commands": [
{
"id": "com.netcatty.hello.sayHello",
"title": "Say Hello",
"category": "Examples"
}
],
"menus": [
{
"command": "com.netcatty.hello.sayHello",
"location": "commandPalette",
"group": "examples",
"order": 10
}
],
"providers": [
{
"id": "com.netcatty.hello.completion",
"label": "Hello completion",
"description": "Suggests the example hello command.",
"kind": "terminal.completion"
},
{
"id": "com.netcatty.hello.decoration",
"label": "Hello decoration",
"description": "Highlights the example greeting in terminal output.",
"kind": "terminal.decoration"
},
{
"id": "com.netcatty.hello.theme",
"label": "Hello theme",
"description": "Adds a subtle cursor color to the example terminal theme.",
"kind": "terminal.theme"
}
]
}
}
}
]
@@ -0,0 +1,48 @@
{
"builderSha256": "57199cc6a5d30f5f356adb072d51de9e2283dac6e26c647a4404e4c2261c4a70",
"catalogSha256": "47e967516437996e00eac8fbe97699baa72439ba1bf7ae791731e4964bb13774",
"indexSha256": "af2e953e3c631b36a0e712806d0dcfb0a058fd02f2051025eddded8cc115d461",
"plugins": [
{
"artifact": null,
"avatar": null,
"commit": "f4a6b205adca486d4e9cda0270e13a9ff0afd7be",
"iconDark": {
"path": "/assets/7a897a67ae6b652339c77a113d1c759919776fead82d2eefd0238d3c05ba87fe.png",
"sha256": "7a897a67ae6b652339c77a113d1c759919776fead82d2eefd0238d3c05ba87fe",
"size": 873
},
"iconLight": {
"path": "/assets/ec7bcd5e470ee3d3d9f7f77d120a4de7496a9c0ba984094f84e560d240bea947.png",
"sha256": "ec7bcd5e470ee3d3d9f7f77d120a4de7496a9c0ba984094f84e560d240bea947",
"size": 873
},
"id": "com.netcatty.starter",
"readme": {
"path": "/assets/d685ec68558c81705f03195ca35a0a62ea6ec75d8837d5ddb5b5a970890aae6e.md",
"sha256": "d685ec68558c81705f03195ca35a0a62ea6ec75d8837d5ddb5b5a970890aae6e",
"size": 2050
},
"repository": "binaricat/Netcatty",
"version": "0.1.2"
},
{
"artifact": null,
"avatar": null,
"commit": "4336f8fd53a05a48d6bbc9ad3ce0ca88b121862f",
"iconDark": null,
"iconLight": null,
"id": "com.netcatty.hello",
"readme": {
"path": "/assets/1692573445e3688b4fe3612282becc086b191a034c0609deeaaebf615627c209.md",
"sha256": "1692573445e3688b4fe3612282becc086b191a034c0609deeaaebf615627c209",
"size": 2951
},
"repository": "binaricat/Netcatty",
"version": "0.1.0"
}
],
"registryCommit": "32578ae27ff86f0151511478d698a1cdc12fb2e0",
"schemaVersion": 1,
"sourceLockSha256": "753711c1860ff0fcc10a558182f8c868d9d06b40e5c8de5522249d0d27b139d6"
}
@@ -0,0 +1,218 @@
{
"schemaVersion": 1,
"plugins": [
{
"id": "com.netcatty.starter",
"current": "0.1.2",
"versions": [
{
"entry": {
"id": "com.netcatty.starter",
"version": "0.1.2",
"name": "Plugin Starter",
"description": "First-party greeting panel, local settings and commands, without terminal or secret access.",
"author": "Netcatty",
"repository": "binaricat/Netcatty",
"commit": "f4a6b205adca486d4e9cda0270e13a9ff0afd7be",
"license": "GPL-3.0-or-later",
"readme": "examples/plugins/starter-netcatty/README.md",
"manifest": {
"manifestVersion": 1,
"id": "com.netcatty.starter",
"name": "starter-netcatty",
"displayName": {
"en": "Plugin Starter",
"zh-CN": "插件入门"
},
"description": "First-party settings and command example. No terminal, network, filesystem or secret access.",
"version": "0.1.2",
"publisher": "netcatty",
"license": "GPL-3.0-or-later",
"engines": {
"netcatty": ">=0.0.0",
"api": ">=0.1.0-internal.1 <0.2.0"
},
"main": {
"browser": "dist/index.js"
},
"activationEvents": [
"onCommand:com.netcatty.starter.greet",
"onView:com.netcatty.starter.panel"
],
"permissions": {
"required": [
"commands",
"settings.read",
"menus",
"views"
],
"optional": []
},
"contributes": {
"settings": [
{
"id": "com.netcatty.starter.greeting",
"label": {
"en": "Greeting",
"zh-CN": "问候语"
},
"description": "A local preference for the example command.",
"control": "text",
"scope": "application",
"default": "Hello, synthetic world!"
}
],
"commands": [
{
"id": "com.netcatty.starter.greet",
"title": {
"en": "Starter: Read Greeting",
"zh-CN": "入门:读取问候语"
}
}
],
"menus": [
{
"command": "com.netcatty.starter.greet",
"location": "commandPalette",
"group": "examples"
}
],
"views": [
{
"id": "com.netcatty.starter.panel",
"title": {
"en": "Starter: Greeting Panel",
"zh-CN": "入门:问候面板"
},
"location": "modal",
"entry": "panel.html"
}
]
},
"features": {
"required": [
"metadata.brand-icon"
]
},
"icon": {
"kind": "package",
"light": "assets/logo-light.png",
"dark": "assets/logo-dark.png"
}
}
},
"distribution": "source-only",
"withdrawal": null
}
]
},
{
"id": "com.netcatty.hello",
"current": "0.1.0",
"versions": [
{
"entry": {
"id": "com.netcatty.hello",
"version": "0.1.0",
"name": "Hello Netcatty",
"description": "First-party example: greeting settings, commands and terminal providers. Build locally and import.",
"author": "Netcatty",
"repository": "binaricat/Netcatty",
"commit": "4336f8fd53a05a48d6bbc9ad3ce0ca88b121862f",
"license": "GPL-3.0-or-later",
"readme": "examples/plugins/hello-netcatty/README.md",
"manifest": {
"$schema": "../../../packages/plugin-contract/schema/plugin-contract.schema.json",
"manifestVersion": 1,
"id": "com.netcatty.hello",
"name": "hello-netcatty",
"displayName": {
"en": "Hello Netcatty",
"zh-CN": "你好,Netcatty"
},
"description": "Minimal plugin contract and SDK example.",
"version": "0.1.0",
"publisher": "netcatty",
"license": "GPL-3.0-or-later",
"engines": {
"netcatty": ">=0.0.0",
"api": ">=0.1.0-internal <0.2.0"
},
"main": {
"browser": "dist/index.js"
},
"activationEvents": [
"onCommand:com.netcatty.hello.sayHello",
"onProvider:com.netcatty.hello.completion",
"onProvider:com.netcatty.hello.decoration",
"onProvider:com.netcatty.hello.theme"
],
"permissions": {
"required": [
"commands",
"settings.read",
"menus",
"provider.terminal",
"terminal.complete",
"terminal.output",
"terminal.decorate"
],
"optional": []
},
"contributes": {
"settings": [
{
"id": "com.netcatty.hello.greeting",
"label": "Greeting",
"description": "Text used by the example command.",
"control": "text",
"scope": "application",
"default": "Hello from Netcatty"
}
],
"commands": [
{
"id": "com.netcatty.hello.sayHello",
"title": "Say Hello",
"category": "Examples"
}
],
"menus": [
{
"command": "com.netcatty.hello.sayHello",
"location": "commandPalette",
"group": "examples",
"order": 10
}
],
"providers": [
{
"id": "com.netcatty.hello.completion",
"label": "Hello completion",
"description": "Suggests the example hello command.",
"kind": "terminal.completion"
},
{
"id": "com.netcatty.hello.decoration",
"label": "Hello decoration",
"description": "Highlights the example greeting in terminal output.",
"kind": "terminal.decoration"
},
{
"id": "com.netcatty.hello.theme",
"label": "Hello theme",
"description": "Adds a subtle cursor color to the example terminal theme.",
"kind": "terminal.theme"
}
]
}
}
},
"distribution": "source-only",
"withdrawal": null
}
]
}
]
}
@@ -0,0 +1,20 @@
{
"catalog": {
"path": "/snapshots/53e5189d2cb58a02826931d5d6321ad3e556b924ef80d38636dc9e66a7df8c55/catalog.json",
"sha256": "47e967516437996e00eac8fbe97699baa72439ba1bf7ae791731e4964bb13774",
"size": 5739
},
"delivery": {
"path": "/snapshots/53e5189d2cb58a02826931d5d6321ad3e556b924ef80d38636dc9e66a7df8c55/delivery.json",
"sha256": "53e5189d2cb58a02826931d5d6321ad3e556b924ef80d38636dc9e66a7df8c55",
"size": 1869
},
"index": {
"path": "/snapshots/53e5189d2cb58a02826931d5d6321ad3e556b924ef80d38636dc9e66a7df8c55/index.json",
"sha256": "af2e953e3c631b36a0e712806d0dcfb0a058fd02f2051025eddded8cc115d461",
"size": 7726
},
"registryCommit": "32578ae27ff86f0151511478d698a1cdc12fb2e0",
"schemaVersion": 1,
"snapshotId": "53e5189d2cb58a02826931d5d6321ad3e556b924ef80d38636dc9e66a7df8c55"
}
@@ -0,0 +1,40 @@
"use strict";
const fs = require("node:fs");
const path = require("node:path");
const { createHash } = require("node:crypto");
const root = path.join(__dirname, "static-distribution");
const digest = (bytes) => createHash("sha256").update(bytes).digest("hex");
const read = (name) => fs.readFileSync(path.join(root, name));
const catalog = JSON.parse(read("catalog.json"));
const delivery = JSON.parse(read("delivery.json"));
function realFiles() {
const pointer = JSON.parse(read("snapshot.json"));
const files = new Map([["/v1/snapshot.json", read("snapshot.json")]]);
for (const name of ["catalog", "index", "delivery"]) files.set(pointer[name].path, read(`${name}.json`));
for (const name of fs.readdirSync(path.join(root, "assets"))) files.set(`/assets/${name}`, read(`assets/${name}`));
return files;
}
function makeFiles(entries = catalog, mutate = () => {}) {
const index = { schemaVersion: 1, plugins: entries.map((entry) => ({ id: entry.id, current: entry.version, versions: [{ entry: structuredClone(entry), distribution: "source-only", withdrawal: null }] })) };
const sidecar = { ...structuredClone(delivery), plugins: entries.map((entry) => ({ ...structuredClone(delivery.plugins.find((item) => item.id === entry.id) ?? delivery.plugins[0]), id: entry.id, version: entry.version, repository: entry.repository, commit: entry.commit })) };
mutate(index, sidecar);
const catalogBytes = Buffer.from(JSON.stringify(entries));
const indexBytes = Buffer.from(JSON.stringify(index));
sidecar.catalogSha256 = digest(catalogBytes); sidecar.indexSha256 = digest(indexBytes);
const deliveryBytes = Buffer.from(JSON.stringify(sidecar));
const snapshotId = digest(deliveryBytes);
const pointer = { schemaVersion: 1, registryCommit: sidecar.registryCommit, snapshotId };
const files = realFiles();
for (const [name, bytes] of [["catalog", catalogBytes], ["index", indexBytes], ["delivery", deliveryBytes]]) {
const ref = { path: `/snapshots/${snapshotId}/${name}.json`, size: bytes.length, sha256: digest(bytes) };
pointer[name] = ref; files.set(ref.path, bytes);
}
files.set("/v1/snapshot.json", Buffer.from(JSON.stringify(pointer)));
return files;
}
function responseFor(files, url) {
if (new URL(url).origin !== "https://plugins.netcatty.app") throw new Error("Unexpected upstream origin");
const bytes = files.get(new URL(url).pathname);
return bytes ? new Response(bytes) : new Response("Missing fixture", { status: 404 });
}
module.exports = { root, read, digest, catalog, delivery, realFiles, makeFiles, responseFor };
@@ -902,10 +902,27 @@
},
"node": {
"$ref": "#/$defs/RelativePackagePath"
},
"data": {
"const": "theme"
}
},
"minProperties": 1,
"additionalProperties": false
"additionalProperties": false,
"if": {
"required": [
"data"
],
"type": "object",
"properties": {
"data": {
"const": "theme"
}
}
},
"then": {
"maxProperties": 1
}
},
"PluginEngines": {
"type": "object",
@@ -1293,9 +1310,34 @@
"panel",
"tab",
"modal",
"settings"
"settings",
"vault-page",
"terminal-sidebar"
]
},
"VaultPagesFeature": {
"const": "views.vault-pages"
},
"TerminalSidebarFeature": {
"const": "views.terminal-sidebar"
},
"PluginViewFocusEvent": {
"type": "object",
"required": [
"instanceId"
],
"properties": {
"instanceId": {
"type": "string",
"minLength": 1,
"maxLength": 128
}
},
"additionalProperties": false
},
"PluginViewFocusRequest": {
"$ref": "#/$defs/PluginViewFocusEvent"
},
"ViewContribution": {
"type": "object",
"required": [
@@ -3270,6 +3312,14 @@
"PluginContributions": {
"type": "object",
"properties": {
"themes": {
"type": "array",
"maxItems": 32,
"items": {
"$ref": "#/$defs/ThemePackContribution"
},
"minItems": 1
},
"settings": {
"type": "array",
"maxItems": 512,
@@ -3432,7 +3482,51 @@
}
}
},
"additionalProperties": false
"additionalProperties": false,
"if": {
"properties": {
"main": {
"required": [
"data"
],
"type": "object",
"properties": {
"data": {
"const": "theme"
}
}
}
}
},
"then": {
"properties": {
"contributes": {
"required": [
"themes"
],
"maxProperties": 1,
"type": "object",
"properties": {
"themes": true
}
},
"permissions": {
"maxProperties": 0,
"type": "object"
},
"activationEvents": {
"maxItems": 0,
"type": "array"
},
"companionExecutables": {
"maxItems": 0,
"type": "array"
}
},
"required": [
"contributes"
]
}
},
"RpcId": {
"oneOf": [
@@ -4747,6 +4841,328 @@
"additionalProperties": false
}
]
},
"ThemePackFeature": {
"const": "appearance.theme-packs"
},
"ThemePackLimits": {
"const": {
"jsonBytes": 65536,
"catalogEntries": 256,
"themesPerPackage": 32
}
},
"ThemePackVariant": {
"type": "string",
"enum": [
"light",
"dark"
]
},
"ThemePackContribution": {
"type": "object",
"properties": {
"id": {
"$ref": "#/$defs/ContributionId"
},
"label": {
"$ref": "#/$defs/LocalizedText"
},
"variant": {
"$ref": "#/$defs/ThemePackVariant"
},
"entry": {
"$ref": "#/$defs/RelativePackagePath"
}
},
"additionalProperties": false,
"required": [
"id",
"label",
"variant",
"entry"
]
},
"ThemePackColors": {
"type": "object",
"properties": {
"background": {
"type": "string",
"pattern": "^#[0-9A-Fa-f]{6}$"
},
"foreground": {
"type": "string",
"pattern": "^#[0-9A-Fa-f]{6}$"
},
"card": {
"type": "string",
"pattern": "^#[0-9A-Fa-f]{6}$"
},
"cardForeground": {
"type": "string",
"pattern": "^#[0-9A-Fa-f]{6}$"
},
"popover": {
"type": "string",
"pattern": "^#[0-9A-Fa-f]{6}$"
},
"popoverForeground": {
"type": "string",
"pattern": "^#[0-9A-Fa-f]{6}$"
},
"secondary": {
"type": "string",
"pattern": "^#[0-9A-Fa-f]{6}$"
},
"secondaryForeground": {
"type": "string",
"pattern": "^#[0-9A-Fa-f]{6}$"
},
"muted": {
"type": "string",
"pattern": "^#[0-9A-Fa-f]{6}$"
},
"mutedForeground": {
"type": "string",
"pattern": "^#[0-9A-Fa-f]{6}$"
},
"accent": {
"type": "string",
"pattern": "^#[0-9A-Fa-f]{6}$"
},
"destructive": {
"type": "string",
"pattern": "^#[0-9A-Fa-f]{6}$"
},
"destructiveForeground": {
"type": "string",
"pattern": "^#[0-9A-Fa-f]{6}$"
},
"border": {
"type": "string",
"pattern": "^#[0-9A-Fa-f]{6}$"
},
"input": {
"type": "string",
"pattern": "^#[0-9A-Fa-f]{6}$"
}
},
"additionalProperties": false,
"minProperties": 1
},
"ThemePackPalette": {
"type": "object",
"properties": {
"formatVersion": {
"const": 1
},
"base": {
"type": "string",
"enum": [
"light",
"dark"
]
},
"colors": {
"$ref": "#/$defs/ThemePackColors"
}
},
"additionalProperties": false,
"required": [
"formatVersion",
"base",
"colors"
]
},
"ThemePackReference": {
"type": "object",
"properties": {
"pluginId": {
"$ref": "#/$defs/PluginId"
},
"themeId": {
"$ref": "#/$defs/ContributionId"
},
"version": {
"$ref": "#/$defs/SemanticVersion"
},
"digest": {
"type": "string",
"pattern": "^[a-f0-9]{64}$"
},
"variant": {
"$ref": "#/$defs/ThemePackVariant"
}
},
"additionalProperties": false,
"required": [
"pluginId",
"themeId",
"version",
"digest",
"variant"
]
},
"ThemePackChoice": {
"type": "object",
"properties": {
"ref": {
"$ref": "#/$defs/ThemePackReference"
}
},
"additionalProperties": false,
"required": [
"ref"
]
},
"ThemePackSelection": {
"type": "object",
"properties": {
"formatVersion": {
"const": 1
},
"revision": {
"$ref": "#/$defs/SafeUnsignedInteger"
},
"light": {
"oneOf": [
{
"$ref": "#/$defs/ThemePackChoice"
},
{
"type": "null"
}
]
},
"dark": {
"oneOf": [
{
"$ref": "#/$defs/ThemePackChoice"
},
{
"type": "null"
}
]
}
},
"additionalProperties": false,
"required": [
"formatVersion",
"revision",
"light",
"dark"
]
},
"ThemePackApplyRequest": {
"type": "object",
"properties": {
"expectedRevision": {
"$ref": "#/$defs/SafeUnsignedInteger"
},
"mode": {
"type": "string",
"enum": [
"light",
"dark"
]
},
"choice": {
"oneOf": [
{
"$ref": "#/$defs/ThemePackChoice"
},
{
"type": "null"
}
]
}
},
"additionalProperties": false,
"required": [
"expectedRevision",
"mode",
"choice"
]
},
"ThemePackEntry": {
"type": "object",
"properties": {
"ref": {
"$ref": "#/$defs/ThemePackReference"
},
"label": {
"type": "string",
"maxLength": 512
},
"pluginName": {
"type": "string",
"maxLength": 512
},
"dataOnly": {
"type": "boolean"
},
"palette": {
"$ref": "#/$defs/ThemePackPalette"
}
},
"additionalProperties": false,
"required": [
"ref",
"label",
"pluginName",
"dataOnly",
"palette"
]
},
"ThemePackCatalog": {
"type": "object",
"properties": {
"selection": {
"$ref": "#/$defs/ThemePackSelection"
},
"entries": {
"type": "array",
"maxItems": 256,
"items": {
"$ref": "#/$defs/ThemePackEntry"
}
},
"diagnostics": {
"type": "array",
"maxItems": 256,
"items": {
"type": "object",
"properties": {
"pluginId": {
"$ref": "#/$defs/PluginId"
},
"themeId": {
"type": "string",
"maxLength": 192
},
"code": {
"type": "string",
"enum": [
"invalid",
"unavailable",
"capacity"
]
}
},
"additionalProperties": false,
"required": [
"pluginId",
"code"
]
}
}
},
"additionalProperties": false,
"required": [
"selection",
"entries",
"diagnostics"
]
}
}
}
+13 -4
View File
@@ -2,6 +2,8 @@
const path = require("node:path");
const NETWORK_CREDENTIAL_LEASE_FEATURE = require("./generated/plugin-contract.schema.json").$defs.NetworkCredentialLeaseFeature.const;
const VAULT_PAGES_FEATURE = require("./generated/plugin-contract.schema.json").$defs.VaultPagesFeature.const;
const TERMINAL_SIDEBAR_FEATURE = require("./generated/plugin-contract.schema.json").$defs.TerminalSidebarFeature.const;
const INTERACTIVE_COMMAND_FEATURE = require("./generated/plugin-contract.schema.json").$defs.InteractiveCommandFeature.const;
const { assertBrowserOnlyPlugin } = require("../../domain/pluginRuntimePolicy.cjs");
@@ -19,7 +21,8 @@ const {
createUtilityModuleMappings,
normalizePluginModuleResources,
} = require("./moduleResources.cjs");
const { RegistryCatalogService } = require("./registryCatalogService.cjs");
const { MarketSourcesService } = require("./marketSourcesService.cjs");
const { PluginThemeService } = require("./themeService.cjs");
const { PackageStore } = require("./packageStore.cjs");
const { createPluginPaths } = require("./paths.cjs");
const { PluginManager } = require("./pluginManager.cjs");
@@ -96,7 +99,7 @@ function createPluginHostService(options) {
database,
netcattyVersion: options.app.getVersion(),
apiVersion: PLUGIN_API_VERSION,
supportedFeatures: [...new Set(["metadata.brand-icon", NETWORK_CREDENTIAL_LEASE_FEATURE, HOST_INTERACTION_FEATURE, INTERACTIVE_COMMAND_FEATURE, ...(options.supportedFeatures ?? [])])],
supportedFeatures: [...new Set(["appearance.theme-packs", "metadata.brand-icon", NETWORK_CREDENTIAL_LEASE_FEATURE, HOST_INTERACTION_FEATURE, VAULT_PAGES_FEATURE, TERMINAL_SIDEBAR_FEATURE, INTERACTIVE_COMMAND_FEATURE, ...(options.supportedFeatures ?? [])])],
});
const contributionIconService = new PluginContributionIconService({
database,
@@ -236,7 +239,7 @@ function createPluginHostService(options) {
paths,
netcattyVersion: options.app.getVersion(),
apiVersion: PLUGIN_API_VERSION,
supportedFeatures: [...new Set(["metadata.brand-icon", NETWORK_CREDENTIAL_LEASE_FEATURE, HOST_INTERACTION_FEATURE, INTERACTIVE_COMMAND_FEATURE, ...(options.supportedFeatures ?? [])])],
supportedFeatures: [...new Set(["appearance.theme-packs", "metadata.brand-icon", NETWORK_CREDENTIAL_LEASE_FEATURE, HOST_INTERACTION_FEATURE, VAULT_PAGES_FEATURE, TERMINAL_SIDEBAR_FEATURE, INTERACTIVE_COMMAND_FEATURE, ...(options.supportedFeatures ?? [])])],
runtimeDirectory,
appRoot,
rpcRegistry,
@@ -364,15 +367,20 @@ function createPluginHostService(options) {
packageStore,
database,
contributionService,
isLocked: options.isAppLocked,
subscribeLock: options.subscribeAppLock,
})
: null;
const themeService = new PluginThemeService({database,packageStore,contributionService});
const manager = new PluginManager({
secretStore,
permissionEngine,
database,
packageStore,
runtimeSupervisor,
contributionService,
beforeClose: async () => {
themeService.shutdown();
interactionService.shutdown();
terminalDataPipelineService?.shutdown();
extensionProviderService.shutdown();
@@ -385,8 +393,9 @@ function createPluginHostService(options) {
},
});
return {
themeService,
interactionService,
catalogService: new RegistryCatalogService({ manager, fetch: options.electron.net?.fetch?.bind(options.electron.net) }),
catalogService: new MarketSourcesService({ database, manager, fetch: options.electron.net?.fetch?.bind(options.electron.net) }),
companionSupervisor,
contributionIconService,
contributionService,
+42
View File
@@ -0,0 +1,42 @@
"use strict";
const { PluginSourceRegistry } = require("./sourceRegistry.cjs");
const { RegistryCatalogService } = require("./registryCatalogService.cjs");
const { createSourceFetch } = require("./sourceFetch.cjs");
class MarketSourcesService {
constructor(options) {
this.options = options;
this.manager = options.manager;
this.registry = new PluginSourceRegistry(options.database);
this.catalogs = new Map();
}
#catalog(sourceId = "official") {
const source = this.registry.get(sourceId);
let catalog = this.catalogs.get(source.id);
if (!catalog) {
catalog = new RegistryCatalogService({ ...this.options, source,
fetch: source.id === "official" ? this.options.fetch : (this.options.createSourceFetch ?? createSourceFetch)(source.origin),
sourceGuard: () => this.registry.get(source.id),
});
this.catalogs.set(source.id, catalog);
}
return catalog;
}
list(sourceId) { return this.#catalog(sourceId).list(); }
readme(id, token, sourceId) { return this.#catalog(sourceId).readme(id, token); }
install(id, token, sourceId = "official", acceptedSourceId) {
if (sourceId !== "official" && acceptedSourceId !== sourceId) throw new Error("Review and confirm this third-party source before installing");
return this.#catalog(sourceId).install(id, token);
}
listSources() { return this.registry.list(); }
addSource(payload) { return this.manager.changeMarketSource(() => this.registry.add(payload?.name, payload?.address)); }
updateSource(payload) { return this.manager.changeMarketSource(() => this.registry.update(payload?.id, payload?.name, payload?.enabled)); }
async removeSource(payload) {
if (typeof payload?.disablePlugins !== "boolean") throw new Error("Choose whether to disable installed plugins");
await this.manager.changeMarketSource(() => {
this.registry.remove(payload.id);
this.catalogs.delete(payload.id);
}, payload.disablePlugins ? payload.id : undefined);
}
}
module.exports = { MarketSourcesService };
@@ -0,0 +1,40 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { PluginDatabase } = require("./database.cjs");
const { PluginManager } = require("./pluginManager.cjs");
const { MarketSourcesService } = require("./marketSourcesService.cjs");
const { realFiles } = require("./fixtures/staticDistributionFixture.cjs");
test("source removal retains installed plugins, optional disabling is serialized, and no source silently takes over", async (t) => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "market-sources-"));
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
const database = new PluginDatabase(path.join(root, "db.sqlite"));
t.after(() => database.close());
const stopped = [];
const manager = new PluginManager({ database, packageStore: { async initialize() {} }, runtimeSupervisor: {
async startEnabled() {}, async stop(id) { stopped.push(id); },
} });
const requests = [];
const service = new MarketSourcesService({ database, manager, createSourceFetch: (origin) => async (url) => {
assert.equal(new URL(url).origin, origin); requests.push(url);
return new Response(realFiles().get(new URL(url).pathname));
}, fetch: async () => { assert.fail("third-party browsing must not fetch official upstream"); } });
const source = await service.addSource({ name: "Community", address: "https://community.example.com" });
const entry = (await service.list(source.id)).entries[0];
database.installVersion({ pluginId: entry.id, version: entry.version, manifest: entry.manifest, source, archiveSha256: "a".repeat(64), packageRelativePath: "test" }, { enable: true });
await assert.rejects(async () => service.install(entry.id, entry.selectionToken, source.id), /confirm/);
await service.updateSource({ id: source.id, name: "Renamed", enabled: false });
const count = requests.length;
assert.throws(() => service.list(source.id), /disabled/);
assert.equal(database.getActivePlugin(entry.id).enabled, true);
await service.removeSource({ id: source.id, disablePlugins: true });
assert.equal(database.getActivePlugin(entry.id).enabled, false);
assert.equal(database.getActivePlugin(entry.id).source.id, source.id);
assert.deepEqual(stopped, [entry.id]);
assert.throws(() => service.readme(entry.id, entry.selectionToken, source.id), /removed/);
assert.equal(requests.length, count);
});
+15
View File
@@ -1,5 +1,7 @@
"use strict";
const { LOCAL_SOURCE, assertSource } = require("./sourceIdentity.cjs");
const { createHash, randomUUID } = require("node:crypto");
const fs = require("node:fs");
const { constants } = fs;
@@ -127,6 +129,7 @@ function validateInstallMetadata(value) {
version,
archiveSha256: value.archiveSha256,
contentSha256: value.contentSha256,
source: assertSource(value.source ?? LOCAL_SOURCE),
};
}
@@ -226,6 +229,7 @@ class PackageStore {
const pluginId = assertPluginStorageSegment(manifest.id, "ID");
const version = assertPluginStorageSegment(manifest.version, "version");
const targetDirectory = resolveInstalledVersionDirectory(this.paths, pluginId, version);
const source = this.database.assertInstallSource(pluginId, options.source ?? LOCAL_SOURCE);
const previousPlugin = this.database.getActivePlugin(pluginId);
if (options.catalog) {
if (snapshot.sha256 !== options.catalog.artifact.sha256) throw new Error("Catalog package SHA-256 mismatch");
@@ -266,6 +270,7 @@ class PackageStore {
if (previousPlugin?.activeVersion !== version || options.enable === false) await prepareActivation("switch-active-version");
const existingPackage = path.join(targetDirectory, PACKAGE_DIRECTORY);
this.database.installVersion({
source,
pluginId,
version,
manifest: existingValidation.manifest,
@@ -282,6 +287,7 @@ class PackageStore {
if (!(error && error.code === "ENOENT")) throw error;
}
const metadata = {
source,
pluginId,
version,
archiveSha256: snapshot.sha256,
@@ -299,6 +305,7 @@ class PackageStore {
);
try {
this.database.installVersion({
source,
pluginId,
version,
manifest,
@@ -391,6 +398,7 @@ class PackageStore {
if (metadata.pluginId !== pluginEntry.name || metadata.version !== versionEntry.name) {
throw new Error("Plugin install metadata does not match its directory");
}
this.database.assertInstallSource(metadata.pluginId, metadata.source);
if (committedVersion && committedVersion.archiveSha256 !== metadata.archiveSha256) {
throw new Error("Plugin install archive hash does not match its database record");
}
@@ -406,6 +414,7 @@ class PackageStore {
if (!committedVersion) {
const packageDirectory = path.join(versionDirectory, PACKAGE_DIRECTORY);
this.database.installVersion({
source: metadata.source,
pluginId: metadata.pluginId,
version: metadata.version,
manifest: validation.manifest,
@@ -579,6 +588,12 @@ class PackageStore {
}
}
async clearUninstalledPrivateFiles(pluginId) {
assertPluginStorageSegment(pluginId, "ID");
if (this.database.getActivePlugin(pluginId)) throw new Error("Plugin is still installed");
await rm(path.join(this.paths.data, pluginId), { recursive: true, force: true });
}
async uninstall(pluginId) {
const plugin = this.database.getActivePlugin(pluginId);
if (!plugin) return false;
+1
View File
@@ -23,6 +23,7 @@ test("packaged application declares every plugin host runtime dependency and res
assert.match(builderSource, /electron\/plugins\/runtime\/\*\*\/\*/);
assert.match(builderSource, /domain\/pluginRuntimePolicy\.cjs/);
assert.match(builderSource, /domain\/pluginPermissionDescriptions\.cjs/);
assert.match(builderSource, /domain\/pluginThemePack\.cjs/);
assert.match(builderSource, /!electron\/plugins\/fixtures\/\*\*\/\*/);
});
+3 -1
View File
@@ -201,7 +201,7 @@ class PluginPermissionEngine {
}
#grantKey(pluginId, permission, declarationHash, resource) {
return `${pluginId}\0${permission}\0${declarationHash}\0${resource}`;
return `${this.database.privateOwner?.(pluginId) ?? pluginId}\0${permission}\0${declarationHash}\0${resource}`;
}
#sessionGrantKey(sessionId, grantKey) {
@@ -219,6 +219,7 @@ class PluginPermissionEngine {
) {
return Object.freeze({
pluginId: context.pluginId,
sourceOwner: this.database.privateOwner?.(context.pluginId) ?? context.pluginId,
permission,
declarationHash,
resource,
@@ -239,6 +240,7 @@ class PluginPermissionEngine {
sessionId,
) {
return grant.pluginId === context.pluginId
&& grant.sourceOwner === (this.database.privateOwner?.(context.pluginId) ?? context.pluginId)
&& grant.permission === permission
&& grant.declarationHash === declarationHash
&& (grant.scope !== "session" || grant.sessionId === sessionId)
+35 -3
View File
@@ -5,6 +5,7 @@ const { randomUUID } = require("node:crypto");
const { createDefinitionValidator } = require("./contractValidator.cjs");
const validateManagedPluginId = createDefinitionValidator("PluginId");
const validateViewFocus = createDefinitionValidator("PluginViewFocusRequest");
const { raceWithAbort } = require("./rpcRouter.cjs");
const {
@@ -15,12 +16,21 @@ const {
const { assertSecretKey } = require("./secretStore.cjs");
const CHANNELS = Object.freeze({
themeCatalog: "netcatty:plugins:theme-catalog",
themeApply: "netcatty:plugins:theme-apply",
themesChanged: "netcatty:plugins:themes-changed",
status: "netcatty:plugins:status",
interactionReady: "netcatty:plugins:interaction-ready",
interactionResponse: "netcatty:plugins:interaction-response",
list: "netcatty:plugins:list",
permissions: "netcatty:plugins:permissions",
revokePermissions: "netcatty:plugins:revoke-permissions",
resetSource: "netcatty:plugins:reset-source",
sourceBinding: "netcatty:plugins:source-binding",
sources: "netcatty:plugins:sources",
addSource: "netcatty:plugins:add-source",
updateSource: "netcatty:plugins:update-source",
removeSource: "netcatty:plugins:remove-source",
catalog: "netcatty:plugins:catalog",
catalogReadme: "netcatty:plugins:catalog-readme",
catalogInstall: "netcatty:plugins:catalog-install",
@@ -75,6 +85,7 @@ const CHANNELS = Object.freeze({
importerProgress: "netcatty:plugins:importer-progress",
openView: "netcatty:plugins:open-view",
closeView: "netcatty:plugins:close-view",
focusView: "netcatty:plugins:focus-view",
setViewBounds: "netcatty:plugins:set-view-bounds",
setViewVisibility: "netcatty:plugins:set-view-visibility",
viewMessage: "netcatty:plugins:view-message",
@@ -654,14 +665,18 @@ function registerPluginBridge(ipcMain, options) {
? { credentialStorageAvailable: credentialResolver.isAvailable() } : {}),
};
});
handle(CHANNELS.catalog, async () => options.catalogService?.list() ?? { entries: [], source: "builtin", error: "Plugin catalog unavailable" });
handle(CHANNELS.sources, async () => options.catalogService?.listSources() ?? []);
handle(CHANNELS.addSource, async (_manager, payload) => options.catalogService.addSource(payload));
handle(CHANNELS.updateSource, async (_manager, payload) => options.catalogService.updateSource(payload));
handle(CHANNELS.removeSource, async (_manager, payload) => options.catalogService.removeSource(payload));
handle(CHANNELS.catalog, async (_manager, payload) => options.catalogService?.list(payload?.sourceId) ?? { entries: [], source: "builtin", error: "Plugin catalog unavailable" });
handle(CHANNELS.catalogReadme, async (_manager, payload) => {
if (!options.catalogService) throw new Error("Plugin catalog unavailable");
return options.catalogService.readme(payload?.id, payload?.selectionToken);
return options.catalogService.readme(payload?.id, payload?.selectionToken, payload?.sourceId);
});
handle(CHANNELS.catalogInstall, async (_manager, payload) => {
if (!options.catalogService) throw new Error("Plugin catalog unavailable");
return options.catalogService.install(payload?.id, payload?.selectionToken);
return options.catalogService.install(payload?.id, payload?.selectionToken, payload?.sourceId, payload?.acceptedSourceId);
});
handle(CHANNELS.interactionReady, async (_manager, payload, event) => {
if (!options.interactionService) throw new Error("Host interactions are unavailable");
@@ -672,6 +687,8 @@ function registerPluginBridge(ipcMain, options) {
return options.interactionService.respond(event.sender, payload);
});
handle(CHANNELS.list, async (activeManager) => activeManager.list());
handle(CHANNELS.sourceBinding, async (activeManager, payload) => activeManager.database.getRetainedPluginSource(assertManagedPluginId(payload)));
handle(CHANNELS.resetSource, async (activeManager, payload) => activeManager.resetUninstalledSource(assertManagedPluginId(payload), payload?.expectedSourceId));
const assertManagedPluginId = (payload) => {
const id = payload?.pluginId;
if (!validateManagedPluginId(id)) {
@@ -721,6 +738,15 @@ function registerPluginBridge(ipcMain, options) {
));
handle(CHANNELS.restart, async (activeManager, payload) => activeManager.restart(payload?.pluginId));
handle(CHANNELS.uninstall, async (activeManager, payload) => activeManager.uninstall(payload?.pluginId));
handle(CHANNELS.themeCatalog, async () => {
if (!options.themeService) throw new Error("Plugin themes are unavailable");
return options.themeService.catalog();
});
handle(CHANNELS.themeApply, async (_manager, request) => {
if (!options.themeService) throw new Error("Plugin themes are unavailable");
return options.themeService.apply(request);
});
options.themeService?.onDidChange?.(() => options.broadcast?.(CHANNELS.themesChanged, {}));
handle(CHANNELS.contributions, async (_activeManager, payload) => {
if (!contributionService) throw new Error("Plugin contributions are unavailable");
return contributionService.snapshot(payload ?? {});
@@ -1605,6 +1631,12 @@ function registerPluginBridge(ipcMain, options) {
await viewHost.close(payload?.instanceId, event.sender);
return null;
});
handle(CHANNELS.focusView, async (_activeManager, payload, event) => {
if (!viewHost) throw new Error("Plugin views are unavailable");
if (!validateViewFocus(payload)) throw new TypeError("Plugin view focus request is invalid");
viewHost.focus(payload.instanceId, event.sender);
return null;
});
handle(CHANNELS.setViewBounds, async (_activeManager, payload, event) => {
if (!viewHost) throw new Error("Plugin views are unavailable");
viewHost.setBounds(payload?.instanceId, payload?.bounds, event.sender);
+7
View File
@@ -1714,3 +1714,10 @@ test("host interaction ready and response channels reject untrusted senders befo
await ipcMain.handlers.get(CHANNELS.interactionResponse)({ trusted: true, sender }, response);
assert.deepEqual(calls, [[sender, true], [sender, response]]);
});
test('theme catalog and selection remain trusted app management channels',async()=>{
const ipc=createIpcMain();let calls=0;
registerPluginBridge(ipc,{manager:{initialize:async()=>{}},isTrustedSender:event=>event?.trusted===true,themeService:{catalog:async()=>{calls++;return{};},apply:async()=>{calls++;return{};}}});
for(const channel of [CHANNELS.themeCatalog,CHANNELS.themeApply])await assert.rejects(ipc.handlers.get(channel)({trusted:false},{}),/Untrusted/);
assert.equal(calls,0);await ipc.handlers.get(CHANNELS.themeCatalog)({trusted:true});await ipc.handlers.get(CHANNELS.themeApply)({trusted:true},{});assert.equal(calls,2);
});
+35
View File
@@ -12,6 +12,7 @@ class PluginManager {
};
this.beforeClose = options.beforeClose ?? null;
this.permissionEngine = options.permissionEngine ?? null;
this.secretStore = options.secretStore ?? null;
this.initialized = false;
this.initializePromise = null;
this.mutationTail = Promise.resolve();
@@ -46,16 +47,37 @@ class PluginManager {
return this.database.listPlugins();
}
changeMarketSource(operation, disableSourceId) {
return this.#mutate(async () => {
await this.#ready();
const result = operation();
if (disableSourceId) {
const errors = [];
for (const plugin of this.database.listPlugins()) {
if (plugin.source?.id !== disableSourceId) continue;
try { this.database.setEnabled(plugin.id, false); } catch (error) { errors.push(error); }
try { this.contributionService.onPluginDisabled(plugin.id); } catch (error) { errors.push(error); }
try { await this.runtimeSupervisor.stop(plugin.id); } catch (error) { errors.push(error); }
}
if (errors.length) throw new AggregateError(errors, "Source removed, but some plugin shutdown steps failed");
}
return result;
});
}
install(archivePath, options) {
return this.#mutate(async () => {
await this.#ready();
options?.sourceGuard?.();
let stoppedPlugin = null;
let plugin;
try {
plugin = await this.packageStore.install(archivePath, {
enable: options?.enable,
catalog: options?.catalog,
source: options?.source,
beforeActivate: async ({ pluginId, previousPlugin }) => {
options?.sourceGuard?.();
if (!previousPlugin?.enabled) return;
this.database.setEnabled(pluginId, false);
this.contributionService.onPluginDisabled(pluginId);
@@ -157,6 +179,19 @@ class PluginManager {
});
}
resetUninstalledSource(pluginId, expectedSourceId) {
return this.#mutate(async () => {
await this.#ready();
if (this.database.getActivePlugin(pluginId)) throw new Error("Uninstall the plugin before changing source");
if (this.database.getPluginSource(pluginId).id !== expectedSourceId) throw new Error("Plugin source changed; review the reset again");
await this.runtimeSupervisor.stop(pluginId);
this.permissionEngine?.revokeAll(pluginId);
this.secretStore?.clearPluginMemory(pluginId);
await this.packageStore.clearUninstalledPrivateFiles(pluginId);
this.database.clearUninstalledSourceData(pluginId, expectedSourceId);
});
}
uninstall(pluginId) {
return this.#mutate(async () => {
await this.#ready();
+22 -8
View File
@@ -35,11 +35,16 @@ class PluginSyncSidecarService {
sync: setting.sync === true,
scope: setting.scope,
}));
map.set(plugin.id, fields);
map.set(this.database.privateOwner(plugin.id), fields);
}
return map;
}
#isCurrentOwner(owner, publicId) {
return publicId !== null && this.database.publicPluginId(owner) === publicId
&& this.#declaredSettingsByPlugin().has(owner);
}
/**
* Build the sidecar bundle for inclusion in the encrypted cloud payload.
*/
@@ -170,12 +175,14 @@ class PluginSyncSidecarService {
}
}
for (const entry of resetCandidates) {
const publicId = this.database.publicPluginId(entry.pluginId);
if (!this.#isCurrentOwner(entry.pluginId, publicId)) continue;
const parsed = parseSettingsSidecarKey(entry.key);
if (!parsed) continue;
if (typeof this.contributionService?.resetSetting === "function") {
try {
await this.contributionService.resetSetting(
entry.pluginId,
publicId,
parsed.settingId,
parsed.scopeId,
);
@@ -183,6 +190,7 @@ class PluginSyncSidecarService {
// Validation rejected the reset (e.g. required setting) — still try
// the stored-coordinate delete below for stale-scope rows.
}
if (!this.#isCurrentOwner(entry.pluginId, publicId)) continue;
// resetSetting normalizes against the *current* declared scope. After a
// scope migration (application → device), that may miss the stale row
// still stored under the old coordinates; always delete those too.
@@ -201,6 +209,8 @@ class PluginSyncSidecarService {
this.database.deleteSetting(entry.pluginId, parsed.settingId, parsed.scope, parsed.scopeId);
}
for (const entry of safe) {
const publicId = this.database.publicPluginId(entry.pluginId);
if (!this.#isCurrentOwner(entry.pluginId, publicId)) continue;
if (entry.kind !== "settings") continue;
const fields = declared.get(entry.pluginId);
if (!fields) continue;
@@ -218,12 +228,13 @@ class PluginSyncSidecarService {
if (typeof this.contributionService?.updateSetting === "function") {
try {
await this.contributionService.updateSetting(
entry.pluginId,
publicId,
parsed.settingId,
entry.value,
targetScopeId,
{ source: "host" },
);
if (!this.#isCurrentOwner(entry.pluginId, publicId)) continue;
// Preserve remote LWW timestamp after validated write (updateSetting uses clock).
this.database.setSetting(
entry.pluginId,
@@ -312,12 +323,14 @@ class PluginSyncSidecarService {
*/
async hydrateInstalledPluginSettings(pluginId) {
if (typeof pluginId !== "string" || pluginId.length < 1) return;
const declared = this.#declaredSettingsByPlugin().get(pluginId);
const owner = this.database.privateOwner(pluginId);
const declared = this.#declaredSettingsByPlugin().get(owner);
if (!declared) return;
const rows = typeof this.database.listSyncSidecars === "function"
? this.database.listSyncSidecars(pluginId)
: [];
for (const entry of rows) {
if (!this.#isCurrentOwner(owner, pluginId)) return;
if (entry.kind !== "settings") continue;
const parsed = parseSettingsSidecarKey(entry.key);
if (!parsed) continue;
@@ -347,8 +360,9 @@ class PluginSyncSidecarService {
targetScopeId,
{ source: "host" },
);
if (!this.#isCurrentOwner(owner, pluginId)) return;
this.database.setSetting(
pluginId,
owner,
parsed.settingId,
targetScope,
targetScopeId,
@@ -358,7 +372,7 @@ class PluginSyncSidecarService {
if (targetScope !== parsed.scope || targetScopeId !== parsed.scopeId) {
try {
this.database.deleteSetting(
pluginId,
owner,
parsed.settingId,
parsed.scope,
parsed.scopeId,
@@ -369,14 +383,14 @@ class PluginSyncSidecarService {
if (typeof this.database.setSyncSidecar === "function") {
try {
this.database.setSyncSidecar(
pluginId,
owner,
"settings",
nextKey,
entry.value,
entry.updatedAt,
);
if (nextKey !== entry.key && typeof this.database.deleteSyncSidecar === "function") {
this.database.deleteSyncSidecar(pluginId, "settings", entry.key);
this.database.deleteSyncSidecar(owner, "settings", entry.key);
}
} catch {
// Sidecar re-key is best-effort after settings write.
@@ -33,7 +33,7 @@ test("sidecar table has no package cascade and survives removePlugin", (context)
database.removePlugin("com.example.sync");
assert.deepEqual(database.getSyncSidecar("com.example.sync", "account_baseline", "account"), {
pluginId: "com.example.sync",
pluginId: database.privateOwner("com.example.sync"),
kind: "account_baseline",
key: "account",
value: { id: "acct" },
@@ -68,7 +68,7 @@ test("collectForSync excludes secrets and preserves missing-plugin baselines", (
const service = new PluginSyncSidecarService({ database, contributionService });
const bundle = service.collectForSync();
assert.equal(bundle.version, 1);
assert.ok(bundle.entries.some((entry) => entry.kind === "crdt_baseline" && entry.pluginId === "com.missing.plugin"));
assert.ok(bundle.entries.some((entry) => entry.kind === "crdt_baseline" && entry.pluginId === database.privateOwner("com.missing.plugin")));
assert.ok(bundle.entries.some((entry) => entry.kind === "settings" && entry.value === "dark"));
assert.equal(bundle.entries.some((entry) => entry.value === "secret"), false);
// Collected settings must land in the non-cascade table for post-uninstall collect.
@@ -187,7 +187,7 @@ test("collectForSync omits deleted settings for installed plugins but keeps miss
const bundle = service.collectForSync();
assert.equal(bundle.entries.some((e) => e.value === "stale"), false);
assert.ok(bundle.entries.some((e) => e.value === "ok"));
assert.ok(bundle.entries.some((e) => e.pluginId === "com.missing.plugin" && e.value === "keep"));
assert.ok(bundle.entries.some((e) => e.pluginId === database.privateOwner("com.missing.plugin") && e.value === "keep"));
});
test("applyFromSync preserves remote updatedAt when writing settings", async (context) => {
@@ -210,7 +210,7 @@ test("applyFromSync preserves remote updatedAt when writing settings", async (co
await service.applyFromSync({
version: 1,
entries: [{
pluginId: "com.example.sync",
pluginId: database.privateOwner("com.example.sync"),
kind: "settings",
key: "com.example.sync.theme\0application\0application",
value: "dark",
@@ -234,7 +234,7 @@ test("applyFromSync does not drop local baselines for plugins absent remotely",
await service.applyFromSync({
version: 1,
entries: [{
pluginId: "com.remote.plugin",
pluginId: database.privateOwner("com.remote.plugin"),
kind: "settings",
key: "com.remote.plugin.theme\0application\0application",
value: "light",
@@ -243,8 +243,8 @@ test("applyFromSync does not drop local baselines for plugins absent remotely",
});
const all = database.listAllSyncSidecars();
assert.ok(all.some((entry) => entry.pluginId === "com.local.only"));
assert.ok(all.some((entry) => entry.pluginId === "com.remote.plugin" && entry.value === "light"));
assert.ok(all.some((entry) => entry.pluginId === database.privateOwner("com.local.only")));
assert.ok(all.some((entry) => entry.pluginId === database.privateOwner("com.remote.plugin") && entry.value === "light"));
});
test("hydrateInstalledPluginSettings applies retained sidecars through contribution service", async (context) => {
@@ -449,7 +449,7 @@ test("applyFromSync does not resurrect missing-plugin keys deleted on another de
await service.applyFromSync({
version: 1,
entries: [{
pluginId: "com.missing.plugin",
pluginId: database.privateOwner("com.missing.plugin"),
kind: "settings",
key: "com.missing.plugin.kept\0application\0application",
value: "remote-new",
@@ -498,7 +498,7 @@ test("applyFromSync keeps newer local baseline over older remote (LWW)", async (
await service.applyFromSync({
version: 1,
entries: [{
pluginId: "com.example.plugin",
pluginId: database.privateOwner("com.example.plugin"),
kind: "crdt_baseline",
key: "crdt",
value: { clock: 50 },
Loaded 100 of 161 files, more files were not shown because too many files have changed in this diff. Show more