Compare commits

...
Author SHA1 Message Date
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
Codex 2838f90b03 refactor(plugins): unify information and notice entry 2026-10-26 07:54:33 +00:00
Codex 494470810a refactor(plugins): compact notices and align card headings 2026-10-26 07:26:38 +00:00
Codex bf91894ab5 fix(plugins): keep replacement impact beside install action 2026-10-26 07:17:06 +00:00
Codex b39f7a3244 feat(plugins): simplify manager guidance and use full-page details 2026-10-26 07:05:38 +00:00
Codex e5bd294cdd fix(plugins): validate and prepare registry snapshots before publication 2026-10-26 06:32:35 +00:00
Codex b48567d448 Merge branch 'feat/plugin-brand-icons' into feat/plugin-registry-client
# Conflicts:
#	application/i18n/locales/en/core.ts
#	application/i18n/locales/es/core.ts
#	application/i18n/locales/ru/core.ts
#	application/i18n/locales/zh-CN/core.ts
#	application/i18n/locales/zh-TW/core.ts
2026-10-26 06:27:10 +00:00
Codex 933bc93fc3 feat(plugins): enrich plugin cards with author and descriptions 2026-10-26 06:26:36 +00:00
Codex 45ed193579 feat(plugins): expose manual community catalog refresh 2026-10-26 06:19:26 +00:00
Codex f471a17a75 Merge branch 'feat/plugin-brand-icons' into feat/plugin-registry-client 2026-10-26 06:17:13 +00:00
Codex b9293a7bb5 fix(plugins): keep sparse installed grids compact 2026-10-26 06:16:55 +00:00
Codex 257660bcc1 fix(plugins): keep normalized README URLs pinned to reviewed commits 2026-10-26 06:15:49 +00:00
Codex eaf8c9a385 test(plugins): include registry source in catalog fixture 2026-10-26 06:10:18 +00:00
Codex ff42d0c1b9 Merge branch 'feat/plugin-brand-icons' into feat/plugin-registry-client 2026-10-26 06:09:32 +00:00
Codex 0adb7b822e feat(plugins): show installed plugins in a compact grid 2026-10-26 06:09:20 +00:00
Codex 311765eaa1 feat(plugins): read validated official registry snapshots 2026-10-26 06:06:26 +00:00
Codex d2a8398b7a fix(plugins): parent permission prompts to focused settings window 2026-10-26 06:01:13 +00:00
Codex 69307cee16 fix(plugins): validate settings fixture activation and polish manager controls 2026-10-26 05:49:24 +00:00
Codex 4445c292ea fix(plugins): bound icon archive reads and reject prototype glyph lookups 2026-10-26 05:45:53 +00:00
Codex 6fad272afe style(plugins): use theme-accent status pills for enable controls 2026-10-26 05:35:45 +00:00
Codex a7f8480da1 test(plugins): check packaging against the current workspace revision 2026-10-26 05:35:45 +00:00
Codex e7e57f2a52 fix(plugins): align root workspace dependencies with API revision 2026-10-26 05:31:23 +00:00
Codex ab2f95837e test(plugins): add static settings view handoff fixture 2026-10-26 05:29:34 +00:00
Codex f3d04a1912 chore(plugins): pin reviewed Starter brand preview to source snapshot 2026-10-26 05:27:55 +00:00
Codex f4a6b205ad feat(plugins): add versioned offline brand icons and author guidance 2026-10-26 05:26:25 +00:00
Codex 614b7c997a fix(plugins): release manager modal before opening settings views 2026-10-26 05:21:00 +00:00
Codex aaef0d2ee1 feat(plugins): open management in a dedicated dialog 2026-10-26 05:13:37 +00:00
Codex 5128b51702 fix(plugins): soften selection colors and label historical errors 2026-10-26 04:13:20 +00:00
Codex f5c951f9a1 fix(plugins): restore narrow community browsing scroll position 2026-10-26 04:04:49 +00:00
Codex 8de33d9b5a feat(plugins): integrate responsive community list and detail layout 2026-10-26 04:02:33 +00:00
Codex c727cca643 fix(plugins): use managed catalog temp storage and lint browser example 2026-10-26 03:49:32 +00:00
Codex ba4fab5679 test(plugins): account for Starter runtime fixture build 2026-10-26 03:42:27 +00:00
Codex 27e5a10bfc chore(plugins): pin Starter panel example source 2026-10-26 03:41:29 +00:00
Codex 3efabf2b0f fix(plugins): expose a visible Starter panel and bundle shared browser helpers 2026-10-26 03:41:21 +00:00
Codex 74a2f99b08 fix(plugins): preserve sync sidecar coordinates on Node 22 2026-10-26 03:35:17 +00:00
Codex 80525d9b2d test(plugins): enforce shared policy and permission UI coverage in CI 2026-10-26 03:27:55 +00:00
Codex 741ea4e9c7 feat(plugins): pin the minimal starter and updated example documentation 2026-10-26 03:25:56 +00:00
Codex 4336f8fd53 feat(plugins): open browser-only settings with AI-style subpages and safe starter 2026-10-26 03:25:40 +00:00
Codex aa8989c515 Merge branch 'feat/plugin-settings-manager' into feat/plugin-curated-catalog 2026-10-26 03:15:25 +00:00
Codex d5fd56ecdf feat(plugins): add curated browser-only catalog and safe README details 2026-10-26 03:10:48 +00:00
129 changed files with 4580 additions and 171 deletions

No files matched your search

+6
View File
@@ -49,6 +49,12 @@ jobs:
- name: Verify Codex App Server schema
run: npm run check:codex-app-server-schema
- name: Verify plugin contract and runtime boundaries
run: |
npm run check:plugin-contract
npm run test:plugin-runtime
node --test --import tsx components/settings/tabs/Plugin*.test.tsx
- name: Test
run: npm test
+6 -3
View File
@@ -106,10 +106,13 @@ Placement rules (`resolveAgentKinds` in `toolSurfaces.cjs`):
**Harness domain (`catalog/harness.cjs`):** Catty executes these locally in `capabilityTools.executeLocalCattyCapability`. The read-only `harness.terminal.read_context` also exposes `netcatty/readContext` to MCP, with scope validation before and after the existing renderer bridge call. It reuses the sidebar reader through the screen snapshot registry (80 rows by default, maximum 300); no script or command runs. Other harness tools remain Catty-only. `harness.web.search` is omitted when web search is not configured.
## Plugin host runtime (internal preview)
## Plugin host runtime
The phase-2 plugin host lives under `electron/plugins/` and is disabled unless
`NETCATTY_PLUGIN_DEV=1` is present at application launch. Public wire and package
The plugin host under `electron/plugins/` exposes browser-only packages in normal
Settings. `NETCATTY_PLUGIN_DEV=1` admits advanced development packages, without
granting their permissions. Admission and default placement share
`domain/pluginRuntimePolicy.cjs`; permission explanations shared with native
prompts live in `domain/pluginPermissionDescriptions.cjs`. Public wire and package
types still come only from `packages/plugin-contract/schema/`; do not add a
second private RPC shape when extending the host.
+36 -4
View File
@@ -1237,7 +1237,7 @@ Highlight the focused split pane:
'settings.tab.plugins': 'Plugins',
"settings.plugins.manager.installed": "Installed plugins",
"settings.plugins.manager.import": "Import .ncpkg",
"settings.plugins.manager.preview": "Developer preview. Imported packages remain disabled until you review and enable them.",
"settings.plugins.manager.preview": "Imported packages stay disabled until you review and enable them.",
"settings.plugins.manager.empty": "Import a local plugin package to get started.",
"settings.plugins.manager.enabled": "Enabled",
"settings.plugins.manager.disabled": "Disabled",
@@ -1246,15 +1246,47 @@ 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.manager.permissions": "Declared permissions",
"settings.plugins.manager.permissionNote": "Read from the installed manifest. Declarations are not grants; sensitive operations still require host approval.",
"settings.plugins.manager.permissionNote": "From the manifest, not a grant. Enabled allows lazy activation; required permissions are requested before first activation and optional permissions when used. Sensitive operations still pass host checks.",
"settings.plugins.manager.noPermissions": "No privileged permissions declared.",
"settings.plugins.manager.required": "Required",
"settings.plugins.manager.optional": "Optional",
"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.title': 'Plugin settings',
'settings.plugins.description': 'Native, validated settings contributed by development plugins.',
"settings.plugins.market.results": "{count} plugins",
"settings.plugins.manager.lastError": "Last recorded error",
"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.",
"settings.plugins.status": "Notices: {count}",
"settings.plugins.info": "Plugin information",
"settings.plugins.manager.noVaultShort": "Secret storage unavailable",
"settings.plugins.market.offlineShort": "Offline catalog · installation unavailable",
"settings.plugins.market.readmeInfo": "About this README",
"settings.plugins.market.installed": "Installed",
"settings.plugins.market.sourceBadge": "Source example",
"settings.plugins.market.title": "Marketplace",
"settings.plugins.market.note": "Curated open-source plugins. Review is not a security certification. Updates are manual.",
"settings.plugins.market.back": "All plugins",
"settings.plugins.market.install": "Install this version (disabled)",
"settings.plugins.market.installNote": "This replaces and stops the installed version. Review its permissions in Installed plugins before enabling.",
"settings.plugins.market.sourceOnly": "First-party source example. Build with the local CLI and import the .ncpkg; no downloadable release has been published.",
"settings.plugins.market.readmeNote": "Pinned to the commit above. HTML is inert and remote images are not loaded. Written by the plugin author.",
"settings.plugins.manager.noVault": "System key storage is unavailable. Secret and credential operations are blocked; browsing and non-secret plugins still work.",
"settings.plugins.manager.sensitive": "Sensitive access: review the data and resource scope before granting.",
"settings.plugins.manager.dual": "Browser is the default runtime. This package also includes a Node entrypoint with full device authority; developer mode only.",
"settings.plugins.manager.advancedDeclaration": "Browser entrypoint with advanced permission declarations. Not eligible for normal browser-only mode; review every declared permission.",
"settings.plugins.market.retry": "Retry",
"settings.plugins.market.empty": "No catalog entries are available. You can still import a local browser plugin.",
"settings.plugins.market.emptyReadme": "This README is empty.",
"settings.plugins.market.search": "Search name, author or description",
"settings.plugins.market.noResults": "No matching plugins. Try another search.",
"settings.plugins.market.manifest": "Compatibility and permissions",
"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.title": "Plugins",
"settings.plugins.description": "Manage your plugins and explore the catalog.",
'settings.plugins.loading': 'Loading plugin settings…',
'settings.plugins.empty': 'No enabled plugin contributes settings.',
'settings.plugins.scopeContext': 'Open this setting in its {scope} context.',
+36 -4
View File
@@ -1237,7 +1237,7 @@ Resalta el panel dividido enfocado:
'settings.tab.plugins': 'Plugins',
"settings.plugins.manager.installed": "Plugins instalados",
"settings.plugins.manager.import": "Importar .ncpkg",
"settings.plugins.manager.preview": "Vista previa para desarrolladores. Los paquetes importados permanecen desactivados hasta que los revises y actives.",
"settings.plugins.manager.preview": "Los paquetes importados permanecen desactivados hasta que los revises y actives.",
"settings.plugins.manager.empty": "Importa un paquete local para empezar.",
"settings.plugins.manager.enabled": "Activado",
"settings.plugins.manager.disabled": "Desactivado",
@@ -1246,15 +1246,47 @@ 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.manager.permissions": "Permisos declarados",
"settings.plugins.manager.permissionNote": "Obtenidos del manifiesto instalado. Las declaraciones no son autorizaciones; las operaciones sensibles requieren aprobación del anfitrión.",
"settings.plugins.manager.permissionNote": "Del manifiesto, no es una autorización. Activado permite la activación diferida; los permisos obligatorios se solicitan antes del primer uso y los opcionales cuando se necesitan. Las operaciones sensibles siguen verificándose.",
"settings.plugins.manager.noPermissions": "No se han declarado permisos privilegiados.",
"settings.plugins.manager.required": "Obligatorio",
"settings.plugins.manager.optional": "Opcional",
"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.title': 'Configuración de plugins',
'settings.plugins.description': 'Ajustes nativos y validados que aportan los plugins de desarrollo.',
"settings.plugins.market.results": "{count} plugins",
"settings.plugins.manager.lastError": "Último error registrado",
"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.",
"settings.plugins.status": "{count} avisos",
"settings.plugins.info": "Información de plugins",
"settings.plugins.manager.noVaultShort": "Almacenamiento de secretos no disponible",
"settings.plugins.market.offlineShort": "Catálogo sin conexión · instalación no disponible",
"settings.plugins.market.readmeInfo": "Acerca de este README",
"settings.plugins.market.installed": "Instalado",
"settings.plugins.market.sourceBadge": "Ejemplo de código",
"settings.plugins.market.title": "Catálogo",
"settings.plugins.market.note": "Plugins de código abierto seleccionados. La revisión no certifica su seguridad. Las actualizaciones son manuales.",
"settings.plugins.market.back": "Todos los plugins",
"settings.plugins.market.install": "Instalar esta versión (desactivada)",
"settings.plugins.market.installNote": "Esto reemplaza y detiene la versión instalada. Revisa sus permisos antes de activarla.",
"settings.plugins.market.sourceOnly": "Ejemplo propio con código fuente. Compílalo con la CLI local e importa el .ncpkg; no hay versión publicada para descargar.",
"settings.plugins.market.readmeNote": "Fijado al commit indicado. El HTML no se ejecuta y las imágenes remotas no se cargan. Contenido del autor.",
"settings.plugins.manager.noVault": "El almacén de claves del sistema no está disponible. Se bloquean secretos y credenciales; el catálogo y los plugins que no los usan siguen disponibles.",
"settings.plugins.manager.sensitive": "Acceso sensible: revisa los datos y el alcance antes de autorizar.",
"settings.plugins.manager.dual": "El navegador es la opción predeterminada. El paquete también incluye una entrada Node con acceso completo al dispositivo; solo para desarrollo.",
"settings.plugins.manager.advancedDeclaration": "Entrada de navegador con permisos avanzados declarados. No se admite en el modo normal; revisa cada permiso.",
"settings.plugins.market.retry": "Reintentar",
"settings.plugins.market.empty": "No hay entradas en el catálogo. Puedes importar un plugin local de navegador.",
"settings.plugins.market.emptyReadme": "Este README está vacío.",
"settings.plugins.market.search": "Buscar nombre, autor o descripción",
"settings.plugins.market.noResults": "No hay plugins coincidentes. Prueba otra búsqueda.",
"settings.plugins.market.manifest": "Compatibilidad y permisos",
"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.title": "Plugins",
"settings.plugins.description": "Gestiona tus plugins y explora el catálogo.",
'settings.plugins.loading': 'Cargando ajustes del plugin…',
'settings.plugins.empty': 'Ningún plugin activado aporta ajustes.',
'settings.plugins.scopeContext': 'Abre este ajuste en su contexto de {scope}.',
+36 -4
View File
@@ -1132,7 +1132,7 @@ export const ruCoreMessages: Messages = {
'settings.tab.plugins': 'Плагины',
"settings.plugins.manager.installed": "Установленные плагины",
"settings.plugins.manager.import": "Импорт .ncpkg",
"settings.plugins.manager.preview": "Предварительная версия для разработчиков. Импортированные пакеты отключены до проверки и включения.",
"settings.plugins.manager.preview": "Импортированные пакеты отключены до проверки и включения.",
"settings.plugins.manager.empty": "Импортируйте локальный пакет плагина.",
"settings.plugins.manager.enabled": "Включён",
"settings.plugins.manager.disabled": "Отключён",
@@ -1141,15 +1141,47 @@ export const ruCoreMessages: Messages = {
"settings.plugins.manager.remove": "Удалить",
"settings.plugins.manager.removeNote": "Удаление очищает код пакета и локальные данные KV. Настройки пользователя, секреты и данные синхронизации сохраняются.",
"settings.plugins.manager.permissions": "Заявленные разрешения",
"settings.plugins.manager.permissionNote": "Из установленного манифеста. Объявление не является разрешением; чувствительные операции требуют одобрения приложения.",
"settings.plugins.manager.permissionNote": "Из манифеста, а не предоставленные права. Включение допускает отложенную активацию; обязательные разрешения запрашиваются перед ней, необязательные — при использовании. Чувствительные операции проверяются приложением.",
"settings.plugins.manager.noPermissions": "Привилегированные разрешения не заявлены.",
"settings.plugins.manager.required": "Обязательно",
"settings.plugins.manager.optional": "Необязательно",
"settings.plugins.manager.browser": "Браузерная среда: привилегированный доступ проходит через проверку разрешений приложения.",
"settings.plugins.manager.advanced": "Расширенная среда: полный доступ Node к файловой системе и сети. Разрешения посредника не ограничивают этот доступ. Только для разработки.",
"settings.plugins.manager.data": "Данные KV остаются на устройстве. Синхронизируются только явно отмеченные несекретные настройки в зашифрованных данных. Пакеты и разрешения не синхронизируются.",
'settings.plugins.title': 'Настройки плагинов',
'settings.plugins.description': 'Проверенные нативные настройки от плагинов разработки.',
"settings.plugins.market.results": "Плагинов: {count}",
"settings.plugins.manager.lastError": "Последняя записанная ошибка",
"settings.plugins.market.offline": "Реестр недоступен. Показан последний корректный каталог или встроенные примеры. Для установки необходимо успешно обновить каталог.",
"settings.plugins.manager.details": "Подробнее",
"settings.plugins.manager.noDescription": "Описание не предоставлено.",
"settings.plugins.status": "Уведомления: {count}",
"settings.plugins.info": "Информация о плагинах",
"settings.plugins.manager.noVaultShort": "Хранилище секретов недоступно",
"settings.plugins.market.offlineShort": "Каталог офлайн · установка недоступна",
"settings.plugins.market.readmeInfo": "Об этом README",
"settings.plugins.market.installed": "Установлен",
"settings.plugins.market.sourceBadge": "Пример исходного кода",
"settings.plugins.market.title": "Каталог",
"settings.plugins.market.note": "Отобранные плагины с открытым кодом. Проверка не гарантирует безопасность. Обновления выполняются вручную.",
"settings.plugins.market.back": "Все плагины",
"settings.plugins.market.install": "Установить эту версию (отключённой)",
"settings.plugins.market.installNote": "Установленная версия будет заменена и остановлена. Проверьте разрешения перед включением.",
"settings.plugins.market.sourceOnly": "Пример из этого проекта. Соберите локальной CLI и импортируйте .ncpkg; готовый пакет для скачивания не опубликован.",
"settings.plugins.market.readmeNote": "Зафиксирован указанный коммит. HTML не выполняется, внешние изображения не загружаются. Текст автора плагина.",
"settings.plugins.manager.noVault": "Системное хранилище ключей недоступно. Операции с секретами и учётными данными заблокированы; каталог и плагины без секретов доступны.",
"settings.plugins.manager.sensitive": "Чувствительный доступ: проверьте данные и область ресурсов перед разрешением.",
"settings.plugins.manager.dual": "По умолчанию используется браузерная среда. Пакет также содержит вход Node с полным доступом к устройству; только для разработчиков.",
"settings.plugins.manager.advancedDeclaration": "Браузерная точка входа с заявленными расширенными разрешениями. Не допускается в обычном режиме; проверьте все разрешения.",
"settings.plugins.market.retry": "Повторить",
"settings.plugins.market.empty": "Каталог пуст. Можно импортировать локальный браузерный плагин.",
"settings.plugins.market.emptyReadme": "Этот README пуст.",
"settings.plugins.market.search": "Поиск по имени, автору или описанию",
"settings.plugins.market.noResults": "Совпадений нет. Измените запрос.",
"settings.plugins.market.manifest": "Совместимость и разрешения",
"settings.plugins.manager.unbounded": "Более узкая область ресурсов не заявлена. Проверьте фактическую область в запросе разрешения.",
"settings.plugins.openView": "Открыть",
"settings.plugins.openManager": "Открыть управление плагинами",
"settings.plugins.title": "Плагины",
"settings.plugins.description": "Управляйте плагинами и просматривайте каталог.",
'settings.plugins.loading': 'Загрузка настроек плагинов…',
'settings.plugins.empty': 'Включённые плагины не предоставляют настройки.',
'settings.plugins.scopeContext': 'Откройте эту настройку в контексте {scope}.',
+36 -4
View File
@@ -1152,7 +1152,7 @@ Vaults 标签保持扁平,不使用这块背景。
'settings.tab.plugins': '插件',
"settings.plugins.manager.installed": "已安装插件",
"settings.plugins.manager.import": "导入 .ncpkg",
"settings.plugins.manager.preview": "开发者预览。导入的插件保持停用,请检查后再启用。",
"settings.plugins.manager.preview": "导入的插件保持停用,请检查后再启用。",
"settings.plugins.manager.empty": "导入本地插件包以开始使用。",
"settings.plugins.manager.enabled": "已启用",
"settings.plugins.manager.disabled": "已停用",
@@ -1161,15 +1161,47 @@ Vaults 标签保持扁平,不使用这块背景。
"settings.plugins.manager.remove": "卸载",
"settings.plugins.manager.removeNote": "卸载会移除插件代码和本地 KV 数据,保留用户设置、秘密存储和同步 sidecar。",
"settings.plugins.manager.permissions": "声明的权限",
"settings.plugins.manager.permissionNote": "来自已安装的 manifest。声明不等于授权,敏感操作仍须通过宿主批准。",
"settings.plugins.manager.permissionNote": "来自 manifest,不代表已授权。启用表示允许按需激活;必需权限在首次激活前申请,可选权限在用到时申请。敏感操作仍受宿主检查。",
"settings.plugins.manager.noPermissions": "未声明特权权限。",
"settings.plugins.manager.required": "必需",
"settings.plugins.manager.optional": "可选",
"settings.plugins.manager.browser": "浏览器运行时:特权访问通过宿主权限代理。",
"settings.plugins.manager.advanced": "高级运行时:拥有完整 Node 文件系统和网络能力,代理权限无法限制这些访问。仅供开发预览。",
"settings.plugins.manager.data": "本地 KV 仅保留在此设备。只有明确标记为同步的非秘密设置进入加密云 sidecar。插件包和权限授权不参与同步。",
'settings.plugins.title': '插件设置',
'settings.plugins.description': '由开发插件提供、经 Netcatty 原生组件验证的设置。',
"settings.plugins.market.results": "{count} 个插件",
"settings.plugins.manager.lastError": "上次记录的错误",
"settings.plugins.market.offline": "官方目录暂时不可用,正在显示上次有效目录或内置示例。刷新成功前不能从市场安装。",
"settings.plugins.manager.details": "查看详情",
"settings.plugins.manager.noDescription": "作者暂未提供功能简介。",
"settings.plugins.status": "{count} 项提醒",
"settings.plugins.info": "插件说明",
"settings.plugins.manager.noVaultShort": "凭据存储不可用",
"settings.plugins.market.offlineShort": "目录离线 · 暂不可安装",
"settings.plugins.market.readmeInfo": "README 来源说明",
"settings.plugins.market.installed": "已安装",
"settings.plugins.market.sourceBadge": "源码示例",
"settings.plugins.market.title": "插件市场",
"settings.plugins.market.note": "精选开源插件。审核不代表安全认证,更新由你手动确认。",
"settings.plugins.market.back": "所有插件",
"settings.plugins.market.install": "安装此版本(保持停用)",
"settings.plugins.market.installNote": "这会替换并停止已安装版本。请在已安装列表检查权限后再启用。",
"settings.plugins.market.sourceOnly": "第一方源码示例。请用本地 CLI 构建并导入 .ncpkg;尚未发布可下载发行包。",
"settings.plugins.market.readmeNote": "固定到上面的提交。HTML 不执行,远程图片不加载。内容由插件作者提供。",
"settings.plugins.manager.noVault": "系统密钥存储不可用。秘密和凭据操作被阻止;浏览市场和不使用秘密的插件仍可使用。",
"settings.plugins.manager.sensitive": "敏感访问:授权前请检查涉及的数据和资源范围。",
"settings.plugins.manager.dual": "默认使用浏览器运行时,但包中还包含拥有完整设备权限的 Node 入口;仅限开发者模式。",
"settings.plugins.manager.advancedDeclaration": "浏览器入口声明了高级权限。不适用于普通浏览器插件模式;请逐项检查声明权限。",
"settings.plugins.market.retry": "重试",
"settings.plugins.market.empty": "目录暂无插件,仍可导入本地浏览器插件。",
"settings.plugins.market.emptyReadme": "README 内容为空。",
"settings.plugins.market.search": "搜索名称、作者或描述",
"settings.plugins.market.noResults": "没有匹配插件,请尝试其他关键词。",
"settings.plugins.market.manifest": "兼容性与权限",
"settings.plugins.manager.unbounded": "未声明更细的资源范围。请在权限提示中检查实际授权范围。",
"settings.plugins.openView": "打开",
"settings.plugins.openManager": "打开插件管理",
"settings.plugins.title": "插件",
"settings.plugins.description": "管理已安装插件,浏览精选目录。",
'settings.plugins.loading': '正在加载插件设置…',
'settings.plugins.empty': '当前没有已启用插件提供设置。',
'settings.plugins.scopeContext': '请在对应的 {scope} 上下文中打开此设置。',
+36 -4
View File
@@ -1155,7 +1155,7 @@ Vaults 分頁維持扁平,不使用這塊背景。
'settings.tab.plugins': '外掛程式',
"settings.plugins.manager.installed": "已安裝插件",
"settings.plugins.manager.import": "導入 .ncpkg",
"settings.plugins.manager.preview": "開發者預覽。導入的插件保持停用,請檢查後再啟用。",
"settings.plugins.manager.preview": "導入的插件保持停用,請檢查後再啟用。",
"settings.plugins.manager.empty": "導入本地插件包以開始使用。",
"settings.plugins.manager.enabled": "已啟用",
"settings.plugins.manager.disabled": "已停用",
@@ -1164,16 +1164,48 @@ Vaults 分頁維持扁平,不使用這塊背景。
"settings.plugins.manager.remove": "卸載",
"settings.plugins.manager.removeNote": "卸載會移除插件代碼和本地 KV 數據,保留用戶設置、秘密存儲和同步 sidecar。",
"settings.plugins.manager.permissions": "聲明的權限",
"settings.plugins.manager.permissionNote": "來自已安裝的 manifest。聲明不等於授權,敏感操作仍須通過宿主批准。",
"settings.plugins.manager.permissionNote": "來自 manifest,不代表已授權。啟用表示允許按需激活;必需權限在首次激活前申請,可選權限在用到時申請。敏感操作仍受宿主檢查。",
"settings.plugins.manager.noPermissions": "未聲明特權權限。",
"settings.plugins.manager.required": "必需",
"settings.plugins.manager.optional": "可選",
"settings.plugins.manager.browser": "瀏覽器運行時:特權訪問通過宿主權限代理。",
"settings.plugins.manager.advanced": "高級運行時:擁有完整 Node 文件系統和網絡能力,代理權限無法限制這些訪問。僅供開發預覽。",
"settings.plugins.manager.data": "本地 KV 僅保留在此設備。只有明確標記為同步的非秘密設置進入加密雲 sidecar。插件包和權限授權不參與同步。",
'settings.plugins.title': '外掛程式設定',
'settings.plugins.description': '由開發外掛程式提供、經 Netcatty 原生元件驗證的設定。',
"settings.plugins.manager.unbounded": "未宣告更細的資源範圍。請在權限提示中檢查實際授權範圍。",
"settings.plugins.openView": "開啟",
"settings.plugins.openManager": "開啟插件管理",
"settings.plugins.title": "插件",
"settings.plugins.description": "管理已安裝插件,瀏覽精選目錄。",
'settings.plugins.loading': '正在載入外掛程式設定…',
"settings.plugins.market.results": "{count} 個插件",
"settings.plugins.manager.lastError": "上次記錄的錯誤",
"settings.plugins.market.offline": "官方目錄暫時無法使用,正在顯示上次有效目錄或內建範例。重新整理成功前無法從市場安裝。",
"settings.plugins.manager.details": "查看詳情",
"settings.plugins.manager.noDescription": "作者尚未提供功能簡介。",
"settings.plugins.status": "{count} 項提醒",
"settings.plugins.info": "外掛說明",
"settings.plugins.manager.noVaultShort": "憑據儲存無法使用",
"settings.plugins.market.offlineShort": "目錄離線 · 暫時無法安裝",
"settings.plugins.market.readmeInfo": "README 來源說明",
"settings.plugins.market.installed": "已安裝",
"settings.plugins.market.sourceBadge": "原始碼範例",
"settings.plugins.market.title": "插件市場",
"settings.plugins.market.note": "精選開源插件。審核不代表安全認證,更新由你手動確認。",
"settings.plugins.market.back": "所有插件",
"settings.plugins.market.install": "安裝此版本(保持停用)",
"settings.plugins.market.installNote": "這會替換並停止已安裝版本。請在已安裝列表檢查權限後再啟用。",
"settings.plugins.market.sourceOnly": "第一方源碼示例。請用本地 CLI 構建並導入 .ncpkg;尚未發佈可下載發行包。",
"settings.plugins.market.readmeNote": "固定到上面的提交。HTML 不執行,遠程圖片不加載。內容由插件作者提供。",
"settings.plugins.manager.noVault": "系統密鑰存儲不可用。秘密和憑據操作被阻止;瀏覽市場和不使用秘密的插件仍可使用。",
"settings.plugins.manager.sensitive": "敏感訪問:授權前請檢查涉及的數據和資源範圍。",
"settings.plugins.manager.dual": "默認使用瀏覽器運行時,但包中還包含擁有完整設備權限的 Node 入口;僅限開發者模式。",
"settings.plugins.manager.advancedDeclaration": "瀏覽器入口聲明瞭高級權限。不適用於普通瀏覽器插件模式;請逐項檢查聲明權限。",
"settings.plugins.market.retry": "重試",
"settings.plugins.market.empty": "目錄暫無插件,仍可導入本地瀏覽器插件。",
"settings.plugins.market.emptyReadme": "README 內容為空。",
"settings.plugins.market.search": "搜索名稱、作者或描述",
"settings.plugins.market.noResults": "沒有匹配插件,請嘗試其他關鍵詞。",
"settings.plugins.market.manifest": "兼容性與權限",
'settings.plugins.empty': '目前沒有已啟用外掛程式提供設定。',
'settings.plugins.scopeContext': '請在對應的 {scope} 情境中開啟此設定。',
'settings.plugins.pressKeybinding': '請按下組合鍵',
+19
View File
@@ -0,0 +1,19 @@
import { useEffect, useState } from 'react';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
type Raster = { light: string; dark?: string };
export function usePluginBrandIcon(plugin?: NetcattyInstalledPlugin): Raster | null {
const pluginId = plugin?.id;
const kind = plugin?.manifest?.icon?.kind;
const key = JSON.stringify([pluginId, plugin?.activeVersion, plugin?.archiveSha256, plugin?.runtime?.quarantinedAt, plugin?.manifest?.icon]);
const [loaded, setLoaded] = useState<{ key: string; value: Raster | null } | null>(null);
useEffect(() => {
if (!pluginId || kind !== 'package') return;
let cancelled = false;
void netcattyBridge.get()?.getPluginBrandIcon?.(pluginId).then((value) => {
if (!cancelled) setLoaded({ key, value });
}).catch(() => { if (!cancelled) setLoaded({ key, value: null }); });
return () => { cancelled = true; };
}, [key, pluginId, kind]);
return loaded?.key === key ? loaded.value : null;
}
@@ -0,0 +1,84 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import React from 'react';
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
import { usePluginCatalog } from './usePluginCatalog';
function deferred<T>() {
let resolve!: (value: T) => void;
let reject!: (cause: Error) => void;
const promise = new Promise<T>((yes, no) => { resolve = yes; reject = no; });
return { promise, resolve, reject };
}
test('README failures clear on retry/switch and stale requests cannot replace the selected entry', async (context) => {
const original = Object.getOwnPropertyDescriptor(globalThis, 'window');
const calls: Array<{ id: string; request: ReturnType<typeof deferred<string>> }> = [];
Object.defineProperty(globalThis, 'window', { configurable: true, value: {
netcatty: {
listPluginCatalog: async () => ({ entries: [], source: "builtin", error: null }),
getPluginCatalogReadme: (id: string) => { const request = deferred<string>(); calls.push({ id, request }); return request.promise; },
},
} });
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
context.after(() => {
if (original) Object.defineProperty(globalThis, 'window', original); else Reflect.deleteProperty(globalThis, 'window');
Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT');
});
let state!: ReturnType<typeof usePluginCatalog>;
let renders = 0;
function Probe({ id }: { id: string }) { state = usePluginCatalog(id); renders++; return null; }
let renderer!: ReactTestRenderer;
await act(async () => { renderer = create(React.createElement(Probe, { id: 'first' })); });
assert.equal(state.readmeStatus, 'loading');
await act(async () => calls[0].request.reject(new Error('temporary failure')));
assert.equal(state.readmeStatus, 'error');
assert.match(state.readmeError!, /temporary failure/);
await act(async () => state.retryReadme());
assert.equal(state.readmeStatus, 'loading'); assert.equal(state.readmeError, null);
await act(async () => calls[1].request.resolve(''));
assert.equal(state.readmeStatus, 'ready'); assert.equal(state.readme, ''); assert.equal(state.readmeError, null);
await act(async () => state.retryReadme());
await act(async () => renderer.update(React.createElement(Probe, { id: 'second' })));
assert.equal(state.readmeError, null);
await act(async () => calls[3].request.resolve('# Second'));
await act(async () => calls[2].request.reject(new Error('stale failure')));
assert.equal(state.readme, '# Second'); assert.equal(state.readmeError, null);
await act(async () => state.retryReadme());
await act(async () => renderer.unmount());
const unmountedRenders = renders;
await act(async () => calls[4].request.resolve('late content'));
assert.equal(renders, unmountedRenders);
});
test('offline snapshot stays browsable, retry clears warning, and operations bind the selected token', async (context) => {
const original = Object.getOwnPropertyDescriptor(globalThis, 'window');
const entry = { id: 'com.test.registry', selectionToken: 'first' } as NetcattyPluginCatalogEntry;
let offline = true;
const reads: unknown[][] = []; const installs: unknown[][] = [];
Object.defineProperty(globalThis, 'window', { configurable: true, value: {
dispatchEvent() {}, netcatty: {
listPluginCatalog: async () => ({ entries: [{ ...entry, selectionToken: offline ? 'first' : 'second' }], source: offline ? 'cached' : 'registry', error: offline ? 'offline' : null }),
getPluginCatalogReadme: async (...args: unknown[]) => { reads.push(args); return '# Pinned'; },
installCatalogPlugin: async (...args: unknown[]) => { installs.push(args); },
},
} });
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
context.after(() => {
if (original) Object.defineProperty(globalThis, 'window', original); else Reflect.deleteProperty(globalThis, 'window');
Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT');
});
let state!: ReturnType<typeof usePluginCatalog>;
function Probe() { state = usePluginCatalog(entry.id); return null; }
let renderer!: ReactTestRenderer;
await act(async () => { renderer = create(React.createElement(Probe)); });
assert.equal(state.entries.length, 1); assert.equal(state.listStatus, 'ready'); assert.equal(state.listError, 'offline');
assert.equal(state.source, 'cached');
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']);
await act(async () => state.install(entry.id));
assert.deepEqual(installs, [[entry.id, 'second']]);
await act(async () => renderer.unmount());
});
+70
View File
@@ -0,0 +1,70 @@
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) {
const [entries, setEntries] = useState<NetcattyPluginCatalogEntry[]>([]);
const [listStatus, setListStatus] = useState<'loading' | 'ready' | 'error'>('loading');
const [source, setSource] = useState<NetcattyPluginCatalogSnapshot['source']>('builtin');
const [listError, setListError] = useState<string | null>(null);
const [listAttempt, setListAttempt] = useState(0);
const [readmeState, setReadmeState] = useState<ReadmeState | null>(null);
const [readmeAttempt, setReadmeAttempt] = useState(0);
const [installError, setInstallError] = useState<{ id: string; message: string } | null>(null);
const [busy, setBusy] = useState(false);
const installing = useRef(false);
const mounted = useRef(false);
useEffect(() => { mounted.current = true; return () => { mounted.current = false; }; }, []);
useEffect(() => {
let active = true;
setListStatus('loading'); setListError(null);
void (async () => {
try {
const bridge = netcattyBridge.get();
if (!bridge?.listPluginCatalog) throw new Error('Catalog browsing unavailable');
const list = await bridge.listPluginCatalog();
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]);
const selectionToken = entries.find((entry) => entry.id === selectedId)?.selectionToken;
useEffect(() => {
if (!selectedId) return;
let active = true;
const base = { id: selectedId, token: selectionToken, attempt: readmeAttempt, text: '', error: null };
setReadmeState({ ...base, status: 'loading' });
void (async () => {
try {
const bridge = netcattyBridge.get();
if (!bridge?.getPluginCatalogReadme) throw new Error('README retrieval unavailable');
const text = await bridge.getPluginCatalogReadme(selectedId, selectionToken);
if (active) setReadmeState({ ...base, status: 'ready', text });
} catch (cause) { if (active) setReadmeState({ ...base, status: 'error', error: String(cause) }); }
})();
return () => { active = false; };
}, [selectedId, selectionToken, readmeAttempt]);
const currentReadme = readmeState?.id === selectedId && readmeState.token === selectionToken && readmeState.attempt === readmeAttempt ? readmeState : null;
return {
entries, listStatus, listError, source,
readme: currentReadme?.status === 'ready' ? currentReadme.text : null,
readmeStatus: currentReadme?.status ?? (selectedId ? 'loading' : 'idle'),
readmeError: currentReadme?.error ?? null,
error: installError?.id === selectedId ? installError.message : null,
busy,
retryList: () => setListAttempt((attempt) => attempt + 1),
retryReadme: () => setReadmeAttempt((attempt) => attempt + 1),
async install(id: string) {
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);
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); }
},
};
}
+5 -2
View File
@@ -4,6 +4,7 @@ import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
export function usePluginManager() {
const [plugins, setPlugins] = useState<NetcattyInstalledPlugin[]>([]);
const [available, setAvailable] = useState(false);
const [credentialStorageAvailable, setCredentialStorageAvailable] = useState<boolean | undefined>();
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const generation = useRef(0);
@@ -15,14 +16,16 @@ export function usePluginManager() {
const list = status?.available ? await bridge?.listPlugins?.() ?? [] : [];
if (current !== generation.current) return;
setAvailable(status?.available === true);
setCredentialStorageAvailable(status?.credentialStorageAvailable);
setPlugins(list);
}, []);
useEffect(() => {
const update = () => { void refresh().catch((cause) => setError(String(cause))); };
update();
const unsubscribe = netcattyBridge.get()?.onPluginContributionsChanged?.(update);
window.addEventListener('netcatty:plugin-packages-changed', update);
const refreshGeneration = generation;
return () => { refreshGeneration.current++; unsubscribe?.(); };
return () => { refreshGeneration.current++; unsubscribe?.(); window.removeEventListener('netcatty:plugin-packages-changed', update); };
}, [refresh]);
const run = useCallback(async (action: (bridge: NetcattyBridge) => Promise<unknown>) => {
if (mutation.current) return;
@@ -38,7 +41,7 @@ export function usePluginManager() {
}
}, [refresh]);
return {
plugins, available, busy, error,
plugins, available, busy, error, credentialStorageAvailable,
importPackage: () => run(async (bridge) => {
if (!bridge.selectFile || !bridge.installPluginPackage) throw new Error('Plugin import unavailable');
const path = await bridge.selectFile('Import plugin package', undefined, [{ name: 'Netcatty plugin', extensions: ['ncpkg'] }]);
@@ -151,3 +151,24 @@ test('plugin palette items keep repeated placements independently addressable',
commandId: 'com.example.palette.run',
}]);
});
test('packaged Starter contributions are discoverable through the real palette builder', async (context) => {
const { createRequire } = await import('node:module');
const { mkdtempSync, rmSync } = await import('node:fs');
const { tmpdir } = await import('node:os');
const { join } = await import('node:path');
const require = createRequire(import.meta.url);
const { PluginDatabase } = require('../electron/plugins/database.cjs');
const { PluginContributionService } = require('../electron/plugins/contributionService.cjs');
const manifest = JSON.parse(readFileSync(new URL('../examples/plugins/starter-netcatty/netcatty.plugin.json', import.meta.url), 'utf8'));
const root = mkdtempSync(join(tmpdir(), 'starter-palette-'));
const database = new PluginDatabase(join(root, 'plugins.sqlite'));
context.after(() => { database.close(); rmSync(root, { recursive: true, force: true }); });
database.installVersion({ pluginId: manifest.id, version: manifest.version, manifest, archiveSha256: 'a'.repeat(64), packageRelativePath: 'starter' }, { enable: true });
const service = new PluginContributionService({ database, runtimeSupervisor: {}, secretStore: {}, getLocale: () => 'en' });
const items = buildPluginPaletteItems(service.snapshot().plugins, 'Starter');
assert.ok(items.some((item) => item.type === 'plugin-command' && item.commandId === 'com.netcatty.starter.greet'));
assert.ok(items.some((item) => item.type === 'plugin-view' && item.id === 'com.netcatty.starter.panel'));
database.setEnabled(manifest.id, false);
assert.equal(buildPluginPaletteItems(service.snapshot().plugins, 'Starter').length, 0);
});
+26
View File
@@ -0,0 +1,26 @@
import React, { useState } from 'react';
import { Package } from 'lucide-react';
import { usePluginBrandIcon } from '../../application/state/usePluginBrandIcon';
import { PluginContributionIcon } from './PluginContributionIcon';
import { resolveCatalogBrandIcon } from './catalogBrandIcons';
export function PluginBrandIcon({ plugin, entry, size = 32, className = '' }: {
plugin?: NetcattyInstalledPlugin;
entry?: NetcattyPluginCatalogEntry;
size?: number;
className?: string;
}) {
const manifest = plugin?.manifest ?? entry?.manifest;
const icon = manifest?.icon;
const key = JSON.stringify([plugin?.id, plugin?.activeVersion, plugin?.archiveSha256, plugin?.runtime?.quarantinedAt, icon]);
const loaded = usePluginBrandIcon(plugin);
const [failed, setFailed] = useState<string | null>(null);
const raster = plugin ? loaded : entry ? resolveCatalogBrandIcon(entry) : null;
const imageKey = JSON.stringify([key, raster]);
if (icon?.kind === 'theme') return <PluginContributionIcon icon={icon} size={size} className={className} />;
if (!raster || failed === imageKey) return <Package size={size} className={className} aria-hidden="true" data-plugin-brand="fallback" />;
return <span className={`inline-flex shrink-0 ${className}`} style={{ width: size, height: size }} aria-hidden="true" data-plugin-brand="package">
<img src={raster.light} alt="" width={size} height={size} className={raster.dark ? 'h-full w-full object-contain dark:hidden' : 'h-full w-full object-contain'} onError={() => setFailed(imageKey)} />
{raster.dark && <img src={raster.dark} alt="" width={size} height={size} className="hidden h-full w-full object-contain dark:block" onError={() => setFailed(imageKey)} />}
</span>;
}
@@ -60,7 +60,7 @@ export function PluginContributionIcon({
const packageIcon = usePluginContributionIcon(pluginId, icon);
if (icon?.kind === 'theme') {
const Icon = THEME_ICONS[icon.name] ?? Puzzle;
const Icon = Object.hasOwn(THEME_ICONS, icon.name) ? THEME_ICONS[icon.name] : Puzzle;
return <Icon size={size} className={className} aria-hidden="true" data-plugin-icon-kind="theme" />;
}
if (packageIcon) {
@@ -0,0 +1,17 @@
[
{
"id": "com.netcatty.starter",
"version": "0.1.2",
"repository": "binaricat/Netcatty",
"commit": "f4a6b205adca486d4e9cda0270e13a9ff0afd7be",
"icon": {
"kind": "package",
"light": "assets/logo-light.png",
"dark": "assets/logo-dark.png"
},
"light": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAIAAAACACAYAAADDPmHLAAADMElEQVR4nO2d200cQRAAmwuAGJAcF3E4BsfhuCwRAwEYf9lGyz5mdqZf01WfSNydqJreYbUMIgAAAFCRJ8s3e3v//WH5fpl5eX6YuFF9E4TPQysIlRdFvB6zQ5j6Yoi3Y1YIU14E8X6MhvAY/QDI92X05z8UAPJjMOLh1vhAfFx6LwndEwD5sen10xUA8nPQ42l4Ewi5aQ6A1Z+LVl9NASA/Jy3eLgNAfm6u/LEHKM5pAKz+NTjzyAQozmEArP61OPLJBCjObgCs/jXZ88oEKA4BFIcAivMlAK7/a7P1ywQoDgEUhwCKQwDFIYDiEEBxCKA4YQL49v2n90coSYgA/sonAnvcA9hKJwJbXAM4kk0EdrgFcCWZCGxwCaBVLhHoYx5Ar1Qi0MU0gLsyiUAP0wB+/Xi9/b1EoIP5JYAIYuGyCSSCOLj9GkgEMXC9EUQE/rjfCiYCX9wDECECT0IEIEIEXoQJQIQIPAgVgAgRWBMuABEisCRkACJEYEXYAESIwILQAYgQgTbhAxAhAk1SBCBCBFqkCUCECDRIFYDI/QhG4lmZdAGI9MtE/jEpAxBpl4r8c9IGwKPlc0gZAI+WzyNdADxaPpdUAYxIZC+wT5oAkK9DigCQr0f4AJCvS+gAkK9P2ACQb0PIAJBvR7gAkG9LqACQb0+YAJDvQ4gAkO+HewDI9yXkOYEtIH8OYc8JPAP58wh9TuAeyJ9L+HMCP4P8+aQ4J1AE+VqkOCcQ+Xo8bb9g8Y8jeyaBhXzvx8WsA395fvzzHvqcQFa+PmHPCUS+DSHPCUS+He63greykW+LewAi/6Uj354QAYgg34swAYAPBFAclxtB4Iv7jSCIAwEUhwCKQwDFIYDiEEBxCKA4BFCcLwF8vkkA67H1ywQoDgEUhwCKsxsA+4A12fPKBCjOYQBMgbU48skEKM5pAEyBNTjzyAQozmUATIHcXPlrmgBEkJMWb82XACLIRasv9gDF6QqAKZCDHk/dE4AIYtPrZ0gmf0QSh7sLc2gPwDSIwYiH4U0gEfgy+vOfKo9Lgh2zFp7K6iUEPWZPXNXxTQjz0LrUml6/CaId9lYAAKDMHwpGNG+oRdPRAAAAAElFTkSuQmCC",
"lightSha256": "ec7bcd5e470ee3d3d9f7f77d120a4de7496a9c0ba984094f84e560d240bea947",
"dark": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAIAAAACACAYAAADDPmHLAAADMElEQVR4nO2cO07EMBQAH4iSk1FyBmokToJEzRkoOdn2UCHQko8d+/3yZvrdrJjxi4kiiwAAAEBFbiwvdv/w/GV5vcxcPt9M3KheBOHz0ApC5UsRr8fsEKZ+GeLtmBXClC9BvB+jIdyO/gDk+zL69x8KAPkxGPFwaHwgPi69t4TuCYD82PT66QoA+Tno8TS8CYTcNAfA6s9Fq6+mAJCfkxZvuwEgPzd7/tgDFGczAFb/OdjyyAQozmoArP5zseaTCVCcxQBY/edkySsToDgEUBwCKM6/ALj/n5trv0yA4hBAcQigOARQHAIoDgEUhwCKEyaAj/dX759QkhAB/MgnAnvcA7iWTgS2uAawJpsI7HALYE8yEdjgEkCrXCLQxzyAXqlEoItpAEdlEoEepgE8Pr0c/iwR6GB+CyCCWLhsAokgDm7/BhJBDFwfBBGBP+6PgonAF/cARIjAkxABiBCBF2ECECECD0IFIEIE1oQLQIQILAkZgAgRWBE2ABEisCB0ACJEoE34AESIQJMUAYgQgRZpAhAhAg1SBSByPIKReM5MugBE+mUif52UAYi0S0X+NmkD4NXyOaQMgFfL55EuAF4tn0uqAEYkshdYJk0AyNchRQDI1yN8AMjXJXQAyNcnbADItyFkAMi3I1wAyLclVADItydMAMj3IUQAyPfDPQDk+xLynMAWkD+HsOcEboH8eYQ+J3AJ5M8l/DmBf0H+fFKcEyiCfC1SnBOIfD3urC/4+PTSNQks5Hu/LuYZeOhzAln5+oQ9JxD5NoQ8JxD5drg/Cr6WjXxb3AMQ+ZWOfHtCBCCCfC/CBAA+EEBxzB8ERaTy7YcJUBwCKA4BFIcAikMAxSGA4hBAcQigOP8CuHy+3Xj8ELDh2i8ToDgEUBwCKM5iAOwDzsmSVyZAcVYDYAqcizWfTIDibAbAFDgHWx6ZAMXZDYApkJs9f00TgAhy0uKt+RZABLlo9cUeoDhdATAFctDjqXsCEEFsev0Mybx/eP4a+TzM4+jCHNoDMA1iMOJheBNIBL6M/v2nyuOWYMeshaeyeglBj9kTV3V8E8I8tG61pvdvgmiHvRUAACjzDQ0NL/vgx29SAAAAAElFTkSuQmCC",
"darkSha256": "7a897a67ae6b652339c77a113d1c759919776fead82d2eefd0238d3c05ba87fe"
}
]
+9
View File
@@ -0,0 +1,9 @@
import previews from './catalog-brand-icons.json';
/** Only bundled reviewed assets; catalog data never becomes an image URL. */
export function resolveCatalogBrandIcon(entry: NetcattyPluginCatalogEntry): { light: string; dark?: string } | null {
const preview = previews.find((item) => item.id === entry.id && item.version === entry.version
&& item.repository === entry.repository && item.commit === entry.commit
&& JSON.stringify(item.icon) === JSON.stringify(entry.manifest.icon));
return preview ? { light: preview.light, dark: preview.dark } : null;
}
@@ -0,0 +1,70 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import React from 'react';
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
import { renderToStaticMarkup } from 'react-dom/server';
import { readFileSync } from 'node:fs';
import { createHash } from 'node:crypto';
import { inspectPluginBrandIcon } from '@netcatty/plugin-contract';
import { PluginBrandIcon } from '../../plugins/PluginBrandIcon';
import { resolveCatalogBrandIcon } from '../../plugins/catalogBrandIcons';
import previews from '../../plugins/catalog-brand-icons.json';
const preview = previews[0];
const entry = { ...preview, manifest: { icon: preview.icon } } as unknown as NetcattyPluginCatalogEntry;
test('reviewed catalog brand previews bind version, source and declaration, never arbitrary URLs', () => {
assert.ok(resolveCatalogBrandIcon(entry));
const catalog = JSON.parse(readFileSync(new URL('../../../electron/plugins/catalog.json', import.meta.url), 'utf8')) as NetcattyPluginCatalogEntry[];
assert.ok(resolveCatalogBrandIcon(catalog.find((item) => item.id === preview.id)!));
for (const replacement of [{ version: '9.0.0' }, { commit: '0'.repeat(40) }, { repository: 'untrusted/plugin' }, { manifest: { icon: { kind: 'package', light: 'https://attacker.invalid/a.png' } } }]) {
assert.equal(resolveCatalogBrandIcon({ ...entry, ...replacement } as NetcattyPluginCatalogEntry), null);
}
for (const theme of ['light', 'dark'] as const) {
const bytes = Buffer.from(preview[theme].split(',')[1], 'base64');
assert.equal(createHash('sha256').update(bytes).digest('hex'), preview[`${theme}Sha256`]);
assert.equal(inspectPluginBrandIcon(bytes, preview.icon[theme]).width, 128);
assert.deepEqual(bytes, readFileSync(new URL(`../../../examples/plugins/starter-netcatty/${preview.icon[theme]}`, import.meta.url)));
}
const html = renderToStaticMarkup(<PluginBrandIcon entry={entry} />);
assert.match(html, /data-plugin-brand="package"/);
assert.match(html, /dark:hidden/);
assert.match(html, /dark:block/);
assert.doesNotMatch(html, /src="https?:/);
assert.match(renderToStaticMarkup(<PluginBrandIcon />), /data-plugin-brand="fallback"/);
});
test('installed brand uses metadata only, rejects stale responses, and falls back after image errors', async () => {
const oldWindow = Object.getOwnPropertyDescriptor(globalThis, 'window');
const previousAct = (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT;
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
const requests: Array<(v: { light: string }) => void> = [];
Object.defineProperty(globalThis, 'window', { configurable: true, value: { netcatty: { getPluginBrandIcon: () => new Promise((resolve) => requests.push(resolve)) } } });
const plugin = { id: 'com.netcatty.starter', enabled: false, activeVersion: '1.0.0', archiveSha256: 'a', runtime: {}, manifest: { icon: preview.icon } } as NetcattyInstalledPlugin;
let renderer: ReactTestRenderer | undefined;
try {
await act(async () => { renderer = create(<PluginBrandIcon plugin={plugin} />); });
assert.equal(requests.length, 1);
await act(async () => { renderer!.update(<PluginBrandIcon plugin={{ ...plugin, archiveSha256: 'b' }} />); });
await act(async () => requests[0]({ light: preview.light }));
assert.equal(renderer!.root.findAllByType('img').length, 0);
await act(async () => requests[1]({ light: preview.light }));
assert.equal(renderer!.root.findAllByType('img').length, 1);
await act(async () => renderer!.root.findByType('img').props.onError());
assert.equal(renderer!.root.findAllByType('img').length, 0);
await act(async () => { renderer!.update(<PluginBrandIcon />); });
assert.equal(renderer!.root.findAllByType('img').length, 0);
} finally {
await act(async () => renderer?.unmount());
if (oldWindow) Object.defineProperty(globalThis, 'window', oldWindow); else Reflect.deleteProperty(globalThis, 'window');
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = previousAct;
}
});
test('disabled brand theme names cannot resolve Object prototype members', () => {
for (const name of ['constructor', '__proto__', 'toString', 'valueOf', 'hasOwnProperty']) {
const plugin = { id: 'com.test.theme', enabled: false, activeVersion: '1.0.0', runtime: {}, manifest: { icon: { kind: 'theme', name } } } as NetcattyInstalledPlugin;
const html = renderToStaticMarkup(<PluginBrandIcon plugin={plugin} />);
assert.match(html, /lucide-puzzle/);
assert.doesNotMatch(html, /<img/);
}
});
@@ -0,0 +1,114 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { JSDOM } from 'jsdom';
test('information previews on hover/focus, pins on click and Escape returns focus without closing the manager', 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);
const { default: React, act, useState } = await import('react');
const { createRoot } = await import('react-dom/client');
const { PluginInfo } = await import('./PluginInfo');
const { PluginManagementDialog } = await import('./PluginManagementDialog');
const { I18nProvider } = await import('../../../application/i18n/I18nProvider');
const root = createRoot(document.getElementById('root')!);
const settle = (ms = 60) => new Promise((resolve) => setTimeout(resolve, ms));
try {
await act(async () => root.render(<I18nProvider locale="en"><PluginManagementDialog><PluginInfo label="Plugin help">Packages remain disabled until reviewed.</PluginInfo><button data-other>Other control</button></PluginManagementDialog></I18nProvider>));
await act(async () => { document.querySelector<HTMLButtonElement>('button')!.click(); await settle(); });
const trigger = document.querySelector<HTMLButtonElement>('button[aria-label="Plugin help"]')!;
const other = document.querySelector<HTMLButtonElement>('[data-other]')!;
assert.ok(trigger); assert.equal(trigger.getAttribute('title'), null, 'not a native title-only hint');
await act(async () => { other.focus(); await settle(); });
const hover = new MouseEvent('pointermove', { bubbles: true });
Object.defineProperty(hover, 'pointerType', { value: 'mouse' });
await act(async () => { trigger.dispatchEvent(hover); await settle(260); });
assert.match(document.querySelector('[role="tooltip"]')?.textContent ?? '', /Packages remain disabled/);
await act(async () => { trigger.focus(); await settle(); });
assert.ok(document.querySelector('[role="tooltip"]'));
await act(async () => { trigger.click(); await settle(); });
const pinned = document.querySelector<HTMLElement>('[role="dialog"][aria-label="Plugin help"]');
assert.ok(pinned, 'click exposes a persistent readable popover');
assert.equal(document.querySelector('[role="tooltip"]'), null);
await act(async () => { pinned.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true })); await settle(); });
assert.equal(document.querySelector('[role="dialog"][aria-label="Plugin help"]'), null);
assert.equal(document.querySelectorAll('[role="dialog"]').length, 1, 'Escape closes only the information layer');
assert.equal(document.activeElement, trigger);
assert.equal(Boolean(document.querySelector('[role="tooltip"]')), false, 'Escape does not immediately reopen the focus preview');
await act(async () => { other.focus(); trigger.focus(); await settle(); });
assert.ok(document.querySelector('[role="tooltip"]'), 'a subsequent deliberate focus still previews help');
const { PluginMarketplaceContent } = await import('./PluginMarketplace');
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>;
function Browser() {
const [selected, setSelected] = useState<string | null>(null);
const [toolbar, setToolbar] = useState<HTMLDivElement | null>(null);
return <I18nProvider locale="en"><div data-toolbar ref={setToolbar} /><div data-settings-scroll-pane><PluginMarketplaceContent catalog={catalog} installed={[]} selected={selected} onSelect={setSelected} toolbarContainer={toolbar} /></div></I18nProvider>;
}
await act(async () => { root.render(<Browser />); await settle(); });
const pane = document.querySelector<HTMLElement>('[data-settings-scroll-pane]')!;
assert.match(document.querySelector('[data-toolbar]')?.textContent ?? '', /Refresh/);
pane.scrollTop = 450;
await act(async () => { document.querySelector<HTMLButtonElement>('[data-plugin-id]')!.click(); await settle(); });
assert.equal(document.querySelector('.plugin-marketplace-catalog'), null, 'detail replaces the candidate list');
assert.doesNotMatch(document.querySelector('[data-toolbar]')?.textContent ?? '', /Refresh/, 'list refresh stays out of detail');
assert.equal(pane.scrollTop, 0);
assert.equal(document.activeElement?.tagName, 'H4');
assert.equal(document.activeElement?.textContent, 'Guide');
const back = Array.from(document.querySelectorAll('button')).find((button) => button.textContent?.includes('All plugins'))!;
await act(async () => { back.click(); await settle(); });
assert.equal(pane.scrollTop, 450);
assert.equal(document.activeElement?.getAttribute('data-plugin-id'), entry.id);
assert.match(document.querySelector('[data-toolbar]')?.textContent ?? '', /Refresh/);
const { PluginStatus } = await import('./PluginStatus');
function StatusHarness() {
const [offline, setOffline] = useState(true);
const [noVault, setNoVault] = useState(true);
return <I18nProvider locale="en"><PluginStatus noVault={noVault} offlineError={offline ? 'Network unavailable' : null} retry={() => setOffline(false)} /><button onClick={() => setNoVault(false)}>Restore vault</button></I18nProvider>;
}
await act(async () => { root.render(<StatusHarness />); await settle(); });
const status = document.querySelector<HTMLButtonElement>('button[aria-label$="Notices: 2"]')!;
await act(async () => { status.focus(); await settle(); });
assert.match(document.querySelector('[role="tooltip"]')?.textContent ?? '', /Network unavailable/);
assert.equal(document.querySelector('[role="tooltip"] button'), null, 'hover preview never contains an interactive Retry');
await act(async () => { status.click(); await settle(); });
const retry = document.querySelector<HTMLButtonElement>('[role="dialog"] button')!;
assert.equal(retry.textContent, 'Retry');
assert.equal(status.textContent, '2', 'only the number badge is visible, not a second text label');
assert.equal(document.querySelectorAll('button[aria-label*="Notices:"]').length, 1);
assert.match(document.querySelector('[role="dialog"]')?.textContent ?? '', /Updates are manual/);
assert.ok(document.querySelector('[role="dialog"] hr'));
await act(async () => { retry.click(); await settle(); });
assert.ok(document.querySelector('button[aria-label$="Notices: 1"]'));
assert.doesNotMatch(document.querySelector('[role="dialog"]')?.textContent ?? '', /Network unavailable/);
await act(async () => { document.querySelector('[role="dialog"]')!.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true })); await settle(); });
assert.match(document.activeElement?.getAttribute('aria-label') ?? '', /Notices: 1$/);
await act(async () => { Array.from(document.querySelectorAll('button')).find((button) => button.textContent === 'Restore vault')!.click(); await settle(); });
const healthy = document.querySelector<HTMLButtonElement>('button[aria-label="Plugin information"]')!;
assert.ok(healthy, 'one information control remains when healthy');
assert.equal(healthy.querySelector('span[aria-hidden="true"]'), null, 'healthy control has no count badge');
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(); });
} finally {
await act(async () => root.unmount());
dom.window.close();
for (const [key, descriptor] of previous) {
if (descriptor) Object.defineProperty(globalThis, key, descriptor); else Reflect.deleteProperty(globalThis, key);
}
}
});
+25
View File
@@ -0,0 +1,25 @@
import React, { useRef, useState } from 'react';
import { Info } from 'lucide-react';
import { Button } from '../../ui/button';
import { Popover, PopoverContent, PopoverTrigger } from '../../ui/popover';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../../ui/tooltip';
/** Hover/focus previews explanatory text; click pins it in the existing accessible popover. */
export function PluginInfo({ label, children, badgeCount, previewContent }: { label: string; children: React.ReactNode; badgeCount?: number; previewContent?: React.ReactNode }) {
const [pinned, setPinned] = useState(false);
const [preview, setPreview] = useState(false);
const suppressFocusPreview = useRef(false);
return <Popover open={pinned} onOpenChange={(open) => { setPinned(open); setPreview(false); }}>
<TooltipProvider delayDuration={200}>
<Tooltip open={!pinned && preview} onOpenChange={(open) => { if (!open || !suppressFocusPreview.current) setPreview(open); }}>
<TooltipTrigger asChild><PopoverTrigger asChild>
<Button type="button" variant="ghost" size="sm" className="relative h-8 w-8 shrink-0 p-0 text-muted-foreground focus-visible:ring-2 focus-visible:ring-ring" aria-label={label} onBlur={() => { suppressFocusPreview.current = false; }} onPointerMove={() => { suppressFocusPreview.current = false; }}>
<Info size={15} />{Boolean(badgeCount) && <span aria-hidden="true" className="absolute right-0 top-0 flex h-3.5 min-w-3.5 items-center justify-center rounded-full bg-muted px-0.5 text-[9px] font-medium leading-none text-foreground">{badgeCount}</span>}
</Button>
</PopoverTrigger></TooltipTrigger>
<TooltipContent className="max-w-xs space-y-2 bg-popover p-3 text-xs leading-5 text-popover-foreground shadow-sm">{previewContent ?? children}</TooltipContent>
</Tooltip>
</TooltipProvider>
<PopoverContent onEscapeKeyDown={() => { suppressFocusPreview.current = true; }} aria-label={label} align="end" className="max-w-xs space-y-2 text-xs leading-5">{children}</PopoverContent>
</Popover>;
}
@@ -0,0 +1,58 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import React from 'react';
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
import { I18nProvider } from '../../../application/i18n/I18nProvider';
import { PluginPackageManager } from './PluginPackageManager';
import type { usePluginManager } from '../../../application/state/usePluginManager';
test('installed grid filters, opens one detail and restores search, scroll and card focus without pill navigation', async () => {
const globals = globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean };
const previous = globals.IS_REACT_ACT_ENVIRONMENT; globals.IS_REACT_ACT_ENVIRONMENT = true;
const raf = globalThis.requestAnimationFrame;
const frames: FrameRequestCallback[] = [];
globalThis.requestAnimationFrame = (fn) => { frames.push(fn); return frames.length; };
const selections: unknown[] = []; const toggles: unknown[] = []; const focus: string[] = [];
const pane = { scrollTop: 160 };
const plugins = ['Alpha', 'Beta'].map((name) => ({ id: `com.test.${name.toLowerCase()}`, enabled: false, activeVersion: '1.0.0',
manifest: { name, publisher: 'Example', description: `${name} safely formats synthetic greetings without accessing hosts or secrets.`, license: 'MIT', main: { browser: 'index.js' }, engines: { api: '0.1.0-internal', netcatty: '*' } },
runtime: { lastError: name === 'Beta' ? 'Incompatible version' : null },
})) as NetcattyInstalledPlugin[];
const manager = { available: true, plugins, busy: false, setEnabled: async (...args: unknown[]) => { toggles.push(args); } } as unknown as ReturnType<typeof usePluginManager>;
let renderer!: ReactTestRenderer;
try {
await act(async () => { renderer = create(<I18nProvider locale="en"><PluginPackageManager manager={manager} onSelectionChange={(id) => selections.push(id)} /></I18nProvider>, {
createNodeMock: (element) => element.type === 'section' ? {
closest: () => pane,
querySelector: () => ({ focus: () => focus.push('detail') }),
querySelectorAll: () => plugins.map((plugin) => ({ dataset: { pluginCard: plugin.id }, focus: () => focus.push(plugin.id) })),
} : null,
}); });
const cards = () => renderer.root.findAllByType('button').filter((node) => node.props['data-plugin-card']);
assert.equal(cards().length, 2);
const alphaCard = renderer.root.findAllByType('article')[0];
assert.match(JSON.stringify(renderer.toJSON()), /Alpha safely formats synthetic greetings/);
assert.ok(alphaCard.findAllByType('p').some((node) => node.children.includes('Example')), 'author has its own line');
assert.ok(alphaCard.findAllByType('p').some((node) => node.props.className?.includes('line-clamp-3')), 'card description is bounded');
assert.match(JSON.stringify(renderer.toJSON()), /MIT/);
assert.match(JSON.stringify(renderer.toJSON()), /Incompatible version/);
assert.ok(renderer.root.findAllByType('div').some((node) => node.props.className?.includes('repeat(auto-fill')));
const pill = renderer.root.findAllByType('button').find((node) => node.props['aria-pressed'] !== undefined)!;
await act(async () => pill.props.onClick());
assert.deepEqual(toggles, [[plugins[0].id, true]]); assert.deepEqual(selections, []);
await act(async () => renderer.root.findByType('input').props.onChange({ target: { value: 'Alpha' } }));
assert.equal(cards().length, 1);
await act(async () => cards()[0].props.onClick()); frames.splice(0).forEach((fn) => fn(0));
assert.deepEqual(selections, [plugins[0].id]); assert.equal(pane.scrollTop, 0); assert.equal(focus.at(-1), 'detail');
assert.equal(renderer.root.findAllByType('input').length, 0);
assert.match(JSON.stringify(renderer.toJSON()), /Declared permissions/);
assert.ok(renderer.root.findByType('article').findAllByType('p').some((node) => node.props.className?.includes('whitespace-pre-wrap')), 'detail shows the complete description');
const back = renderer.root.findAllByType('button').find((node) => node.children.includes('Installed plugins'))!;
await act(async () => back.props.onClick()); frames.splice(0).forEach((fn) => fn(0));
assert.equal(renderer.root.findByType('input').props.value, 'Alpha'); assert.equal(cards().length, 1);
assert.equal(pane.scrollTop, 160); assert.equal(focus.at(-1), plugins[0].id);
await act(async () => renderer.root.findByType('input').props.onChange({ target: { value: 'none' } }));
assert.equal(cards().length, 0);
await act(async () => renderer.unmount());
} finally { globals.IS_REACT_ACT_ENVIRONMENT = previous; globalThis.requestAnimationFrame = raf; }
});
@@ -0,0 +1,80 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { JSDOM } from 'jsdom';
// Exercise the real Radix portal/focus lifecycle, rather than a mocked Dialog.
test('plugin manager is single-instance, closes with Escape, restores focus and remounts on reopen', 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);
const { default: React, act, useEffect } = await import('react');
const { createRoot } = await import('react-dom/client');
const { I18nProvider } = await import('../../../application/i18n/I18nProvider');
const { PluginManagementDialog } = await import('./PluginManagementDialog');
const root = createRoot(document.getElementById('root')!);
let mounts = 0;
let unmounts = 0;
function Content() {
useEffect(() => { mounts++; return () => { unmounts++; }; }, []);
return <input aria-label="Plugin search" defaultValue="" />;
}
const settle = () => new Promise((resolve) => setTimeout(resolve, 20));
const click = async (element: HTMLElement) => { await act(async () => { element.click(); await settle(); }); };
try {
await act(async () => { root.render(<I18nProvider locale="en"><PluginManagementDialog><Content /></PluginManagementDialog></I18nProvider>); });
const trigger = document.querySelector<HTMLButtonElement>('button')!;
assert.equal(document.querySelector('[role="dialog"]'), null);
assert.equal(mounts, 0, 'closed settings entry does not mount manager subscriptions');
trigger.focus();
await click(trigger);
assert.equal(document.querySelectorAll('[role="dialog"]').length, 1);
assert.equal(mounts, 1);
assert.equal(document.activeElement?.getAttribute('aria-label'), 'Plugin search');
assert.equal(document.querySelector('[role="dialog"]')?.contains(document.querySelector('[data-settings-scroll-pane]')), true);
await act(async () => { root.render(<I18nProvider locale="en"><PluginManagementDialog><Content /></PluginManagementDialog></I18nProvider>); });
assert.equal(document.querySelectorAll('[role="dialog"]').length, 1);
assert.equal(mounts, 1, 'rerender does not spawn another manager');
const closeButton = document.querySelector<HTMLButtonElement>('[role="dialog"] button[data-dialog-close]:not([aria-hidden])')!;
await act(async () => {
closeButton.focus();
closeButton.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', bubbles: true, cancelable: true }));
});
assert.equal(document.activeElement?.getAttribute('aria-label'), 'Plugin search', 'Tab wraps inside the modal');
await act(async () => { document.activeElement!.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); await settle(); });
assert.equal(document.querySelector('[role="dialog"]'), null);
assert.equal(document.activeElement, trigger);
assert.equal(unmounts, 1);
await click(trigger);
assert.equal(mounts, 2);
await click(document.querySelector<HTMLButtonElement>('[role="dialog"] button[data-dialog-close]:not([aria-hidden])')!);
assert.equal(document.querySelector('[role="dialog"]'), null);
assert.equal(document.activeElement, trigger);
assert.equal(unmounts, 2);
let handedOff = false;
await act(async () => root.render(<I18nProvider locale="en"><PluginManagementDialog>{(handoff) => <button onClick={() => handoff(() => {
handedOff = true;
assert.equal(document.querySelector('[role="dialog"]'), null);
assert.notEqual(document.body.style.pointerEvents, 'none');
})}>Open contributed settings view</button>}</PluginManagementDialog></I18nProvider>));
await click(trigger);
await click(document.querySelector<HTMLButtonElement>('[role="dialog"] button:not([data-dialog-close])')!);
assert.equal(handedOff, true, 'external view opens only after modal ownership is released');
} finally {
await act(async () => root.unmount());
dom.window.close();
for (const [key, descriptor] of previous) {
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
else Reflect.deleteProperty(globalThis, key);
}
}
});
@@ -0,0 +1,38 @@
import React, { useRef, useState } from 'react';
import { useI18n } from '../../../application/i18n/I18nProvider';
import { Button } from '../../ui/button';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '../../ui/dialog';
/** Uses the existing renderer and host bridge; opening the manager never starts a plugin. */
export function PluginManagementDialog({ children }: {
children: React.ReactNode | ((handoff: (action: () => void) => void) => React.ReactNode);
}) {
const { t } = useI18n();
const [open, setOpen] = useState(false);
const pendingHandoff = useRef<(() => void) | null>(null);
const handoff = (action: () => void) => { pendingHandoff.current = action; setOpen(false); };
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="outline">{t('settings.plugins.openManager')}</Button>
</DialogTrigger>
<DialogContent onCloseAutoFocus={(event) => {
const action = pendingHandoff.current;
pendingHandoff.current = null;
if (action) {
event.preventDefault();
// Wait for Radix to release modal pointer/focus ownership before opening
// the existing external plugin view host (including exit animations).
queueMicrotask(action);
}
}} className="flex h-[min(88dvh,900px)] w-[calc(100vw-2rem)] max-w-[1180px] flex-col gap-0 overflow-hidden p-0">
<DialogHeader className="shrink-0 border-b px-6 py-4 pr-12 text-left">
<DialogTitle>{t('settings.plugins.title')}</DialogTitle>
</DialogHeader>
<div data-settings-scroll-pane className="min-h-0 flex-1 overflow-y-auto overscroll-contain p-4 sm:p-6">
{typeof children === 'function' ? children(handoff) : children}
</div>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,164 @@
import assert from 'node:assert/strict';
import test, { before, after } from 'node:test';
import { JSDOM } from 'jsdom';
import React, { useState } from 'react';
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
import { renderToStaticMarkup } from 'react-dom/server';
import { readFileSync } from 'node:fs';
import { I18nProvider } from '../../../application/i18n/I18nProvider';
import { PluginMarketplaceContent } from './PluginMarketplace';
import type { usePluginCatalog } from '../../../application/state/usePluginCatalog';
const infoDom = new JSDOM('<!doctype html><html><body></body></html>');
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, 'window');
const previousDocument = Object.getOwnPropertyDescriptor(globalThis, 'document');
before(() => {
Object.defineProperty(globalThis, 'window', { configurable: true, value: infoDom.window });
Object.defineProperty(globalThis, 'document', { configurable: true, value: infoDom.window.document });
});
after(() => {
infoDom.window.close();
if (previousWindow) Object.defineProperty(globalThis, 'window', previousWindow); else Reflect.deleteProperty(globalThis, 'window');
if (previousDocument) Object.defineProperty(globalThis, 'document', previousDocument); else Reflect.deleteProperty(globalThis, 'document');
});
const entries = ['Alpha', 'Beta'].map((name) => ({
id: `com.example.${name.toLowerCase()}`, name, author: 'Example', version: '1.0.0', license: 'MIT',
description: `${name} local utility`, repository: 'example/plugins', commit: 'a'.repeat(40),
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,
error: null, busy: false, retryList() {}, retryReadme() {}, async install() {},
} as ReturnType<typeof usePluginCatalog>;
test('catalog keeps search during selection/back, switches detail, and displays real installed state', async () => {
const previousRaf = globalThis.requestAnimationFrame;
globalThis.requestAnimationFrame = (callback) => { callback(0); return 0; };
const actGlobal = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean };
const previousActEnvironment = actGlobal.IS_REACT_ACT_ENVIRONMENT;
actGlobal.IS_REACT_ACT_ENVIRONMENT = true;
let renderer: ReactTestRenderer;
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>;
}
try {
await act(async () => { renderer = create(<Harness />); });
const cards = () => renderer.root.findAll((node) => node.type === 'button' && Boolean(node.props['data-plugin-id']));
const input = () => renderer.root.findByType('input');
assert.equal(cards().length, 2);
await act(async () => input().props.onChange({ target: { value: 'Alpha' } }));
assert.equal(cards().length, 1);
await act(async () => cards()[0].props.onClick());
assert.equal(renderer.root.findByType('article').props['aria-label'], 'Alpha');
assert.equal(cards().length, 0, 'detail replaces the entire grid at every width');
assert.equal(renderer.root.findAllByType('input').length, 0);
const detailText = JSON.stringify(renderer.toJSON());
assert.match(detailText, /0.9.0/);
assert.match(detailText, /Disabled/);
assert.match(detailText, /Incompatible with this host/);
assert.match(detailText, /Last recorded error/);
assert.equal(renderer.root.findAllByType('details').length, 0, 'permissions are not hidden in a disclosure');
assert.equal(renderer.root.findAll((node) => node.type === 'button' && node.children.includes('Install this version (disabled)')).length, 0, 'source-only cards have no install action');
const back = () => renderer.root.findAllByType('button').find((node) => node.children.includes('All plugins'))!;
await act(async () => back().props.onClick());
assert.equal(renderer.root.findAllByType('article').length, 0);
assert.equal(input().props.value, 'Alpha');
await act(async () => input().props.onChange({ target: { value: '' } }));
await act(async () => cards()[1].props.onClick());
assert.equal(renderer.root.findByType('article').props['aria-label'], 'Beta');
assert.equal(cards().length, 0);
await act(async () => back().props.onClick());
await act(async () => input().props.onChange({ target: { value: 'no match' } }));
assert.equal(cards().length, 0);
assert.equal(input().props.value, 'no match');
await act(async () => renderer.unmount());
} finally {
globalThis.requestAnimationFrame = previousRaf;
actGlobal.IS_REACT_ACT_ENVIRONMENT = previousActEnvironment;
}
});
test('README failure leaves the permissions and source visible, before the failure message', () => {
const html = renderToStaticMarkup(<I18nProvider locale="en"><PluginMarketplaceContent
catalog={{ ...catalog, readmeStatus: 'error', readme: null, readmeError: 'Network unavailable' }}
selected={entries[0].id} onSelect={() => {}} installed={[]} /></I18nProvider>);
assert.match(html, /Compatibility and permissions/);
assert.match(html, /settings.read/);
assert.match(html, /not a grant/);
assert.match(html, /Source example/);
assert.ok(html.indexOf('settings.read') < html.indexOf('Network unavailable'));
assert.match(html, /Retry/);
assert.doesNotMatch(html, /<details/);
});
test('cards use subtle shadows and details have no breakpoint-specific columns or nested scroller', () => {
const css = readFileSync(new URL('./plugin-marketplace.css', import.meta.url), 'utf8');
assert.match(css, /box-shadow: 0 1px 4px/);
assert.match(css, /\.dark \.plugin-card/);
assert.doesNotMatch(css, /@container|248px|max-height|overflow-y/);
});
test('full-page detail starts at the top and back restores the settings scroll and card focus', async () => {
const previousRaf = globalThis.requestAnimationFrame;
const frames: FrameRequestCallback[] = [];
globalThis.requestAnimationFrame = (callback) => { frames.push(callback); return frames.length; };
const actGlobal = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean };
const previousActEnvironment = actGlobal.IS_REACT_ACT_ENVIRONMENT;
actGlobal.IS_REACT_ACT_ENVIRONMENT = true;
const pane = { scrollTop: 450 };
let focused = false;
const list = { scrollTop: 0, closest: (selector: string) => selector === '.plugin-marketplace' ? { clientWidth: 1100 } : pane,
querySelectorAll: () => [{ dataset: { pluginId: entries[0].id }, focus: () => { focused = true; } }] };
let renderer!: ReactTestRenderer;
function Harness() {
const [selected, setSelected] = useState<string | null>(null);
return <I18nProvider locale="en"><PluginMarketplaceContent catalog={catalog} selected={selected} onSelect={setSelected} installed={[]} /></I18nProvider>;
}
try {
await act(async () => { renderer = create(<Harness />, { createNodeMock: (element) => element.type === 'div' && (element.props as { className?: string }).className?.includes('plugin-marketplace-catalog') ? list : null }); });
const card = renderer.root.findAll((node) => node.type === 'button' && node.props['data-plugin-id'] === entries[0].id)[0];
await act(async () => card.props.onClick());
frames.splice(0).forEach((frame) => frame(0));
assert.equal(pane.scrollTop, 0);
const back = renderer.root.findAllByType('button').find((node) => node.children.includes('All plugins'))!;
await act(async () => back.props.onClick());
frames.splice(0).forEach((frame) => frame(0));
assert.equal(pane.scrollTop, 450);
assert.equal(focused, true);
await act(async () => renderer.unmount());
} finally {
globalThis.requestAnimationFrame = previousRaf;
actGlobal.IS_REACT_ACT_ENVIRONMENT = previousActEnvironment;
}
});
test('offline catalog warning keeps details visible and disables market installation', () => {
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], listError: 'Registry offline', source: 'cached' }}
selected={downloadable.id} onSelect={() => {}} installed={[]} /></I18nProvider>);
assert.match(html, /Offline catalog · installation unavailable/);
assert.match(html, /Compatibility and permissions/);
assert.match(html, /<button[^>]*disabled=""[^>]*>Install this version/);
assert.match(html, /Notices: 1/);
});
test('manual refresh remains available after success and is disabled while loading', () => {
const ready = renderToStaticMarkup(<I18nProvider locale="en"><PluginMarketplaceContent catalog={catalog} selected={null} onSelect={() => {}} installed={[]} /></I18nProvider>);
assert.match(ready, />Refresh<\/button>/);
const loading = renderToStaticMarkup(<I18nProvider locale="en"><PluginMarketplaceContent catalog={{ ...catalog, listStatus: 'loading' }} selected={null} onSelect={() => {}} installed={[]} /></I18nProvider>);
assert.match(loading, /<button[^>]*disabled=""[^>]*>Refresh<\/button>/);
});
test('replacing an installed plugin discloses that its running version will stop beside the install action', () => {
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>);
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)'));
});
@@ -0,0 +1,9 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { filterPluginCatalog } from './PluginMarketplace';
test('catalog search matches name, author and description without case sensitivity', () => {
const entries = [{ id: 'a', name: 'Starter', author: 'Netcatty', description: 'Local greeting' }] as NetcattyPluginCatalogEntry[];
for (const query of ['starter', 'NETCATTY', ' greeting ']) assert.equal(filterPluginCatalog(entries, query).length, 1);
assert.equal(filterPluginCatalog(entries, 'unrelated').length, 0);
assert.equal(filterPluginCatalog(entries, '').length, 1);
});
@@ -0,0 +1,106 @@
import { createPortal } from 'react-dom';
import { PluginStatus } from './PluginStatus';
import { PluginInfo } from './PluginInfo';
import React, { useRef, useState } from 'react';
import { PluginBrandIcon } from '../../plugins/PluginBrandIcon';
import { ArrowLeft } from 'lucide-react';
import { useI18n } from '../../../application/i18n/I18nProvider';
import { usePluginCatalog } from '../../../application/state/usePluginCatalog';
import { Input } from '../../ui/input';
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 }) {
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} />;
}
// The browser owns only presentation state; installation and README policy stay in the existing hook.
export function PluginMarketplaceContent({ catalog, installed, selected, onSelect, toolbarContainer, noVault }: {
noVault?: boolean;
toolbarContainer?: HTMLElement | null;
catalog: ReturnType<typeof usePluginCatalog>;
installed: NetcattyInstalledPlugin[];
selected: string | null;
onSelect: (id: string | null) => void;
}) {
const { t } = useI18n();
const [search, setSearch] = useState('');
const listRef = useRef<HTMLDivElement>(null);
const detailRef = useRef<HTMLHeadingElement>(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 choose = (id: string) => {
const pane = listRef.current?.closest<HTMLElement>('[data-settings-scroll-pane]');
pageScroll.current = pane?.scrollTop ?? 0;
lastSelection.current = id;
onSelect(id);
requestAnimationFrame(() => {
if (pane) pane.scrollTop = 0;
detailRef.current?.focus({ preventScroll: true });
});
};
const back = () => {
onSelect(null);
requestAnimationFrame(() => {
if (listRef.current) {
const pane = listRef.current.closest('[data-settings-scroll-pane]');
if (pane) pane.scrollTop = pageScroll.current;
const button = Array.from(listRef.current.querySelectorAll<HTMLButtonElement>('[data-plugin-id]'))
.find((item) => item.dataset.pluginId === lastSelection.current);
button?.focus({ preventScroll: true });
}
});
};
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>}
{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}>
<div className="flex items-center gap-3"><Input value={search} onChange={(event) => setSearch(event.target.value)} placeholder={t('settings.plugins.market.search')} aria-label={t('settings.plugins.market.search')} />
{catalog.listStatus === 'ready' && <p className="shrink-0 text-xs text-muted-foreground" aria-live="polite">{t('settings.plugins.market.results', { count: filtered.length })}</p>}</div>
{catalog.listStatus === 'ready' && catalog.entries.length > 0 && filtered.length === 0 && <p className="rounded-lg border border-dashed p-6 text-center text-sm text-muted-foreground">{t('settings.plugins.market.noResults')}</p>}
{catalog.listStatus === 'loading' && <p className="text-xs text-muted-foreground">{t('settings.plugins.loading')}</p>}
{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);
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>
<p className="break-words text-xs text-muted-foreground">{item.author}</p>
<p className="line-clamp-3 break-words text-sm leading-5 text-muted-foreground">{item.description}</p>
<div className="mt-auto flex flex-wrap items-center justify-between gap-2 pt-1 text-xs text-muted-foreground"><span>{item.version} · {item.license}</span><span className="text-foreground">{t('settings.plugins.manager.details')}</span></div>
<div className="flex flex-wrap gap-2 text-xs text-muted-foreground">{isInstalled && <span className="rounded bg-muted px-2 py-0.5 text-foreground">{t('settings.plugins.market.installed')}</span>}{!item.artifact && <span className="rounded bg-muted/40 px-2 py-0.5">{t('settings.plugins.market.sourceBadge')}</span>}</div>
</button>;
})}</div>
</div>}
{entry && <article key={entry.id} aria-label={entry.name} className="plugin-marketplace-detail mx-auto w-full max-w-[920px] min-w-0 space-y-6 focus:outline-none">
<Button variant="ghost" size="sm" onClick={back}><ArrowLeft size={14} className="mr-2" />{t('settings.plugins.market.back')}</Button>
<div className="space-y-2"><div className="flex items-center gap-3"><PluginBrandIcon entry={entry} size={48} /><h4 ref={detailRef} tabIndex={-1} className="break-words text-xl font-semibold focus:outline-none">{entry.name}</h4></div><p className="text-xs text-muted-foreground">{entry.author} · {entry.version} · {entry.license}</p>
{installedEntry && <p className="text-xs text-muted-foreground">{t('settings.plugins.market.installed')} · {installedEntry.activeVersion} · {t(installedEntry.enabled ? 'settings.plugins.manager.enabled' : 'settings.plugins.manager.disabled')}</p>}
</div>
{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>}
<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>}
{catalog.readmeStatus === 'ready' && (catalog.readme?.trim() ? <PluginReadme markdown={catalog.readme} /> : <p className="mt-3 text-xs text-muted-foreground">{t('settings.plugins.market.emptyReadme')}</p>)}
</div>
</article>}
</div>
</section>;
}
export function filterPluginCatalog(entries: NetcattyPluginCatalogEntry[], query: string) {
const needle = query.trim().toLocaleLowerCase();
return entries.filter((entry) => `${entry.name} ${entry.author} ${entry.description}`.toLocaleLowerCase().includes(needle));
}
@@ -22,6 +22,6 @@ test('package detail renders manifest resources as text and explains advanced au
assert.match(html, /https:\/\/example.com/);
assert.match(html, /&lt;script&gt;/);
assert.doesNotMatch(html, /<script>/);
assert.match(html, /Declarations are not grants/);
assert.match(html, /not a grant/);
assert.match(html, /aaaaaaaaaaaaaaaa/);
});
@@ -1,19 +1,25 @@
import React, { useState } from 'react';
import { Package, Upload } from 'lucide-react';
import { describePluginPermission } from '@/domain/pluginPermissionDescriptions.cjs';
import { describePluginRuntime } from '@/domain/pluginRuntimePolicy.cjs';
import React, { useRef, useState } from 'react';
import { PluginBrandIcon } from '../../plugins/PluginBrandIcon';
import { ArrowLeft } from 'lucide-react';
import { usePluginManager } from '../../../application/state/usePluginManager';
import { useI18n } from '../../../application/i18n/I18nProvider';
import { Button } from '../../ui/button';
import { Input } from '../../ui/input';
export function PluginPackageDetails({ plugin }: { plugin: NetcattyInstalledPlugin }) {
const { t } = useI18n();
const { t, resolvedLocale } = useI18n();
const manifest = plugin.manifest;
if (!manifest) return null;
const permissions = [
...(manifest.permissions?.required ?? []).map((value) => ({ value, required: true })),
...(manifest.permissions?.optional ?? []).map((value) => ({ value, required: false })),
];
const advanced = !manifest.main.browser || Boolean(manifest.companionExecutables?.length)
|| manifest.contributes?.providers?.some((p) => p.kind === 'terminal.interceptor.input' || p.kind === 'terminal.interceptor.output');
const policy = describePluginRuntime(manifest);
const runtimeKind = plugin.runtime.kind ?? policy.defaultRuntimeKind;
const runtimeLabel = runtimeKind === 'utility' ? 'advanced'
: policy.hasNode ? 'dual' : policy.declaresAdvanced ? 'advancedDeclaration' : 'browser';
return <div className="space-y-4 border-t border-border/60 pt-4 text-sm">
<dl className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-2 text-xs">
<dt className="text-muted-foreground">ID</dt><dd className="break-all font-mono">{plugin.id}</dd>
@@ -21,44 +27,84 @@ 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={advanced ? 'text-destructive' : 'text-muted-foreground'}>{t(advanced ? 'settings.plugins.manager.advanced' : 'settings.plugins.manager.browser')}</p>
<p className={!policy.browserOnly ? 'text-destructive' : 'text-muted-foreground'}>{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>
{permissions.length === 0 && <p className="text-xs text-muted-foreground">{t('settings.plugins.manager.noPermissions')}</p>}
{permissions.map(({ value, required }, index) => <div key={index} className="rounded-md bg-muted/40 p-3">
<div className="flex justify-between gap-3"><code className="break-all text-xs">{typeof value === 'string' ? value : value.permission}</code><span className="text-xs text-muted-foreground">{t(required ? 'settings.plugins.manager.required' : 'settings.plugins.manager.optional')}</span></div>
{permissions.map(({ value, required }, index) => {
const id = typeof value === 'string' ? value : value.permission;
const description = describePluginPermission(id, resolvedLocale);
return <div key={index} className="rounded-md bg-muted/40 p-3">
<div className="flex justify-between gap-3"><span className="text-sm font-medium">{description.label}</span><span className="text-xs text-muted-foreground">{t(required ? 'settings.plugins.manager.required' : 'settings.plugins.manager.optional')}</span></div>
<p className="mt-1 text-xs text-muted-foreground">{description.description}</p>
<code className="mt-1 block break-all text-[10px] text-muted-foreground">{id}</code>
{description.sensitive && <p className="mt-1 text-xs font-medium text-destructive">{t('settings.plugins.manager.sensitive')}</p>}
{(typeof value === 'string' || value.resources.length === 0) && <p className="mt-1 text-xs">{t('settings.plugins.manager.unbounded')}</p>}
{typeof value !== 'string' && <><p className="mt-1 whitespace-pre-wrap break-all text-xs">{value.resources.join('\n')}</p>{value.reason && <p className="mt-1 text-xs text-muted-foreground">{value.reason}</p>}</>}
</div>)}
</div>; })}
</div>
<p className="text-xs text-muted-foreground">{t('settings.plugins.manager.data')}</p>
</div>;
}
export function PluginPackageManager() {
export function PluginPackageManager({ manager, onSelectionChange }: { manager: ReturnType<typeof usePluginManager>; onSelectionChange?: (id: string | null) => void }) {
const { t, resolvedLocale } = useI18n();
const manager = usePluginManager();
const [selected, setSelected] = useState<string | null>(null);
const [search, setSearch] = useState('');
const rootRef = useRef<HTMLElement>(null);
const scrollTop = useRef(0);
const lastSelection = useRef<string | null>(null);
const selectedPlugin = manager.plugins.find((plugin) => plugin.id === selected);
const nameOf = (plugin: NetcattyInstalledPlugin) => typeof plugin.manifest?.displayName === 'string' ? plugin.manifest.displayName : plugin.manifest?.displayName?.[resolvedLocale] ?? plugin.manifest?.displayName?.en ?? plugin.manifest?.name ?? plugin.id;
const descriptionOf = (plugin: NetcattyInstalledPlugin) => {
const value = plugin.manifest?.description;
return typeof value === 'string' ? value : value?.[resolvedLocale] ?? value?.en ?? '';
};
const choose = (id: string | null) => {
const pane = rootRef.current?.closest<HTMLElement>('[data-settings-scroll-pane]');
if (id) { scrollTop.current = pane?.scrollTop ?? 0; lastSelection.current = id; }
setSelected(id); setRemoving(null); onSelectionChange?.(id);
requestAnimationFrame(() => {
if (pane) pane.scrollTop = id ? 0 : scrollTop.current;
if (id) rootRef.current?.querySelector<HTMLElement>('[data-plugin-detail]')?.focus({ preventScroll: true });
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 [removing, setRemoving] = useState<string | null>(null);
if (!manager.available) return null;
return <section className="space-y-4" aria-label={t('settings.plugins.manager.installed')}>
<div className="flex items-center justify-between gap-3">
<h3 className="font-medium">{t('settings.plugins.manager.installed')} <span className="text-muted-foreground">{manager.plugins.length}</span></h3>
<Button variant="outline" size="sm" disabled={manager.busy} onClick={() => void manager.importPackage()}><Upload size={14} className="mr-2" />{t('settings.plugins.manager.import')}</Button>
</div>
<p className="text-xs text-muted-foreground">{t('settings.plugins.manager.preview')}</p>
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 && <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>}
{!manager.plugins.length && <div className="rounded-xl border border-dashed border-border p-8 text-center text-sm text-muted-foreground">{t('settings.plugins.manager.empty')}</div>}
{manager.plugins.map((plugin) => <article key={plugin.id} className="space-y-4 rounded-xl border border-border/70 bg-background p-4">
<div className="flex items-center gap-3">
<div className="rounded-lg bg-muted p-2"><Package size={20} /></div>
<button type="button" className="min-w-0 flex-1 text-left" aria-expanded={selected === plugin.id} onClick={() => setSelected(selected === plugin.id ? null : plugin.id)}>
<div className="truncate text-sm font-medium">{typeof plugin.manifest?.displayName === 'string' ? plugin.manifest.displayName : plugin.manifest?.displayName?.[resolvedLocale] ?? plugin.manifest?.displayName?.en ?? plugin.manifest?.name ?? plugin.id}</div>
<div className="text-xs text-muted-foreground">{plugin.activeVersion} · {t(plugin.enabled ? 'settings.plugins.manager.enabled' : 'settings.plugins.manager.disabled')}</div>
<div className={selectedPlugin ? 'space-y-4' : 'grid grid-cols-[repeat(auto-fill,minmax(min(100%,280px),1fr))] gap-3'}>
{visible.map((plugin) => <article key={plugin.id} data-plugin-detail={selectedPlugin ? true : undefined} tabIndex={selectedPlugin ? -1 : undefined} aria-label={nameOf(plugin)} className={`min-w-0 rounded-lg focus:outline-none ${selectedPlugin ? 'space-y-4' : 'plugin-card flex min-h-[200px] flex-col gap-3 bg-card p-4'}`}>
<div className="flex flex-wrap items-center gap-3">
<div className="flex h-12 w-12 shrink-0 items-center justify-center"><PluginBrandIcon plugin={plugin} size={32} /></div>
<button type="button" data-plugin-card={plugin.id} className="min-w-0 flex-1 rounded text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" aria-expanded={selected === plugin.id} onClick={() => choose(selected === plugin.id ? null : plugin.id)}>
<div className="break-words text-sm font-medium">{nameOf(plugin)}</div>
</button>
<Button variant="outline" size="sm" disabled={manager.busy} onClick={() => void manager.setEnabled(plugin.id, !plugin.enabled)}>{t(plugin.enabled ? 'settings.plugins.manager.disable' : 'settings.plugins.manager.enable')}</Button>
<Button variant="ghost" size="sm" type="button"
className={`h-7 shrink-0 rounded-full border-0 px-3 text-xs focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background ${plugin.enabled ? 'bg-primary/10 hover:bg-primary/15' : 'bg-muted/60 text-muted-foreground hover:bg-muted hover:text-foreground'}`}
style={plugin.enabled ? { color: 'color-mix(in srgb, hsl(var(--primary)) 40%, hsl(var(--foreground)))' } : undefined}
aria-pressed={plugin.enabled} aria-busy={manager.busy}
aria-label={`${t(plugin.enabled ? 'settings.plugins.manager.disable' : 'settings.plugins.manager.enable')} ${plugin.manifest?.name ?? plugin.id}`}
title={t(plugin.enabled ? 'settings.plugins.manager.disable' : 'settings.plugins.manager.enable')}
disabled={manager.busy} onClick={() => void manager.setEnabled(plugin.id, !plugin.enabled)}>
{t(plugin.enabled ? 'settings.plugins.manager.enabled' : 'settings.plugins.manager.disabled')}
</Button>
</div>
{plugin.runtime.lastError && <p role="status" className="break-words text-xs text-destructive">{plugin.runtime.lastError}</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">
<p className="break-words text-xs text-muted-foreground">{plugin.activeVersion}{plugin.manifest?.license ? ` · ${plugin.manifest.license}` : ''}</p>
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs" onClick={() => choose(plugin.id)} aria-label={`${t('settings.plugins.manager.details')} ${nameOf(plugin)}`}>{t('settings.plugins.manager.details')}</Button>
</div> : <p className="text-xs text-muted-foreground">{plugin.activeVersion}{plugin.manifest?.license ? ` · ${plugin.manifest.license}` : ''}</p>}
{plugin.runtime.lastError && <p role="status" className="break-words text-xs text-destructive">{t('settings.plugins.manager.lastError')}: {plugin.runtime.lastError}</p>}
{selected === plugin.id && <>
<PluginPackageDetails plugin={plugin} />
{removing === plugin.id ? <div className="space-y-2 rounded-lg border border-destructive/30 p-3">
@@ -68,5 +114,6 @@ export function PluginPackageManager() {
</div> : <Button variant="ghost" size="sm" disabled={manager.busy} onClick={() => setRemoving(plugin.id)}>{t('settings.plugins.manager.remove')}</Button>}
</>}
</article>)}
</div>
</section>;
}
@@ -0,0 +1,14 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import React from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { PluginReadme } from './PluginReadme';
test('README never creates active HTML, remote media or script links', () => {
const html = renderToStaticMarkup(<PluginReadme markdown={'# Example\n\n<script>alert(1)</script>\n\n<img src="https://tracker.example/pixel">\n\n![track](https://tracker.example/x.svg)\n\n[run](javascript:alert%281%29)\n\n[docs](https://example.com)\n\n```html\n<iframe src="file:///secret">\n```'} />);
assert.doesNotMatch(html, /<(script|img|iframe|svg)\b/);
assert.doesNotMatch(html, /href="(javascript|file):/);
assert.match(html, /href="https:\/\/example.com"/);
assert.match(html, /rel="noopener noreferrer"/);
assert.match(html, /&lt;script&gt;/);
});
+37
View File
@@ -0,0 +1,37 @@
import React, { useMemo } from 'react';
import { fromMarkdown } from 'mdast-util-from-markdown';
import type { RootContent, Root } from 'mdast';
function renderNode(node: Root | RootContent, key: number, depth = 0): React.ReactNode {
if (depth > 24) return null;
const children = 'children' in node ? node.children.map((child, index) => renderNode(child as RootContent, index, depth + 1)) : null;
switch (node.type) {
case 'root': return <React.Fragment key={key}>{children}</React.Fragment>;
case 'heading': return <h4 key={key} className="mt-5 font-semibold">{children}</h4>;
case 'paragraph': return <p key={key} className="my-2 leading-relaxed">{children}</p>;
case 'text': return node.value;
case 'strong': return <strong key={key}>{children}</strong>;
case 'emphasis': return <em key={key}>{children}</em>;
case 'inlineCode': return <code key={key} className="rounded bg-muted px-1">{node.value}</code>;
case 'code': return <pre key={key} className="my-3 overflow-auto rounded-lg bg-muted p-3 text-xs"><code>{node.value}</code></pre>;
case 'list': return <ul key={key} className="my-2 list-disc pl-5">{children}</ul>;
case 'listItem': return <li key={key}>{children}</li>;
case 'blockquote': return <blockquote key={key} className="border-l-2 pl-3 text-muted-foreground">{children}</blockquote>;
case 'break': return <br key={key} />;
case 'thematicBreak': return <hr key={key} />;
case 'link': {
let safe = false;
try { const url = new URL(node.url); safe = url.protocol === 'https:' && !url.username && !url.password; } catch { /* relative and unsafe links stay text */ }
return safe ? <a key={key} href={node.url} target="_blank" rel="noopener noreferrer" className="underline">{children}</a> : <span key={key}>{children}</span>;
}
// Never embed HTML, media, SVG, iframes, remote images, or reference URLs.
case 'image': return <span key={key} className="text-muted-foreground">[{node.alt ?? 'image'}]</span>;
case 'html': return <span key={key}>{node.value}</span>;
default: return <React.Fragment key={key}>{children}</React.Fragment>;
}
}
export function PluginReadme({ markdown }: { markdown: string }) {
const tree = useMemo(() => fromMarkdown(markdown.slice(0, 256 * 1024)), [markdown]);
return <div className="break-words text-sm">{renderNode(tree, 0)}</div>;
}
@@ -0,0 +1,43 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import React from 'react';
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
import { I18nProvider } from '../../../application/i18n/I18nProvider';
import { PluginPackageManager } from './PluginPackageManager';
import type { usePluginManager } from '../../../application/state/usePluginManager';
test('state pill exposes actual state, opposite action, focus styling and pending disable without granting permissions', async () => {
const globals = globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean };
const previous = globals.IS_REACT_ACT_ENVIRONMENT; globals.IS_REACT_ACT_ENVIRONMENT = true;
const calls: unknown[] = [];
const plugin = { id: 'com.test.pill', activeVersion: '1.0.0', enabled: true, manifest: { name: 'Pill' }, runtime: {} } as NetcattyInstalledPlugin;
const manager = { available: true, plugins: [plugin], busy: false, setEnabled: async (...args: unknown[]) => { calls.push(args); } } as unknown as ReturnType<typeof usePluginManager>;
let renderer: ReactTestRenderer;
const render = (enabled: boolean, busy = false) => <I18nProvider locale="en"><PluginPackageManager manager={{ ...manager, busy, plugins: [{ ...plugin, enabled }] }} /></I18nProvider>;
try {
await act(async () => { renderer = create(render(true)); });
const pill = () => renderer.root.findAllByType('button').find((node) => node.props['aria-pressed'] !== undefined)!;
assert.equal(pill().props['aria-pressed'], true);
const logoSlot = renderer.root.findAllByType('div').find((node) => node.props.className === 'flex h-12 w-12 shrink-0 items-center justify-center');
assert.ok(logoSlot, 'logo keeps its 48px layout slot without an extra decorative background');
assert.equal(pill().props['aria-label'], 'Disable Pill');
assert.ok(pill().children.includes('Enabled'));
assert.match(pill().props.className, /rounded-full/);
assert.match(pill().props.className, /border-0/);
assert.doesNotMatch(pill().props.className, /border-primary|border-border/);
assert.match(pill().props.className, /bg-primary\/10/);
assert.match(pill().props.className, /focus-visible:ring-2/);
assert.match(pill().props.style.color, /--primary/);
await act(async () => pill().props.onClick());
assert.deepEqual(calls, [['com.test.pill', false]]);
await act(async () => renderer.update(render(false, true)));
assert.equal(pill().props['aria-pressed'], false);
assert.equal(pill().props['aria-label'], 'Enable Pill');
assert.ok(pill().children.includes('Disabled'));
assert.equal(pill().props.disabled, true);
assert.equal(pill().props['aria-busy'], true);
assert.match(pill().props.className, /bg-muted/);
assert.equal(pill().props.style, undefined);
await act(async () => renderer.unmount());
} finally { globals.IS_REACT_ACT_ENVIRONMENT = previous; }
});
+14
View File
@@ -0,0 +1,14 @@
import React from 'react';
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 }) {
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 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 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>;
}
+48 -18
View File
@@ -1,10 +1,15 @@
import { usePluginManager } from '../../../application/state/usePluginManager';
import { PluginManagementDialog } from './PluginManagementDialog';
import { PluginMarketplace } from './PluginMarketplace';
import { PluginPackageManager } from './PluginPackageManager';
import { FolderOpen } from 'lucide-react';
import { PluginStatus } from './PluginStatus';
import React, { useEffect, useMemo, useState } from 'react';
import { normalizePluginKeyboardEvent } from '../../../application/state/pluginKeybindings';
import { usePluginContributions } from '../../../application/state/usePluginContributions';
import { useI18n } from '../../../application/i18n/I18nProvider';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '../../ui/tabs';
import { Button } from '../../ui/button';
import { Input } from '../../ui/input';
import { SettingsAnchor, SettingsTabContent } from '../settings-ui';
@@ -279,8 +284,12 @@ export function PluginSettingField({
);
}
export default function SettingsPluginsTab() {
function PluginManagementContent({ handoff }: { handoff: (action: () => void) => void }) {
const { t } = useI18n();
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 availableFonts = useAvailableFonts();
const scopeCatalog = usePluginSettingScopeCatalog();
const [scopeIds, setScopeIds] = useState<Partial<Record<NetcattyPluginSettingScopeKind, string>>>({});
@@ -293,22 +302,29 @@ export default function SettingsPluginsTab() {
}), [scopeIds]);
const contributions = usePluginContributions(query);
const contextualScopes = useMemo(() => new Set(contributions.snapshot.plugins
.filter((plugin) => plugin.id === selectedInstalled)
.flatMap((plugin) => plugin.settings.map((setting) => setting.scope))
.filter((scope): scope is NetcattyPluginSettingScopeKind => scope !== 'application')),
[contributions.snapshot.plugins]);
const hasVisibleContributions = contributions.snapshot.plugins.some((plugin) => (
plugin.settings.some((setting) => setting.visible)
|| plugin.views.some((view) => view.visible && view.location === 'settings')
));
[contributions.snapshot.plugins, selectedInstalled]);
return (
<SettingsTabContent value="plugins">
<SettingsAnchor anchorId="plugins-root" className="mx-auto w-full max-w-3xl space-y-6 px-8 py-8">
<div>
<h2 className="text-xl font-semibold">{t('settings.plugins.title')}</h2>
<p className="mt-1 text-sm text-muted-foreground">{t('settings.plugins.description')}</p>
<div className="space-y-5">
<Tabs value={pluginView} onValueChange={(value) => { setSelectedInstalled(null); setPluginView(value as 'installed' | 'marketplace'); }} 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>
</TabsList>
<div className="flex items-center gap-2">
{pluginView === 'installed' && <PluginStatus noVault={manager.credentialStorageAvailable === false} />}
<div ref={setCatalogToolbar} className="flex items-center gap-2" />
<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>
<PluginPackageManager />
<TabsContent value="marketplace" className="m-0 space-y-6"><PluginMarketplace installed={manager.plugins} toolbarContainer={catalogToolbar} noVault={manager.credentialStorageAvailable === false} /></TabsContent>
<TabsContent value="installed" className="m-0 space-y-6">
<PluginPackageManager manager={manager} onSelectionChange={setSelectedInstalled} />
{contextualScopes.size > 0 && (
<section className="grid gap-3 rounded-lg border border-border/70 bg-muted/10 p-4 sm:grid-cols-2" aria-label={t('settings.plugins.scopeTargets')}>
{[...contextualScopes].map((kind) => (
@@ -328,7 +344,7 @@ export default function SettingsPluginsTab() {
)}
{contributions.loading && <p className="text-sm text-muted-foreground">{t('settings.plugins.loading')}</p>}
{contributions.error && <p role="alert" className="text-sm text-destructive">{contributions.error.message}</p>}
{contributions.snapshot.plugins.map((plugin) => {
{contributions.snapshot.plugins.filter((plugin) => plugin.id === selectedInstalled).map((plugin) => {
const settings = plugin.settings.filter((setting) => setting.visible);
const views = plugin.views.filter((view) => view.visible && view.location === 'settings');
if (!settings.length && !views.length) return null;
@@ -358,18 +374,32 @@ export default function SettingsPluginsTab() {
<div className="font-mono text-[10px] text-muted-foreground">{view.id}</div>
</div>
</div>
<Button type="button" variant="outline" size="sm" onClick={() => requestOpenPluginView({
<Button type="button" variant="outline" size="sm" onClick={() => handoff(() => requestOpenPluginView({
viewId: view.id,
context: { 'netcatty.surface': 'settings' },
})}>{t('common.open')}</Button>
}))}>{t('settings.plugins.openView')}</Button>
</div>
))}
</section>
);
})}
{!contributions.loading && contributions.available && !hasVisibleContributions && (
<p className="text-sm text-muted-foreground">{t('settings.plugins.empty')}</p>
)}
</TabsContent>
</Tabs>
</div>
);
}
export default function SettingsPluginsTab() {
const { t } = useI18n();
return (
<SettingsTabContent value="plugins">
<SettingsAnchor anchorId="plugins-root" className="space-y-5">
<div>
<h2 className="text-xl font-semibold">{t('settings.plugins.title')}</h2>
<p className="mt-1 text-sm text-muted-foreground">{t('settings.plugins.description')}</p>
</div>
<PluginManagementDialog>{(handoff) => <PluginManagementContent handoff={handoff} />}</PluginManagementDialog>
</SettingsAnchor>
</SettingsTabContent>
);
@@ -0,0 +1,9 @@
.plugin-marketplace-browser { min-width: 0; }
.plugin-marketplace-catalog { min-width: 0; }
.plugin-marketplace-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, 280px), 1fr)); gap: 16px; }
/* Details use only the manager's main scrolling surface at every width. */
.plugin-marketplace-detail { overflow-wrap: anywhere; }
.plugin-card { box-shadow: 0 1px 4px hsl(var(--foreground) / 0.06); }
.plugin-card:hover { box-shadow: 0 2px 8px hsl(var(--foreground) / 0.09); }
.dark .plugin-card { background: hsl(var(--muted) / 0.2); box-shadow: none; }
.dark .plugin-card:hover { background: hsl(var(--muted) / 0.3); box-shadow: none; }
+106
View File
@@ -0,0 +1,106 @@
# Curated plugin catalog
The catalog lives in `electron/plugins/catalog.json` in this repository. There
is no new marketplace account service or external directory repository. It is
shipped with the app; catalog changes become available with an app update.
Normal Settings exposes browser-only management and catalog browsing. Advanced
local packages require `NETCATTY_PLUGIN_DEV=1`; that flag never grants permissions.
Native acceptance and security/distribution review remain required before merge.
## Submit or update a plugin
Open a PR changing one entry. Provide:
- Stable reverse-domain plugin ID, author, name and short description.
- Public GitHub source repository and explicit license matching the package.
- Exact version, full 40-character source commit and relative README `.md` path.
- The complete validated manifest: engine/API ranges, required/optional permissions,
bounded resources, features and data/sync declarations must match package bytes.
- Optional published artifact URL and SHA-256. No artifact means a source-only
example: the UI does not offer installation. Do not invent an artifact URL.
- A concise change log, permission diff, supported OS test results, and synthetic
reproduction instructions. Current public runtime eligibility is browser-only
on the host platforms; native/platform-specific companions are excluded.
Use the local CLI from the repository root:
```sh
npm ci
npm run build:plugin-packages
node packages/plugin-cli/dist/cli.js validate examples/plugins/hello-netcatty
node packages/plugin-cli/dist/cli.js pack examples/plugins/hello-netcatty --out /tmp/hello-netcatty.ncpkg
node packages/plugin-cli/dist/cli.js validate /tmp/hello-netcatty.ncpkg
```
The catalog contains first-party source examples: Plugin Starter for minimal
settings/command use, and Hello Netcatty for terminal provider demonstrations.
No downloadable release is fabricated or published by this change. A maintainer
must verify real artifact provenance before adding the artifact object.
## Enforced install boundary
The renderer sends only a catalog ID. The main process selects the shipped
entry, downloads over HTTPS from approved GitHub hosts with bounded size/time
and redirects, checks SHA-256, and hands a private file to the same serialized
PluginManager and PackageStore used for local imports. The immutable staged
snapshot is hashed again before any version switch. Its exact ID/version,
license and full manifest must match the catalog. Unknown permissions fail
canonical schema validation. Browser-only eligibility rejects Node entrypoints,
companions, raw terminal interceptors and advanced runtime declarations.
The directory is a curated integrity assertion, not a publisher signature.
Unsigned runtime grants remain bound to package digest. No latest fallback and
no background code updates exist. Installing an update stops the old version;
the new version remains disabled until reviewed and explicitly enabled. Required
permissions are mediated again by the existing runtime permission engine.
Local advanced development packages require the developer flag and use the existing permission model;
this does not grant advanced privileges or make Node a sandbox.
README is fetched only from the selected commit, on opening details. Redirects
are refused and responses are limited to 256 KiB. Rendering constructs a small
allowlist of React elements from Markdown; HTML remains escaped text and images
are not fetched. Only explicit HTTPS links can be opened. README content is
untrusted author documentation; permissions come from the manifest.
## Review and maintenance
CI now runs canonical plugin contract drift checks and the runtime suite,
including catalog validation and install-boundary regression tests.
Run `node --test electron/plugins/catalogService.test.cjs` and the plugin
contract/runtime tests for every entry change. Review actual package/source
provenance and permission/resource differences manually on first submission and
sensitive updates. Packaging validation includes traversal/integrity checks but
is not a malware audit. Human review is mandatory before adding an artifact;
there is no automated acceptance bot in this slice.
Authors maintain their plugins, document data handling and respond to security
reports. Maintainers may remove entries or decline updates. Directory checks
are limited checks, not a safety certification, legal waiver or a promise to
repair third-party plugins. No third-party terms are copied here.
## Data and lifecycle
Local KV is per-plugin and does not sync. Only explicitly syncable non-secret
settings enter encrypted sidecars; machine paths, grants, packages, runtime
state and secrets do not become cloud authority. Existing sidecar conflict and
account isolation mechanisms are reused. A generic KV sync API remains a separate
schema/migration decision.
Incompatible host/API/features disable plugins before lazy contributions are
published, retain package/data and record a reason. A compatible downgrade never
automatically re-enables them. Disable stops runtime and contributions; uninstall
removes package code and local KV, while user-owned settings/secrets/sidecars
remain. Existing crash quarantine and generation-bound cancellation apply.
Missing OS-backed key storage blocks secrets/credentials, without blocking
catalog browsing or non-secret plugins. The Settings manager reports this state;
never switch to plaintext storage or disable Chromium sandboxing for a demo.
## When permission prompts appear
Enabled is an availability switch, not a security review or a grant. A lazy
plugin can become enabled without starting code. Its first command/provider use
starts the runtime; required permissions are authorized at that activation
boundary. Optional permissions are requested when a broker operation needs them.
Missing/denied decisions fail closed. Never interpret an Enabled badge as proof
that terminal, credential, network or filesystem access has been approved.
+16 -1
View File
@@ -1,6 +1,6 @@
# Netcatty plugin contract and SDK
Status: internal preview (`0.1.0-internal`)
Status: internal preview (`0.1.0-internal.1`; legacy `0.1.0-internal` remains offered)
Tracking issue: [#2269](https://github.com/binaricat/Netcatty/issues/2269)
Phases 2 and 3 consume this contract in the isolated host runtime and secure
@@ -615,3 +615,18 @@ npm run build:plugin-packages
The complete application checks remain mandatory because workspace and root
dependency changes affect installation and release builds.
## Plugin brand icons (internal revision 0.1.0-internal.1)
Optional top-level `icon` reuses `IconReference` from contributions. A package
using it must require `metadata.brand-icon` and an API range accepting
`0.1.0-internal.1` but excluding `0.1.0-internal`. See
[the author guide](plugin-icons.md) for bounds and packaging commands.
The host still offers the previous wire revision to plugins with no top-level
icon whose range matches only that revision. Compatibility selection returns the
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.
+47
View File
@@ -0,0 +1,47 @@
# Native Electron handoff
Repository: binaricat/Netcatty. Frozen baseline: 0256e7471d66bbbca077c361ef379d7b11ce8465.
Stack: feat/plugin-compatibility-review -> feat/plugin-settings-manager -> feat/plugin-curated-catalog.
Use the final catalog branch for all three slices. Do not merge or release.
1. Check out the branch on the approved desktop. Run npm ci and npm run build.
2. Build the first-party sample:
npm run build:plugin-packages
node packages/plugin-cli/dist/cli.js pack examples/plugins/starter-netcatty --out /tmp/starter-netcatty.ncpkg
node packages/plugin-cli/dist/cli.js validate /tmp/starter-netcatty.ncpkg
3. Launch the actual Electron binary without NETCATTY_PLUGIN_DEV and with a new disposable
--user-data-dir path, keeping Chromium sandbox enabled. Use the desktop's working
display and safe configuration; never --no-sandbox or real profiles/accounts.
4. Settings > Plugins: record Community plugins list and Plugin Starter details.
Open README: actual pinned first-party GitHub retrieval; HTML inert, no remote images.
It intentionally says source-only: no downloadable artifact/release was invented.
5. Import /tmp/starter-netcatty.ncpkg. It must remain disabled. Expand the installed
item: manifest permissions/resources, sensitive markers, Netcatty/API ranges,
SHA-256. Enable allows lazy activation; it does not grant permissions.
First command/view use prompts before runtime creation. Denying must not run code.
6. With approved non-advanced permissions for the first-party example, edit Greeting
and open Starter: Greeting Panel (入门:问候面板) in the command palette.
Click Show greeting (显示问候语) and record the preference rendered in the panel.
No real host, cloud account, terminal credentials, runtime.advanced or third-party code.
Use Starter 0.1.1 or later. Its menus/views permissions support only the declared
command and sandboxed panel; do not expect terminal output.
7. Disable, re-enable, close/restart the app with the same synthetic profile. Confirm
settings persist. Re-import same .ncpkg and verify it stops and stays disabled.
8. Uninstall only the synthetic plugin using the two-step confirmation. Code/local KV
are removed; user-owned settings/secrets/sync sidecars have documented retention.
9. On machines without a usable OS vault, the manager shows a downgrade notice;
secrets remain blocked but browsing and non-secret examples work.
Compatibility upgrade/downgrade, required-feature changes, integrity mismatch,
manifest mismatch, Node rejection, cleanup and traversal have automated synthetic
coverage. Do not claim the above native steps were executed on this executor.
This executor's native launch failed at Chromium's SUID sandbox configuration:
chrome-sandbox is not root-owned mode 4755. No sandbox weakening was attempted.
Native video is pending on the parent task's working desktop.
10. Use the same AI-settings-style subpages: Installed and Community. Search by
name/author/description, open details and return. Verify README error/retry UI
if network is unavailable; do not bypass the network restriction.
11. Inspect human-readable permission name, effect, scope, required/optional and
risk. Enabled means eligible for lazy activation, not permissions granted.
@@ -0,0 +1,54 @@
# Plugin manager window and brand icons
Status: historical design investigation. The manager dialog and the accepted brand-icon design are now implemented. See [the current author guide](plugin-icons.md) for the shipped contract and bounds. The sections below preserve the original investigation and recommendations.
## Manager window
Settings > Plugins keeps an explicit Open plugin manager entry. The existing Radix Dialog opens a modal in the current renderer, up to 1180px wide and 88dvh high. Installed and Community, local import, manifest permissions and README remain inside it. Closing releases the mounted manager hooks; reopening starts at Installed. Detail Back stays inside the manager and preserves the Community search. Escape closes the manager and restores focus to its trigger.
This uses the existing theme, dialog focus handling, plugin host and trusted bridge. A separate Electron BrowserWindow would require another renderer lifecycle, preload/context provisioning and trusted-sender review without providing a needed capability here. No window IPC, plugin runtime or grants are added.
## Existing icon support
- `packages/plugin-contract/schema/plugin-contract.schema.json`: `IconReference` is either a host-owned theme icon or a package icon with `light` and optional `dark` paths. It exists on commands, menus and views. `PluginManifest` has no top-level icon and rejects unknown properties.
- `packages/plugin-cli/src/manifest.ts` validates contribution icon paths. `archive.ts` checks the declared resources during packaging and archive inspection. Missing files and unsafe paths are rejected.
- `electron/plugins/contributionIconService.cjs` checks declaration ownership and current installed version before and after loading. It requires enabled/non-quarantined status, but does not require a running runtime. It reads a verified package root through `pluginProtocol.cjs` containment/realpath checks. Current input/output limits are 512 KiB, source edge 4096px, 16 megapixels, output edge 64px. Formats include PNG/WebP and SVG, JPEG, GIF, ICO.
- `contributionIconRasterizer.cjs` decodes in a disposable sandboxed, network-denied BrowserWindow and returns bounded PNG data. Its SVG inspection is not a general SVG sanitizer; isolation/CSP/rasterization remain essential.
- `application/state/usePluginContributionIcon.ts` discards stale requests. `components/plugins/PluginContributionIcon.tsx` selects light/dark images or a Puzzle fallback.
- `PluginMarketplace.tsx` currently uses the host Package glyph. The catalog has no separately verified logo asset. Installed/detail surfaces do not expose a plugin brand logo.
An author can already add a contribution icon (inside a command/menu/view):
```json
"icon": { "kind": "package", "light": "assets/action.png", "dark": "assets/action-dark.png" }
```
Include both files in the package and run CLI validate/pack. This changes that contribution's icon, not its market listing logo.
## Proposed minimal brand-icon contract
Reuse `IconReference`, adding an **optional** `PluginManifest.icon`; do not invent a URL/logo string format. A package brand icon would look like this after support ships:
```json
"icon": {
"kind": "package",
"light": "assets/logo.png",
"dark": "assets/logo-dark.webp"
}
```
Recommended first version:
- Static PNG or WebP, transparent background if desired; recommend a square 128–256px image. Enforce 256 KiB per source and a 512px maximum edge, validate MIME/signature and decoded dimensions, reject animated input, output bounded raster PNG. A single `light` asset works in both themes; `dark` is optional. Host theme icons can reuse the existing allowlist without package I/O.
- Relative package paths only: no URL, absolute/drive/UNC paths, traversal, backslash, NUL, symlink escape or archive special entries. Do not read arbitrary renderer-supplied paths. Verify exact package hash/version and declaration on each uncached resolution; key caches by package identity/hash/theme, and discard stale results on update/uninstall.
- Old plugins omit the field and continue to work with one shared host fallback. Per `contract-and-sdk.md`, adding this optional field requires an API/schema revision. New packages using it must declare the matching API range and required feature, plus a minimum host version that understands it: older strict schemas will reject an unknown field. Do not promise new-manifest compatibility with older hosts.
- Extend the single contract and regenerate its consumers. Extend CLI path enumeration and both pack/archive checks; enforce image bounds during validation/import, not only in the UI. Reuse contained-file reads and the bounded rasterizer instead of adding file:// or runtime resource access in the application renderer.
- Separate brand-image availability from contribution availability. Verified installed packages may show a logo while disabled without enabling or activating code. Do not loosen the existing contribution-icon enabled gate globally. Share the decoding implementation through a narrow brand-image operation that resolves the active installed declaration itself.
- For uninstalled market entries, do not execute or install a plugin to obtain its logo. Use a small, catalog-reviewed static asset bundled with Netcatty, with its source commit and digest bound to that catalog version; absent assets use the same fallback. In particular the two current source-only examples have no downloadable artifact to extract. A later remote asset transport would need its own bounded allowlist/hash/redirect/cache policy and is outside this first proposal.
- Use one brand-icon component in catalog cards, catalog details and installed lists/details. On decode/read failure keep the fallback and metadata/actions usable; do not fall back to a remote URL. Icons are decorative, with plugin names remaining accessible text.
Do not add brand SVG support initially. Existing contribution SVG support is not removed. SVG brings XML/CSS/external-reference, filter and decoding complexity; supporting it for distribution means retaining isolated offline rasterization, bounded dimensions/time/concurrency and malformed/hostile-source tests. Raster PNG/WebP covers the initial need with less format complexity, though image decoders still require limits and isolation.
## Validation required before implementing brand icons
Contract/CLI tests should cover old manifests, optional fields, missing files, both themes, spoofed MIME, animation, oversized/zero dimensions, archive traversal and symlinks. Host tests should cover disabled installed packages without activation, source-only catalog fallback, stale update/uninstall results and no remote fetch. UI/native checks should cover all three surfaces, broken-image fallback, light/dark themes and accessible names. No new host, filesystem, network or terminal permission should be granted for displaying a verified logo.
+81
View File
@@ -0,0 +1,81 @@
# Add a plugin brand icon
Brand icons appear on Community cards/details and Installed lists/details. They
are optional; plugins without them retain a host-owned fallback. An icon does
not activate code or grant plugin permissions.
## Declare the icon
Use the existing `IconReference` shape at the top level of `netcatty.plugin.json`:
```json
{
"engines": { "netcatty": ">=0.0.0", "api": ">=0.1.0-internal.1 <0.2.0" },
"features": { "required": ["metadata.brand-icon"] },
"icon": {
"kind": "package",
"light": "assets/logo-light.png",
"dark": "assets/logo-dark.webp"
}
}
```
This is a manifest fragment. Preserve the required ID, name, version, publisher,
main and other fields. The host-version value above is for this repository's
unreleased development build (version 0.0.0); use the actual first supporting
Netcatty release as your lower bound when it is assigned. Do not invent a release
number. The API range and required feature enforce support in the meantime.
Alternatively use a host-owned theme glyph, for example
`"icon": { "kind": "theme", "name": "terminal" }`, with the same API/feature
requirements. Theme glyphs use the existing host allowlist; unknown names fall back safely.
## Prepare files
Use a static PNG or WebP. A square transparent 128×128 or 256×256 image is
recommended. Each source must be at most 256 KiB, with both dimensions between
1 and 512 pixels. `dark` is optional; otherwise `light` is used in both themes.
Use lowercase `.png` / `.webp` extensions matching the encoded bytes. Animated
PNG/WebP and SVG are not accepted for brand icons. Existing contribution icons
are unchanged, including their separately isolated SVG support.
Paths are relative to the package root. URLs, absolute paths, traversal,
backslashes and symlinks escaping the package are not supported. Include each
referenced file in the package. No external image URL is loaded as a fallback.
The host performs offline isolated decoding and displays a bounded PNG result;
invalid or unreadable images fall back to the host glyph.
## Validate and package
From this repository, after building your plugin:
```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 compatibility examples/plugins/starter-netcatty --netcatty 0.0.0 --api 0.1.0-internal.1 --features metadata.brand-icon
node packages/plugin-cli/dist/cli.js pack examples/plugins/starter-netcatty --out /tmp/starter-netcatty.ncpkg
```
Directory validation, packaging and imported-archive validation check declared
brand files and bounds. The host reads installed metadata by plugin ID and
verified package identity, not a renderer-provided file path. Disabled packages
can display their icon; quarantined, missing or changed packages fall back. The
existing enabled-only contribution icon service gate is not relaxed.
## Community previews
For an uninstalled plugin, the directory maintainer includes a reviewed raster
preview in the application bundle. The preview binds the plugin ID, version,
repository, source commit and icon declaration. Its bytes and SHA-256 are checked
against the reviewed source files in tests. The current Starter preview uses its
first-party source assets; source-only listings do not pretend to have a release
artifact. Missing or mismatched previews use the same fallback, without fetching
an image from the author's server. Submit the source assets and updated directory
metadata through the existing repository PR review process.
## Compatibility
This is API/schema revision `0.1.0-internal.1`. An older host rejects the unknown
manifest field or missing required feature. The new host continues to offer
`0.1.0-internal` for legacy plugins without the new field, including exact old
API ranges. Existing plugins need no manifest or data migration.
+84
View File
@@ -0,0 +1,84 @@
# Official static distribution client
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.
## Snapshot contract
This client implements the version-1 contract from
`binaricat/netcatty-plugins@7bfe23e5627e16ba63806de6dded5b2667c02984`.
The source catalog/index remain unchanged; **no `readmeAsset` field is added**.
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.
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.
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.
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.
## Bounds, cache and selection
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.
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.
+17 -5
View File
@@ -43,15 +43,17 @@ directory was present at this baseline.
User approved public source plus a clear license for catalog entries, and manual
confirmation of plugin updates. Private local packages remain separate.
Recommended public v1 eligibility: browser runtime only. Utility Node, native
User-approved public v1 eligibility: browser runtime only. Utility Node, native
companions and raw terminal interceptors retain the development preview path.
This excludes advanced provider implementations requiring Node/native binaries;
ordinary browser contributions, broker-backed networking and providers remain
possible. This policy needs product confirmation; no existing advanced path is
removed by this review.
possible. No existing local advanced development path is removed.
Keep `NETCATTY_PLUGIN_DEV=1` until distribution trust, permission UI, compatibility
migration and native acceptance are reviewed. Passing tests is not proof of
After product review, normal Settings now admits browser-only packages. The
`NETCATTY_PLUGIN_DEV=1` flag gates advanced local packages only; their existing
permission prompts remain mandatory. Shared host/UI classification preserves
browser-first placement for dual-entry packages while excluding them from normal
admission. Native acceptance remains pending. Passing tests is not proof of
security isolation. Do not describe utility processes as broker-constrained.
A future KV sync feature needs an explicit manifest opt-in schema, size limits,
@@ -65,3 +67,13 @@ A hash mismatch must fail rather than fall back to latest. README must not execu
HTML or automatically fetch remote media. Directory PR checks are a bounded
review process, not a security certification or a guarantee that maintainers will
repair third-party plugins.
## Validation at the implementation stage
The full repository TypeScript check has 773 diagnostics on both the frozen main
and this implementation. After normalizing worktree paths and line shifts, no
new diagnostics were introduced. This existing debt includes plugin integration
types and should be addressed in a separate API-drift cleanup rather than being
hidden by changing public contracts opportunistically. Canonical contract drift
checking and plugin runtime tests pass for this implementation; native Electron
UI acceptance is tracked separately in `native-acceptance.md`.
+44
View File
@@ -0,0 +1,44 @@
"use strict";
// Presentation only. Admission and grants remain owned by the canonical schema
// and permission engine. Tests require exact coverage of its permission enum.
const PERMISSION_DESCRIPTIONS = {
storage: [false, "Plugin storage", "Save and read this plugin's local data.", "插件存储", "保存和读取此插件自己的本地数据。"],
"runtime.advanced": [true, "Full device access", "Run Node code with filesystem and network access outside broker restrictions. Developer mode only.", "完整设备访问", "运行 Node 代码,可直接访问文件系统和网络,不受代理权限限制。仅限开发者模式。"],
"settings.read": [false, "Read plugin settings", "Read this plugin's configured preferences.", "读取插件设置", "读取此插件自己的设置。"],
"settings.write": [false, "Change plugin settings", "Update this plugin's preferences.", "修改插件设置", "修改此插件自己的设置。"],
commands: [false, "Plugin commands", "Register and run this plugin's own commands.", "插件命令", "注册和执行此插件自己的命令。"],
menus: [false, "Menu entries", "Add this plugin's actions to host menus.", "菜单入口", "将此插件的操作加入应用菜单。"],
views: [false, "Plugin panels", "Display plugin-owned panels inside the app.", "插件面板", "在应用中显示插件自己的面板。"],
"clipboard.read": [true, "Read clipboard", "Read copied text, which may contain passwords or other sensitive data.", "读取剪贴板", "读取复制的文本,其中可能包含密码或其他敏感数据。"],
"clipboard.write": [true, "Write clipboard", "Replace clipboard content that you may paste elsewhere.", "写入剪贴板", "替换剪贴板内容,可能影响你随后粘贴的数据。"],
"terminal.metadata": [true, "Terminal metadata", "Read terminal/session details that may identify hosts and activity.", "终端元数据", "读取终端和会话信息,可能暴露主机及操作活动。"],
"terminal.output": [true, "Read terminal output", "Receive terminal output through supported providers; it may contain credentials or private data.", "读取终端输出", "通过支持的提供器接收终端输出,其中可能包含凭据或隐私信息。"],
"terminal.input": [true, "Send terminal input", "Send input to a terminal; input may execute commands or change a remote host.", "发送终端输入", "向终端发送输入,可能执行命令或更改远程主机。"],
"terminal.decorate": [false, "Terminal presentation", "Provide annotations and visual changes to terminal content.", "终端展示", "为终端内容提供标注和视觉效果。"],
"terminal.complete": [true, "Terminal completion", "Receive completion context and suggest terminal input.", "终端补全", "接收补全上下文并建议终端输入。"],
"terminal.intercept.input": [true, "Rewrite terminal input", "Inspect and modify input before delivery. Not every secret/no-echo input can be detected. Developer mode only.", "改写终端输入", "检查并修改发送前的输入。无法识别所有秘密或无回显输入。仅限开发者模式。"],
"terminal.intercept.output": [true, "Rewrite terminal output", "Inspect and modify output, including information shown as command results. Developer mode only.", "改写终端输出", "检查并修改输出,包括呈现为命令结果的信息。仅限开发者模式。"],
"vault.metadata": [true, "Host inventory", "Read host metadata that can reveal infrastructure details.", "主机清单", "读取主机元数据,可能暴露基础设施信息。"],
"vault.write": [true, "Change host inventory", "Request changes to stored host records.", "修改主机清单", "请求更改保存的主机记录。"],
"vault.credentials": [true, "Use host credentials", "Use approved host credential references through operation-bound leases; this can authenticate to hosts.", "使用主机凭据", "通过绑定操作的租约使用已批准的主机凭据引用,可能用于向主机认证。"],
"sftp.read": [true, "Read remote files", "Read remote file content through supported host APIs.", "读取远程文件", "通过支持的宿主 API 读取远程文件内容。"],
"sftp.write": [true, "Change remote files", "Write or change remote files through supported host APIs.", "修改远程文件", "通过支持的宿主 API 写入或修改远程文件。"],
network: [true, "Network requests", "Contact approved origins. Data sent to them leaves this device.", "网络请求", "访问已批准的网络来源。发送的数据会离开此设备。"],
"filesystem.read": [true, "Read local files", "Read approved local paths, which may contain sensitive files.", "读取本地文件", "读取已批准的本地路径,其中可能包含敏感文件。"],
"filesystem.write": [true, "Change local files", "Write to approved local paths; existing content may be changed.", "修改本地文件", "写入已批准的本地路径,可能改变现有文件内容。"],
secrets: [true, "Plugin secrets", "Store and use this plugin's secrets through OS-backed storage and controlled leases.", "插件秘密", "通过系统密钥存储与受控租约保存和使用此插件的秘密。"],
"companion.execute": [true, "Run native programs", "Launch digest-verified companion executables with device authority. Developer mode only.", "运行本机程序", "启动经过摘要校验的辅助可执行程序,拥有设备权限。仅限开发者模式。"],
"provider.terminal": [true, "Terminal providers", "Provide terminal features; separate permissions govern access to terminal data.", "终端提供器", "提供终端扩展功能;终端数据访问由额外权限控制。"],
"provider.connection": [true, "Connection provider", "Implement a connection backend and handle its session data.", "连接提供器", "实现连接后端并处理对应会话数据。"],
"provider.authentication": [true, "Authentication provider", "Participate in login challenges using approved credential leases.", "认证提供器", "使用已批准的凭据租约参与登录验证。"],
"provider.sync": [true, "Encrypted sync storage", "Store encrypted sync objects; this permission does not provide the master key or plaintext vault.", "加密同步存储", "存储加密同步对象;此权限不提供主密钥或明文凭据库。"],
"provider.importer": [true, "Import provider", "Parse user-selected files into drafts for host review; input files may be sensitive.", "导入提供器", "解析用户选择的文件并生成供宿主确认的草稿;输入文件可能包含敏感数据。"],
};
function describePluginPermission(permission, locale = "en") {
const entry = Object.hasOwn(PERMISSION_DESCRIPTIONS, permission) ? PERMISSION_DESCRIPTIONS[permission] : null;
const chinese = locale.startsWith("zh");
if (!entry) return { known: false, sensitive: true, label: chinese ? "无法识别的权限" : "Unrecognized permission", description: chinese ? "此版本无法识别该权限,拒绝授权。" : "This version does not recognize this permission. Access is denied." };
return { known: true, sensitive: entry[0], label: entry[chinese ? 3 : 1], description: entry[chinese ? 4 : 2] };
}
module.exports = { PERMISSION_DESCRIPTIONS, describePluginPermission };
@@ -0,0 +1,15 @@
const assert = require('node:assert/strict');
const test = require('node:test');
const schema = require('../packages/plugin-contract/schema/plugin-contract.schema.json');
const { PERMISSION_DESCRIPTIONS, describePluginPermission } = require('./pluginPermissionDescriptions.cjs');
test('permission explanation coverage cannot drift from the canonical permission catalog', () => {
assert.deepEqual(Object.keys(PERMISSION_DESCRIPTIONS).sort(), [...schema.$defs.PluginPermission.enum].sort());
for (const id of schema.$defs.PluginPermission.enum) {
for (const locale of ['en', 'zh-CN']) {
const description = describePluginPermission(id, locale);
assert.equal(description.known, true); assert.ok(description.description.length > 5);
}
}
assert.equal(describePluginPermission('unknown.permission').sensitive, true);
assert.equal(describePluginPermission('unknown.permission').known, false);
});
+36
View File
@@ -0,0 +1,36 @@
"use strict";
// 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 describePluginRuntime(manifest) {
const providers = manifest?.contributes?.providers ?? [];
const permissions = [...(manifest?.permissions?.required ?? []), ...(manifest?.permissions?.optional ?? [])];
const permissionIds = permissions.map((entry) => typeof entry === "string" ? entry : entry.permission);
const hasCompanions = Boolean(manifest?.companionExecutables?.length);
const hasInterceptors = providers.some((provider) => (
provider.kind === "terminal.interceptor.input" || provider.kind === "terminal.interceptor.output"
));
const declaresAdvanced = permissionIds.some((permission) => [
"runtime.advanced", "companion.execute", "terminal.intercept.input", "terminal.intercept.output",
].includes(permission));
const hasBrowser = Boolean(manifest?.main?.browser);
const hasNode = Boolean(manifest?.main?.node);
const defaultRuntimeKind = hasBrowser && !hasCompanions && !hasInterceptors ? "browser" : "utility";
return {
defaultRuntimeKind,
hasNode,
declaresAdvanced,
browserOnly: hasBrowser && !hasNode && !hasCompanions && !hasInterceptors && !declaresAdvanced,
};
}
function assertBrowserOnlyPlugin(manifest) {
if (!describePluginRuntime(manifest).browserOnly) {
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 };
+21
View File
@@ -0,0 +1,21 @@
const assert = require('node:assert/strict');
const test = require('node:test');
const { describePluginRuntime, assertBrowserOnlyPlugin } = require('./pluginRuntimePolicy.cjs');
test('dual entrypoints retain browser default placement but require developer admission', () => {
const manifest = { main: { browser: 'browser.js', node: 'node.js' } };
assert.equal(describePluginRuntime(manifest).defaultRuntimeKind, 'browser');
assert.equal(describePluginRuntime(manifest).hasNode, true);
assert.throws(() => assertBrowserOnlyPlugin(manifest), /developer mode/);
});
test('normal release admission rejects advanced declarations and privileged providers', () => {
assert.doesNotThrow(() => assertBrowserOnlyPlugin({ main: { browser: 'index.js' }, permissions: { required: ['settings.read', 'commands'] } }));
for (const permission of ['runtime.advanced', 'companion.execute', 'terminal.intercept.input', 'terminal.intercept.output']) {
for (const kind of ['required', 'optional']) {
assert.throws(() => assertBrowserOnlyPlugin({ main: { browser: 'index.js' }, permissions: { [kind]: [permission] } }), /developer mode/);
}
}
const manifest = { main: { browser: 'index.js', node: 'node.js' }, contributes: { providers: [{ kind: 'terminal.interceptor.input' }] } };
assert.equal(describePluginRuntime(manifest).defaultRuntimeKind, 'utility');
assert.throws(() => assertBrowserOnlyPlugin(manifest), /developer mode/);
});
+2
View File
@@ -68,6 +68,8 @@ module.exports = {
// Renderer and Mosh's main-process bootstrap share the same
// fail-closed prompt classifier at runtime.
'domain/terminalPromptSecurity.shared.cjs',
'domain/pluginRuntimePolicy.cjs',
'domain/pluginPermissionDescriptions.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',
+11 -2
View File
@@ -148,8 +148,8 @@ function createBridgeRegistrar(context) {
} = require("../plugins/pluginBridge.cjs");
let terminalWorkerManager = null;
const { toElectronAccelerator } = require("../plugins/keybindings.cjs");
const { startDevelopmentPluginHost } = require("../plugins/hostBootstrap.cjs");
const pluginHostService = startDevelopmentPluginHost({
const { startPluginHost } = require("../plugins/hostBootstrap.cjs");
const pluginHostService = startPluginHost({
env: process.env,
createService: () => {
const { createPluginHostService } = require("../plugins/hostService.cjs");
@@ -166,8 +166,15 @@ function createBridgeRegistrar(context) {
electron: electronModule,
safeStorage,
requestPermissionDecision: createNativePermissionDecisionProvider({
getLocale: () => getWindowManager().getCurrentLanguage?.() ?? "en",
dialog: electronModule.dialog,
window: win,
getWindow: () => {
const settingsWindow = getWindowManager().getSettingsWindow?.();
return settingsWindow && !settingsWindow.isDestroyed() && settingsWindow.isFocused()
? settingsWindow
: win;
},
}),
getLocale: () => getWindowManager().getCurrentLanguage?.() ?? "en",
requestTerminalInterceptorSelection: async ({ direction, providers }) => {
@@ -210,6 +217,7 @@ function createBridgeRegistrar(context) {
});
registerPluginBridge(ipcMain, {
manager: pluginHostService?.manager,
catalogService: pluginHostService?.catalogService,
contributionService: pluginHostService?.contributionService,
terminalProviderService: pluginHostService?.terminalProviderService,
terminalDataPipelineService: pluginHostService?.terminalDataPipelineService,
@@ -227,6 +235,7 @@ function createBridgeRegistrar(context) {
});
return result.canceled ? null : result.filePaths[0] ?? null;
},
resolveBrandIcon: (pluginId) => pluginHostService?.contributionIconService?.resolveBrand(pluginId),
resolveContributionIcon: (payload) => pluginHostService?.contributionIconService?.resolve(payload),
viewHost: pluginHostService?.viewHost,
env: process.env,
+6
View File
@@ -23,3 +23,9 @@ require("./plugins/runtimeSupervisor.test.cjs");
require("./plugins/shutdownCoordinator.test.cjs");
require("./plugins/streamRouter.test.cjs");
require("./plugins/utilityPluginRuntime.test.cjs");
require('./plugins/brandIconService.test.cjs');
require('./plugins/settingsViewFixture.test.cjs');
require('./plugins/registryCatalogService.test.cjs');
@@ -0,0 +1,38 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs/promises');
const path = require('node:path');
const os = require('node:os');
const { PluginContributionIconService } = require('./contributionIconService.cjs');
async function fixture(t, rasterize) {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'brand-icon-'));
t.after(() => fs.rm(root, { recursive: true, force: true }));
await fs.copyFile(path.join(__dirname, '../..', 'examples/plugins/starter-netcatty/assets/logo-light.png'), path.join(root, 'logo.png'));
let plugin = { id: 'com.test.brand', activeVersion: '1.0.0', archiveSha256: 'a', enabled: false, runtime: {}, manifest: { icon: { kind: 'package', light: 'logo.png' } } };
const service = new PluginContributionIconService({ database: { getActivePlugin: () => plugin }, packageStore: { preparePackageRoot: async () => root }, rasterizeIcon: rasterize ?? (async () => {
const output = Buffer.alloc(24); Buffer.from([137,80,78,71,13,10,26,10]).copy(output); output.write('IHDR', 12); output.writeUInt32BE(32, 16); output.writeUInt32BE(32, 20); return output;
}) });
return { service, plugin, setPlugin: (value) => { plugin = value; } };
}
test('disabled installed branding is readable without runtime activation or changing contribution admission', async (t) => {
const { service, plugin } = await fixture(t);
assert.match((await service.resolveBrand(plugin.id)).light, /^data:image\/png;base64,/);
await assert.rejects(service.resolve({ pluginId: plugin.id, icon: plugin.manifest.icon }), /not declared/);
plugin.manifest.contributes = { views: [{ icon: plugin.manifest.icon }] };
await assert.rejects(service.resolve({ pluginId: plugin.id, icon: plugin.manifest.icon }), /unavailable/);
plugin.runtime.quarantinedAt = 1;
assert.equal(await service.resolveBrand(plugin.id), null);
});
test('brand results cannot survive uninstall or a changed package hash during decoding', async (t) => {
for (const change of ['uninstall', 'hash']) {
let release; let entered;
const started = new Promise((resolve) => { entered = resolve; });
const gate = new Promise((resolve) => { release = resolve; });
const f = await fixture(t, async () => { entered(); await gate; const b = Buffer.alloc(24); Buffer.from([137,80,78,71,13,10,26,10]).copy(b); b.write('IHDR',12); b.writeUInt32BE(32,16); b.writeUInt32BE(32,20); return b; });
const pending = f.service.resolveBrand(f.plugin.id);
await started;
if (change === 'uninstall') f.setPlugin(null); else f.plugin.archiveSha256 = 'b';
release();
await assert.rejects(pending, /ownership changed/);
}
});
+195
View File
@@ -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"
}
]
}
}
}
]
+13
View File
@@ -0,0 +1,13 @@
"use strict";
const { assertBrowserOnlyPlugin } = require("../../domain/pluginRuntimePolicy.cjs");
function assertCatalogManifest(manifest, expected) {
if (manifest.id !== expected.id || manifest.version !== expected.version) {
throw new Error("Catalog package identity/version mismatch");
}
assertBrowserOnlyPlugin(manifest);
if (!manifest.license || manifest.license !== expected.license) throw new Error("Catalog package license mismatch");
}
module.exports = { assertCatalogManifest };
+126
View File
@@ -0,0 +1,126 @@
"use strict";
const { createHash } = require("node:crypto");
const fs = require("node:fs/promises");
const path = require("node:path");
const catalog = require("./catalog.json");
const { getTempFilePath } = require("../bridges/tempDirBridge.cjs");
const { createDefinitionValidator, formatValidationErrors } = require("./contractValidator.cjs");
const validateManifest = createDefinitionValidator("PluginManifest");
const { assertCatalogManifest } = require("./catalogPolicy.cjs");
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 DISTRIBUTION_ORIGIN = "https://plugins.netcatty.app";
function assertDownloadUrl(value) {
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.origin !== DISTRIBUTION_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 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");
}
if (entry.repository.split("/").some((part) => part === "." || part === "..")) {
throw new Error("README source escapes the pinned commit");
}
}
function validateCatalog(entries) {
assertPluginJsonValue(entries, { maxBytes: MAX_CATALOG_BYTES });
if (!Array.isArray(entries) || entries.length > 1000) throw new Error("Invalid plugin catalog");
const ids = new Set();
for (const entry of entries) {
if (!entry || Object.keys(entry).some((key) => !CATALOG_FIELDS.has(key)) || ["id", "version", "name", "description", "author", "repository", "commit", "license", "readme"].some((key) => typeof entry[key] !== "string" || entry[key].length > 4096) || !/^[a-z0-9][a-z0-9.-]+$/u.test(entry.id) || ids.has(entry.id)
|| !/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/u.test(entry.repository)
|| !/^[a-f0-9]{40}$/u.test(entry.commit)
|| !entry.license || !entry.author || !entry.name
|| typeof entry.readme !== "string" || !entry.readme.endsWith(".md")
|| entry.readme.split("/").some((p) => !p || p === "." || p === "..") || /[\\%?#\0]/u.test(entry.readme)) {
throw new Error("Invalid or unpinned plugin catalog 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);
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(asset.url);
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) {
const signal = AbortSignal.timeout(30_000);
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();
throw new Error("Catalog redirects are not allowed");
}
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.fetch = options.fetch ?? fetch;
this.getTempFilePath = options.getTempFilePath ?? getTempFilePath;
this.manager = options.manager;
this.installing = false;
}
list() { return structuredClone(this.entries); }
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 install(id) {
const entry = this.get(id);
if (!entry.artifact) throw new Error("This source example has no published package; build and import locally");
if (this.installing) throw new Error("A catalog installation is already in progress");
this.installing = true;
let directory;
try {
const bytes = await fetchBounded(entry.artifact.url, 32 * 1024 * 1024, this.fetch);
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 });
} finally {
this.installing = false;
if (directory) await fs.rm(directory, { recursive: true, force: true });
}
}
}
module.exports = { PluginCatalogService, assertDownloadUrl, fetchBounded, validateCatalog, assertReadmeSource, MAX_CATALOG_BYTES, DISTRIBUTION_ORIGIN };
+66
View File
@@ -0,0 +1,66 @@
"use strict";
const assert = require("node:assert/strict");
const test = require("node:test");
const { createHash } = require("node:crypto");
const fs = require("node:fs/promises");
const os = require("node:os");
const path = require("node:path");
const { PluginCatalogService, validateCatalog, fetchBounded } = require("./catalogService.cjs");
const { assertCatalogManifest } = require("./catalogPolicy.cjs");
const entries = require("./catalog.json");
test("curated catalog uses pinned public first-party source and rejects advanced manifests", () => {
assert.equal(validateCatalog(entries).length, 2);
for (const main of [{ node: "index.js" }, { browser: "index.js", node: "node.js" }]) {
const entry = structuredClone(entries[0]); entry.manifest.main = main;
assert.throws(() => validateCatalog([entry]), /browser-only packages/);
}
const entry = structuredClone(entries[0]);
entry.manifest.permissions.optional.push("runtime.advanced");
assert.throws(() => assertCatalogManifest(entry.manifest, entry), /advanced developer mode/);
for (const readme of ["../README.md", "a/%2e%2e/README.md", "a\\README.md"]) {
assert.throws(() => validateCatalog([{ ...entries[0], readme }]), /unpinned/);
}
assert.throws(() => validateCatalog([{ ...entries[0], commit: "main" }]), /unpinned/);
});
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://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://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-");
return path.join(root, name);
}, fetch: async () => new Response(bytes), manager: {
async install(archive, options) {
installs++;
assert.equal(path.dirname(path.dirname(archive)), root);
assert.equal((await fs.stat(path.dirname(archive))).mode & 0o777, 0o700);
assert.deepEqual(await fs.readFile(archive), bytes);
assert.equal(options.enable, false);
assert.equal(options.catalog.artifact.sha256, entry.artifact.sha256);
return { id: entry.id };
},
} });
await service.install(entry.id);
assert.equal(installs, 1);
assert.deepEqual(await fs.readdir(root), []);
service.fetch = async () => new Response("changed");
await assert.rejects(service.install(entry.id), /SHA-256 mismatch/);
assert.equal(installs, 1);
await assert.rejects(service.install("unknown"), /curated catalog/);
});
test("catalog rejects unknown permissions instead of presenting an unimplemented grant", () => {
const entry = structuredClone(entries[0]);
entry.manifest.permissions.optional.push("future.unknown");
assert.throws(() => validateCatalog([entry]), /manifest is invalid/);
});
+1 -1
View File
@@ -2,7 +2,7 @@
const PLUGIN_DEV_ENV = "NETCATTY_PLUGIN_DEV";
const PLUGIN_PROTOCOL_SCHEME = "netcatty-plugin";
const PLUGIN_API_VERSION = "0.1.0-internal";
const PLUGIN_API_VERSION = "0.1.0-internal.1";
const PLUGIN_ACTIVATION_TIMEOUT_MS = 5_000;
const PLUGIN_DEACTIVATION_TIMEOUT_MS = 2_000;
const PLUGIN_UTILITY_TERMINATION_GRACE_MS = 500;
+32 -2
View File
@@ -231,12 +231,42 @@ class PluginContributionIconService {
return result;
}
async #readIcon(packageRoot, packagePath) {
// Branding is inert installed metadata. It does not share the enabled-only
// contribution admission path, and accepts no renderer-provided resource path.
async resolveBrand(pluginId) {
if (typeof pluginId !== "string" || pluginId.length > 256) throw new TypeError("Invalid plugin ID");
const plugin = this.database.getActivePlugin(pluginId);
if (!plugin?.activeVersion || plugin.runtime?.quarantinedAt != null) return null;
const icon = plugin.manifest?.icon;
if (icon?.kind !== "package") return null;
const identity = JSON.stringify([plugin.activeVersion, plugin.archiveSha256, icon]);
const key = JSON.stringify(["brand", pluginId, identity]);
if (this.cache.has(key)) return this.cache.get(key);
if (this.pending.has(key)) return this.pending.get(key);
const pending = (async () => {
const root = await this.packageStore.preparePackageRoot(plugin);
const result = { light: await this.#readIcon(root, icon.light, true),
...(icon.dark ? { dark: await this.#readIcon(root, icon.dark, true) } : {}) };
const current = this.database.getActivePlugin(pluginId);
if (!current || current.runtime?.quarantinedAt != null || identity !== JSON.stringify([current.activeVersion, current.archiveSha256, current.manifest?.icon])) {
throw new Error("Plugin brand icon ownership changed while loading");
}
if (this.cache.size >= 256) this.cache.clear();
this.cache.set(key, result);
return result;
})().finally(() => this.pending.delete(key));
this.pending.set(key, pending);
return pending;
}
async #readIcon(packageRoot, packagePath, brand = false) {
const segments = packagePath.split("/");
const file = await readContainedFile(packageRoot, segments);
if (file.body.byteLength > MAX_ICON_SOURCE_BYTES) throw new Error("Plugin contribution icon is too large");
const extension = path.extname(file.filePath).toLowerCase();
const inspected = inspectContributionIconSource(file.body, extension);
const inspected = brand
? (await import("@netcatty/plugin-contract")).inspectPluginBrandIcon(file.body, packagePath)
: inspectContributionIconSource(file.body, extension);
const png = await this.rasterizeIcon({
body: file.body,
...inspected,
+4
View File
@@ -47,6 +47,10 @@ class PluginCredentialCatalog {
}
}
isAvailable() {
try { this.#assertAvailable(); return true; } catch { return false; }
}
update(entries) {
if (!Array.isArray(entries) || entries.length > MAX_CREDENTIAL_CATALOG_ENTRIES) {
throw new PluginRpcError(RPC_ERRORS.invalidArgument, "Vault credential catalog is invalid or too large");
@@ -66,3 +66,9 @@ test("Vault credential catalog fails closed when secure storage is unavailable",
/unavailable|not found/i,
);
});
test("availability reporting does not unlock a missing OS vault", () => {
const catalog = new PluginCredentialCatalog({ safeStorage: { isEncryptionAvailable: () => false } });
assert.equal(catalog.isAvailable(), false);
assert.throws(() => catalog.update([]), /OS-backed Vault/);
});
+6 -4
View File
@@ -614,14 +614,15 @@ class PluginDatabase {
listSyncSidecars(pluginId) {
return this.db.prepare(`
SELECT plugin_id, kind, key, value_json, updated_at
SELECT plugin_id, kind, CAST(key AS BLOB) AS key_bytes, value_json, updated_at
FROM plugin_sync_sidecars
WHERE plugin_id = ?
ORDER BY kind COLLATE BINARY, key COLLATE BINARY
`).all(pluginId).map((row) => ({
pluginId: row.plugin_id,
kind: row.kind,
key: row.key,
// Node 22 truncates TEXT results at NUL; settings keys contain NUL separators.
key: Buffer.from(row.key_bytes).toString("utf8"),
value: parseJson(row.value_json, "sync sidecar value"),
updatedAt: Number(row.updated_at),
}));
@@ -629,13 +630,14 @@ class PluginDatabase {
listAllSyncSidecars() {
return this.db.prepare(`
SELECT plugin_id, kind, key, value_json, updated_at
SELECT plugin_id, kind, CAST(key AS BLOB) AS key_bytes, value_json, updated_at
FROM plugin_sync_sidecars
ORDER BY plugin_id COLLATE BINARY, kind COLLATE BINARY, key COLLATE BINARY
`).all().map((row) => ({
pluginId: row.plugin_id,
kind: row.kind,
key: row.key,
// Node 22 truncates TEXT results at NUL; settings keys contain NUL separators.
key: Buffer.from(row.key_bytes).toString("utf8"),
value: parseJson(row.value_json, "sync sidecar value"),
updatedAt: Number(row.updated_at),
}));
+11
View File
@@ -833,3 +833,14 @@ test("inferPluginIdForSyncProvider never guesses from credential key prefixes",
assert.equal(database.inferPluginIdForSyncProvider("com.missing.sync"), undefined);
database.close();
});
test("sync sidecar enumeration preserves NUL-separated setting coordinates", (context) => {
const database = createDatabase(context);
context.after(() => database.close());
const key = "com.example.test.theme\0application\0application";
database.setSyncSidecar("com.example.test", "settings", key, "dark", 10);
assert.equal(database.listSyncSidecars("com.example.test")[0].key, key);
assert.equal(database.listAllSyncSidecars()[0].key, key);
database.replaceAllSyncSidecars(database.listAllSyncSidecars());
assert.equal(database.getSyncSidecar("com.example.test", "settings", key).value, "dark");
});
+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/"
}
}
}
]
}
}
}
}
}
}
}
}
}
+107
View File
@@ -0,0 +1,107 @@
"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 validateDelivery = ajv.compile(require("./distribution/delivery.schema.json"));
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) {
const bytes = await fetchBounded(`${DISTRIBUTION_ORIGIN}${ref.path}`, ref.size, fetchImpl);
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) {
validateCatalog(entries);
if (!validateIndex(index)) throw new Error(`Invalid distribution index: ${formatValidationErrors(validateIndex.errors)}`);
if (!validateDelivery(delivery)) throw new Error(`Invalid static delivery: ${formatValidationErrors(validateDelivery.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 (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 !== "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"]);
resources.set(key, plugin);
}
if (resources.size !== entries.length) throw new Error("Missing delivery entry for catalog plugin");
return resources;
}
async function fetchDistribution(fetchImpl) {
const pointer = parseJson(await fetchBounded(REGISTRY_URL, 16 * 1024, fetchImpl), "snapshot pointer", 16 * 1024);
assertPointer(pointer);
const documents = await Promise.all(["catalog", "index", "delivery"].map(async (name) => parseJson(await fetchVerified(pointer[name], fetchImpl), name)));
const [entries, index, delivery] = documents;
const resources = validateSet(pointer, entries, index, delivery);
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 };
@@ -1,6 +1,6 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://netcatty.com/schemas/plugins/0.1.0-internal/plugin-contract.schema.json",
"$id": "https://netcatty.com/schemas/plugins/0.1.0-internal.1/plugin-contract.schema.json",
"title": "Netcatty Plugin Contract",
"description": "Canonical public contract for Netcatty plugin manifests and host RPC messages.",
"$ref": "#/$defs/PluginManifest",
@@ -2949,6 +2949,9 @@
"type": "string",
"maxLength": 128
},
"icon": {
"$ref": "#/$defs/IconReference"
},
"homepage": {
"type": "string",
"format": "uri"
+2 -5
View File
@@ -1,9 +1,6 @@
"use strict";
const { isPluginDevelopmentEnabled } = require("./constants.cjs");
function startDevelopmentPluginHost(options) {
if (!isPluginDevelopmentEnabled(options.env)) return null;
function startPluginHost(options) {
const reportFailure = (message, error) => {
try { options.logger?.error?.(message, error); } catch {}
};
@@ -30,4 +27,4 @@ function startDevelopmentPluginHost(options) {
}
}
module.exports = { startDevelopmentPluginHost };
module.exports = { startPluginHost };
+11 -11
View File
@@ -3,24 +3,24 @@
const assert = require("node:assert/strict");
const test = require("node:test");
const { startDevelopmentPluginHost } = require("./hostBootstrap.cjs");
const { startPluginHost } = require("./hostBootstrap.cjs");
test("disabled development host does not construct plugin infrastructure", () => {
test("normal release constructs the browser plugin host without a developer flag", async () => {
let created = 0;
const service = startDevelopmentPluginHost({
const service = { manager: { async initialize() {}, async shutdown() {} } };
assert.equal(startPluginHost({
env: {},
createService() { created += 1; },
createService() { created += 1; return service; },
registerShutdown() {},
logger: { error() {} },
});
assert.equal(service, null);
assert.equal(created, 0);
}), service);
assert.equal(created, 1);
});
test("synchronous plugin host construction failure leaves Netcatty available", () => {
const errors = [];
let registered = 0;
const service = startDevelopmentPluginHost({
const service = startPluginHost({
env: { NETCATTY_PLUGIN_DEV: "1" },
createService() { throw new Error("corrupt plugin database"); },
registerShutdown() { registered += 1; },
@@ -41,7 +41,7 @@ test("created plugin host initializes and registers one shutdown owner", async (
async shutdown() { shutdown += 1; },
},
};
assert.equal(startDevelopmentPluginHost({
assert.equal(startPluginHost({
env: { NETCATTY_PLUGIN_DEV: "1" },
createService: () => service,
registerShutdown(handler) { shutdownHandler = handler; },
@@ -63,7 +63,7 @@ test("asynchronous initialization failure closes and leaves the host unavailable
shutdown: async () => { shutdown += 1; },
},
};
assert.equal(startDevelopmentPluginHost({
assert.equal(startPluginHost({
env: { NETCATTY_PLUGIN_DEV: "1" },
createService: () => service,
registerShutdown(handler) { shutdownHandler = handler; },
@@ -79,7 +79,7 @@ test("asynchronous initialization failure closes and leaves the host unavailable
test("failure while registering shutdown closes the constructed host", async () => {
let shutdown = 0;
const errors = [];
const service = startDevelopmentPluginHost({
const service = startPluginHost({
env: { NETCATTY_PLUGIN_DEV: "1" },
createService: () => ({
manager: {
+10 -3
View File
@@ -2,7 +2,8 @@
const path = require("node:path");
const { PLUGIN_API_VERSION } = require("./constants.cjs");
const { assertBrowserOnlyPlugin } = require("../../domain/pluginRuntimePolicy.cjs");
const { isPluginDevelopmentEnabled, PLUGIN_API_VERSION } = require("./constants.cjs");
const { PluginDatabase } = require("./database.cjs");
const { PluginCompanionSupervisor } = require("./companionSupervisor.cjs");
const { PluginCredentialBroker, assertLeaseParams } = require("./credentialBroker.cjs");
@@ -16,6 +17,7 @@ const {
createUtilityModuleMappings,
normalizePluginModuleResources,
} = require("./moduleResources.cjs");
const { RegistryCatalogService } = require("./registryCatalogService.cjs");
const { PackageStore } = require("./packageStore.cjs");
const { createPluginPaths } = require("./paths.cjs");
const { PluginManager } = require("./pluginManager.cjs");
@@ -78,17 +80,19 @@ function collectInstalledSyncProviderCatalog(database) {
}
function createPluginHostService(options) {
const allowAdvancedPlugins = isPluginDevelopmentEnabled(options.env ?? process.env);
const paths = createPluginPaths(options.app.getPath("userData"));
const appRoot = options.appRoot ?? options.app.getAppPath();
const runtimeDirectory = options.runtimeDirectory ?? path.join(__dirname, "runtime");
const database = new PluginDatabase(paths.database);
try {
const packageStore = new PackageStore({
allowAdvancedPlugins,
paths,
database,
netcattyVersion: options.app.getVersion(),
apiVersion: PLUGIN_API_VERSION,
supportedFeatures: options.supportedFeatures ?? [],
supportedFeatures: [...new Set(["metadata.brand-icon", ...(options.supportedFeatures ?? [])])],
});
const contributionIconService = new PluginContributionIconService({
database,
@@ -178,11 +182,13 @@ function createPluginHostService(options) {
}
const requestedRuntimeResolver = options.resolveRuntimeKind ?? resolveDefaultRuntimeKind;
const resolveRuntimeKind = async (context) => {
if (!allowAdvancedPlugins) assertBrowserOnlyPlugin(context.plugin.manifest);
const kind = await requestedRuntimeResolver(context);
if (
(kind !== "browser" && kind !== "utility")
|| !context.availableKinds.includes(kind)
) throw new Error(`Plugin runtime selection is unavailable: ${String(kind)}`);
if (!allowAdvancedPlugins && kind !== "browser") throw new Error("Advanced plugin runtime requires developer mode");
await permissionEngine.authorizeRequired(context.plugin, {
securityPrincipal: context.securityPrincipal,
signal: context.signal,
@@ -217,7 +223,7 @@ function createPluginHostService(options) {
paths,
netcattyVersion: options.app.getVersion(),
apiVersion: PLUGIN_API_VERSION,
supportedFeatures: options.supportedFeatures ?? [],
supportedFeatures: [...new Set(["metadata.brand-icon", ...(options.supportedFeatures ?? [])])],
runtimeDirectory,
appRoot,
rpcRegistry,
@@ -363,6 +369,7 @@ function createPluginHostService(options) {
},
});
return {
catalogService: new RegistryCatalogService({ manager, fetch: options.electron.net?.fetch?.bind(options.electron.net) }),
companionSupervisor,
contributionIconService,
contributionService,
+15
View File
@@ -16,6 +16,7 @@ function createOptions(root, configureRpcRegistry) {
getPath: () => root,
getVersion: () => "0.0.0",
},
env: { NETCATTY_PLUGIN_DEV: "1" },
electron: {},
configureRpcRegistry,
};
@@ -386,3 +387,17 @@ test("custom host methods without an explicit authorization classification are d
/no authorization policy/,
);
});
test("normal release blocks a custom utility placement before permission prompts", async (context) => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-plugin-normal-mode-"));
context.after(() => fs.rmSync(root, { recursive: true, force: true }));
let prompts = 0;
const service = createPluginHostService({ ...createOptions(root), env: {}, resolveRuntimeKind: () => "utility", requestPermissionDecision: () => { prompts++; } });
context.after(() => service.database.close());
await assert.rejects(service.runtimeSupervisor.resolveRuntimeKind({
plugin: { id: "com.example.safe", manifest: { main: { browser: "index.js" } } },
availableKinds: ["browser", "utility"],
signal: new AbortController().signal,
}), /requires developer mode/);
assert.equal(prompts, 0);
});
+10 -3
View File
@@ -1,5 +1,7 @@
"use strict";
const { describePluginPermission } = require("../../domain/pluginPermissionDescriptions.cjs");
const MAX_VISIBLE_RESOURCES = 20;
const MAX_VISIBLE_FIELD_LENGTH = 1_024;
const UNSAFE_DISPLAY_CHARACTERS = /[\u0000-\u001f\u007f-\u009f\u2028\u2029\u202a-\u202e\u2066-\u2069]/gu;
@@ -14,7 +16,8 @@ function escapePermissionText(value, maxLength = MAX_VISIBLE_FIELD_LENGTH) {
});
}
function describePermissionRequest(request) {
function describePermissionRequest(request, locale = "en") {
const permission = describePluginPermission(request.permission, locale);
const identity = request.pluginName
? `${escapePermissionText(request.pluginName)} (${escapePermissionText(request.pluginId)})`
: escapePermissionText(request.pluginId);
@@ -31,6 +34,7 @@ function describePermissionRequest(request) {
`Plugin: ${identity}`,
...(request.publisher ? [`Publisher: ${escapePermissionText(request.publisher)}`] : []),
`Permission: ${escapePermissionText(request.permission)}`,
`${permission.label}: ${permission.description}`,
`Reason: ${escapePermissionText(request.reason)}`,
...(visibleResources.length ? ["Resources:", ...visibleResources] : []),
].join("\n");
@@ -41,9 +45,9 @@ function createNativePermissionDecisionProvider(options) {
throw new TypeError("Native plugin permission decisions require an Electron dialog");
}
const dialog = options.dialog;
const window = options.window;
return async (request, context = {}) => {
context.signal?.throwIfAborted();
if (!describePluginPermission(request.permission).known) return Object.freeze({ requestId: request.requestId, decision: "deny" });
const allowedScopes = Array.isArray(request.allowedScopes)
? new Set(request.allowedScopes)
: null;
@@ -65,13 +69,16 @@ function createNativePermissionDecisionProvider(options) {
type: "warning",
title: "Plugin permission request",
message: `${escapePermissionText(request.pluginName ?? request.pluginId)} requests ${escapePermissionText(request.permission)}`,
detail: describePermissionRequest(request),
detail: describePermissionRequest(request, options.getLocale?.() ?? "en"),
buttons: choices.map(({ label }) => label),
defaultId: 0,
cancelId: 0,
noLink: true,
...(context.signal ? { signal: context.signal } : {}),
};
// Choose a host-owned parent when the prompt opens, not once at bootstrap.
const candidate = options.getWindow?.() ?? options.window;
const window = candidate?.isDestroyed?.() ? undefined : candidate;
const result = window
? await dialog.showMessageBox(window, messageBoxOptions)
: await dialog.showMessageBox(messageBoxOptions);
@@ -131,3 +131,36 @@ test("native permission dialogs visibly escape control and bidi characters", asy
assert.equal(captured.detail.split("\n").filter((line) => line.startsWith("Permission:")).length, 1);
assert.equal(escapePermissionText("x".repeat(MAX_VISIBLE_FIELD_LENGTH + 20)).length, MAX_VISIBLE_FIELD_LENGTH);
});
test("native prompts share human-readable permission explanations and deny unknown permission IDs", async () => {
assert.match(describePermissionRequest(permissionRequest({ permission: "terminal.output" }), "zh-CN"), /读取终端输出/);
assert.match(describePermissionRequest(permissionRequest({ permission: "terminal.output" })), /credentials or private data/);
let prompts = 0;
const provider = createNativePermissionDecisionProvider({ dialog: { showMessageBox: async () => { prompts++; return { response: 1 }; } } });
assert.deepEqual(await provider(permissionRequest({ permission: "unknown.permission" })), { requestId: "request-1", decision: "deny" });
assert.equal(prompts, 0);
});
test("permission parent is resolved at prompt time without forcing focus", async () => {
const main = { name: "main" };
const settings = { name: "settings", isDestroyed: () => false };
let active = main;
const parents = [];
const provider = createNativePermissionDecisionProvider({
window: main,
getWindow: () => active,
dialog: { async showMessageBox(...args) {
parents.push(args.length === 2 ? args[0] : null);
return { response: 0 };
} },
});
await provider(permissionRequest());
active = settings;
await provider(permissionRequest());
active = { isDestroyed: () => true };
await provider(permissionRequest());
active = null;
await provider(permissionRequest());
assert.deepEqual(parents, [main, settings, null, main]);
});
+4 -1
View File
@@ -157,7 +157,10 @@ test("network response streaming stops above the byte cap", async () => {
);
});
test("network broker reports its own timeout as a stable deadline error", async () => {
test("network broker reports its own timeout as a stable deadline error", async (context) => {
// A real request owns a socket; this pending fake must also keep the loop alive.
const keepAlive = setInterval(() => {}, 1_000);
context.after(() => clearInterval(keepAlive));
const broker = new PluginNetworkBroker({
fetch: async (_url, options) => new Promise((_resolve, reject) => {
options.signal.addEventListener("abort", () => reject(options.signal.reason), { once: true });
+22 -4
View File
@@ -21,6 +21,9 @@ const {
resolveInstalledVersionDirectory,
} = require("./paths.cjs");
const { assertBrowserOnlyPlugin } = require("../../domain/pluginRuntimePolicy.cjs");
const { assertCatalogManifest } = require("./catalogPolicy.cjs");
const INSTALL_METADATA_FILE = "install.json";
const ARCHIVE_SNAPSHOT_FILE = "package.ncpkg";
const PACKAGE_DIRECTORY = "package";
@@ -136,6 +139,7 @@ function validateRemovalMetadata(value) {
class PackageStore {
constructor(options) {
this.allowAdvancedPlugins = options.allowAdvancedPlugins !== false;
this.paths = options.paths;
this.database = options.database;
this.netcattyVersion = options.netcattyVersion;
@@ -156,7 +160,7 @@ class PackageStore {
for (const plugin of this.database.listPlugins()) {
if (!plugin.enabled) continue;
await this.assertCompatible(plugin).catch((error) => {
if (error.code !== "PLUGIN_INCOMPATIBLE") throw error;
if (!["PLUGIN_INCOMPATIBLE", "PLUGIN_UNSUPPORTED_RUNTIME"].includes(error.code)) throw error;
});
}
}
@@ -168,9 +172,15 @@ class PackageStore {
apiVersion: this.apiVersion,
features: this.supportedFeatures,
});
if (result.compatible) return result;
const error = new Error(`Plugin is incompatible: ${result.errors.join("; ")}`);
error.code = "PLUGIN_INCOMPATIBLE";
let error;
if (!this.allowAdvancedPlugins) {
try { assertBrowserOnlyPlugin(plugin.manifest); } catch (cause) { error = cause; }
}
if (!error && result.compatible) return result;
if (!error) {
error = new Error(`Plugin is incompatible: ${result.errors.join("; ")}`);
error.code = "PLUGIN_INCOMPATIBLE";
}
const current = this.database.getActivePlugin(plugin.id);
if (current?.activeVersion === plugin.activeVersion) {
this.database.setEnabled(plugin.id, false);
@@ -204,6 +214,7 @@ class PackageStore {
const validation = await pluginCli.extractPluginPackage(archiveSnapshot, extractedDirectory);
await syncDirectoryTree(extractedDirectory);
const manifest = validation.manifest;
if (!this.allowAdvancedPlugins) assertBrowserOnlyPlugin(manifest);
const compatibility = pluginCli.checkPluginCompatibility(manifest, {
netcattyVersion: this.netcattyVersion,
apiVersion: this.apiVersion,
@@ -216,6 +227,13 @@ class PackageStore {
const version = assertPluginStorageSegment(manifest.version, "version");
const targetDirectory = resolveInstalledVersionDirectory(this.paths, pluginId, version);
const previousPlugin = this.database.getActivePlugin(pluginId);
if (options.catalog) {
if (snapshot.sha256 !== options.catalog.artifact.sha256) throw new Error("Catalog package SHA-256 mismatch");
assertCatalogManifest(manifest, options.catalog);
// Detail-page permissions and engine ranges must describe these exact bytes.
const { isDeepStrictEqual } = require("node:util");
if (!isDeepStrictEqual(manifest, options.catalog.manifest)) throw new Error("Catalog manifest does not match package");
}
const enableAfterActivation = options.enable === false
? false
: options.enable === true || previousPlugin?.enabled === true;
+35
View File
@@ -421,3 +421,38 @@ test("explicit disabled import never auto-enables an update of an enabled plugin
assert.equal(installed.activeVersion, "1.1.0");
assert.equal(installed.enabled, false);
});
test("catalog installation verifies actual package manifest and rejects Node before activation", async (context) => {
const fixture = createStore(context);
await fixture.store.initialize();
const archive = await createPackage(fixture.root, { manifest: { license: "MIT" } });
const sha256 = createHash("sha256").update(await fs.promises.readFile(archive.archive)).digest("hex");
const entry = { id: archive.manifest.id, version: archive.manifest.version, license: "MIT", manifest: archive.manifest, artifact: { sha256 } };
await assert.rejects(fixture.store.install(archive.archive, { catalog: { ...entry, version: "9.0.0" } }), /identity\/version/);
await assert.rejects(fixture.store.install(archive.archive, { catalog: { ...entry, artifact: { sha256: "0".repeat(64) } } }), /SHA-256/);
await assert.rejects(fixture.store.install(archive.archive, { catalog: { ...entry, manifest: { ...archive.manifest, description: "different" } } }), /manifest does not match/);
assert.equal(fixture.database.listPlugins().length, 0);
assert.equal((await fixture.store.install(archive.archive, { enable: false, catalog: entry })).enabled, false);
const advanced = await createPackage(fixture.root, { manifest: { version: "2.0.0", license: "MIT", main: { node: "dist/index.js" }, permissions: { required: ["runtime.advanced"] } } });
const advancedEntry = { ...entry, version: "2.0.0", manifest: advanced.manifest, artifact: { sha256: createHash("sha256").update(await fs.promises.readFile(advanced.archive)).digest("hex") } };
await assert.rejects(fixture.store.install(advanced.archive, { catalog: advancedEntry }), /browser-only packages/);
assert.equal(fixture.database.getActivePlugin(entry.id).activeVersion, "1.0.0");
});
test("normal release refuses advanced local imports and disables previously installed lazy packages", async (context) => {
const fixture = createStore(context);
await fixture.store.initialize();
const archive = await createPackage(fixture.root, { manifest: {
main: { browser: "dist/index.js", node: "dist/index.js" },
permissions: { optional: ["runtime.advanced"] },
} });
const installed = await fixture.store.install(archive.archive, { enable: true });
fixture.database.setValue(installed.id, "synthetic", 42);
fixture.store.allowAdvancedPlugins = false;
await fixture.store.initialize();
assert.equal(fixture.database.getActivePlugin(installed.id).enabled, false);
assert.match(fixture.database.getActivePlugin(installed.id).runtime.lastError, /developer mode/);
assert.equal(fixture.database.getValue(installed.id, "synthetic"), 42);
await assert.rejects(fixture.store.install(archive.archive), /browser-only packages/);
await assert.rejects(fixture.store.assertCompatible(installed), /browser-only packages/);
});
+7 -4
View File
@@ -12,13 +12,17 @@ test("packaged application declares every plugin host runtime dependency and res
"@netcatty/plugin-contract",
"@netcatty/plugin-sdk",
]) {
assert.equal(packageJson.dependencies[dependency], "0.1.0-internal");
const workspace = JSON.parse(fs.readFileSync(path.join(__dirname, "../../packages", dependency.split("/")[1], "package.json"), "utf8"));
assert.equal(packageJson.dependencies[dependency], workspace.version);
assert.equal(workspace.version, require("./constants.cjs").PLUGIN_API_VERSION);
}
const builderSource = fs.readFileSync(path.join(__dirname, "../../electron-builder.config.cjs"), "utf8");
assert.match(builderSource, /node_modules\/@netcatty\/plugin-cli\/\*\*\/\*/);
assert.match(builderSource, /node_modules\/@netcatty\/plugin-contract\/\*\*\/\*/);
assert.match(builderSource, /node_modules\/@netcatty\/plugin-sdk\/\*\*\/\*/);
assert.match(builderSource, /electron\/plugins\/runtime\/\*\*\/\*/);
assert.match(builderSource, /domain\/pluginRuntimePolicy\.cjs/);
assert.match(builderSource, /domain\/pluginPermissionDescriptions\.cjs/);
assert.match(builderSource, /!electron\/plugins\/fixtures\/\*\*\/\*/);
});
@@ -35,7 +39,6 @@ test("clean-checkout test entrypoints build plugin runtime workspaces first", ()
packageJson.scripts.pretest,
/^npm run build:plugin-runtime-deps(?: && npm run check:codebuddy-sdk-contract)?$/,
);
for (const lifecycle of ["pretest:plugin-runtime", "pretest:plugin-runtime:electron"]) {
assert.equal(packageJson.scripts[lifecycle], "npm run build:plugin-runtime-deps");
}
assert.equal(packageJson.scripts["pretest:plugin-runtime"], "npm run build:plugin-runtime-deps && npm run build --workspace @netcatty/example-starter-plugin");
assert.equal(packageJson.scripts["pretest:plugin-runtime:electron"], "npm run build:plugin-runtime-deps");
});
+24 -7
View File
@@ -3,7 +3,6 @@
const fs = require("node:fs/promises");
const { randomUUID } = require("node:crypto");
const { isPluginDevelopmentEnabled } = require("./constants.cjs");
const { raceWithAbort } = require("./rpcRouter.cjs");
const {
INLINE_SYNC_OBJECT_SAFE_BYTES,
@@ -15,11 +14,15 @@ const { assertSecretKey } = require("./secretStore.cjs");
const CHANNELS = Object.freeze({
status: "netcatty:plugins:status",
list: "netcatty:plugins:list",
catalog: "netcatty:plugins:catalog",
catalogReadme: "netcatty:plugins:catalog-readme",
catalogInstall: "netcatty:plugins:catalog-install",
install: "netcatty:plugins:install",
setEnabled: "netcatty:plugins:set-enabled",
restart: "netcatty:plugins:restart",
uninstall: "netcatty:plugins:uninstall",
contributions: "netcatty:plugins:contributions",
brandIcon: "netcatty:plugins:brand-icon",
contributionIcon: "netcatty:plugins:contribution-icon",
contributionsChanged: "netcatty:plugins:contributions-changed",
executeCommand: "netcatty:plugins:execute-command",
@@ -209,7 +212,6 @@ function registerPluginBridge(ipcMain, options) {
const getTerminalWorkerManager = options.getTerminalWorkerManager ?? (() => null);
const selectImporterFile = options.selectImporterFile;
const viewHost = options.viewHost;
const env = options.env ?? process.env;
const isTrustedSender = options.isTrustedSender;
const defaultScopeCatalog = normalizePluginScopeCatalog({ device: [{ id: "device", label: "This device" }] });
const scopeCatalogs = new Map();
@@ -251,15 +253,14 @@ function registerPluginBridge(ipcMain, options) {
if (scopeCatalogs.delete(key)) publishScopeCatalog();
});
};
const configured = isPluginDevelopmentEnabled(env)
&& Boolean(manager)
const configured = Boolean(manager)
&& typeof manager.initialize === "function";
const resolveManager = async () => {
if (!configured) throw new Error("Plugin development runtime is disabled or unavailable");
if (!configured) throw new Error("Plugin runtime is disabled or unavailable");
try {
await manager.initialize();
} catch (cause) {
throw new Error("Plugin development runtime is disabled or unavailable", { cause });
throw new Error("Plugin runtime is disabled or unavailable", { cause });
}
return manager;
};
@@ -641,7 +642,19 @@ function registerPluginBridge(ipcMain, options) {
await resolveManager();
available = true;
} catch {}
return { available, experimental: true };
return { available, experimental: true,
...(typeof credentialResolver?.isAvailable === "function"
? { credentialStorageAvailable: credentialResolver.isAvailable() } : {}),
};
});
handle(CHANNELS.catalog, async () => options.catalogService?.list() ?? { 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);
});
handle(CHANNELS.catalogInstall, async (_manager, payload) => {
if (!options.catalogService) throw new Error("Plugin catalog unavailable");
return options.catalogService.install(payload?.id, payload?.selectionToken);
});
handle(CHANNELS.list, async (activeManager) => activeManager.list());
handle(CHANNELS.install, async (activeManager, payload) => activeManager.install(
@@ -658,6 +671,10 @@ function registerPluginBridge(ipcMain, options) {
if (!contributionService) throw new Error("Plugin contributions are unavailable");
return contributionService.snapshot(payload ?? {});
});
handle(CHANNELS.brandIcon, async (_activeManager, payload) => {
if (!options.resolveBrandIcon) throw new Error("Plugin brand icons are unavailable");
return options.resolveBrandIcon(payload?.pluginId);
});
handle(CHANNELS.contributionIcon, async (_activeManager, payload) => {
if (!options.resolveContributionIcon) throw new Error("Plugin contribution icons are unavailable");
return options.resolveContributionIcon(payload);
+36
View File
@@ -1599,3 +1599,39 @@ test("syncDeleteSecrets uses retained provider binding when contribution is miss
assert.deepEqual(deleted, [["com.example", "prefix:sync-credential"]]);
assert.deepEqual(unbound, [["com.example", "com.example.sync"]]);
});
test("missing OS vault does not block curated browsing and status exposes the limitation", async () => {
const ipcMain = createIpcMain();
let selected;
registerPluginBridge(ipcMain, {
manager: { async initialize() {} },
env: { NETCATTY_PLUGIN_DEV: "1" },
isTrustedSender: (event) => event.trusted === true,
credentialResolver: { isAvailable: () => false },
catalogService: {
list: () => [{ id: "com.example.synthetic" }],
install: async (id) => { selected = id; return { id }; },
},
});
const event = { trusted: true };
assert.deepEqual(await ipcMain.handlers.get(CHANNELS.status)(event), {
available: true, experimental: true, credentialStorageAvailable: false,
});
assert.equal((await ipcMain.handlers.get(CHANNELS.catalog)(event)).length, 1);
await assert.rejects(ipcMain.handlers.get(CHANNELS.catalog)({ trusted: false }), /Untrusted/);
await ipcMain.handlers.get(CHANNELS.catalogInstall)(event, { id: "com.example.synthetic", url: "https://attacker.invalid", enable: true });
assert.equal(selected, "com.example.synthetic");
});
test('brand metadata bridge accepts only trusted UI and forwards only the plugin ID', async () => {
const ipcMain = createIpcMain(); const seen = [];
registerPluginBridge(ipcMain, {
manager: { initialize: async () => {} },
isTrustedSender: (event) => event.trusted === true,
resolveBrandIcon: async (...args) => { seen.push(args); return { light: 'data:image/png;base64,fixture' }; },
});
await assert.rejects(ipcMain.handlers.get(CHANNELS.brandIcon)({ trusted: false }, { pluginId: 'com.test.brand' }), /Untrusted/);
assert.equal(seen.length, 0);
await ipcMain.handlers.get(CHANNELS.brandIcon)({ trusted: true }, { pluginId: 'com.test.brand', path: '/secret', url: 'https://attacker.invalid/' });
assert.deepEqual(seen, [['com.test.brand']]);
});
+1
View File
@@ -53,6 +53,7 @@ class PluginManager {
try {
plugin = await this.packageStore.install(archivePath, {
enable: options?.enable,
catalog: options?.catalog,
beforeActivate: async ({ pluginId, previousPlugin }) => {
if (!previousPlugin?.enabled) return;
this.database.setEnabled(pluginId, false);
@@ -0,0 +1,73 @@
"use strict";
const { createHash } = require("node:crypto");
const { PluginCatalogService, validateCatalog, MAX_CATALOG_BYTES } = require("./catalogService.cjs");
const { REGISTRY_URL, fetchDistribution, fetchVerified, tuple } = require("./distributionSnapshot.cjs");
const tokenFor = (entry) => createHash("sha256").update(JSON.stringify(entry)).digest("hex");
function prepareSnapshot(value, resources = new Map()) {
const entries = structuredClone(validateCatalog(value));
const publicEntries = structuredClone(entries).map((entry) => ({ ...entry, selectionToken: tokenFor([entry, resources.get(tuple(entry)) ?? null]) }));
return { entries, publicEntries, resources: structuredClone(resources) };
}
class RegistryCatalogService extends PluginCatalogService {
constructor(options) {
super(options);
this.publicEntries = prepareSnapshot(this.entries).publicEntries;
this.resources = new Map();
this.source = "builtin";
this.error = null;
this.refreshing = null;
}
async refresh() {
if (this.refreshing) return this.refreshing;
this.refreshing = (async () => {
try {
// No redirects: only this exact registry endpoint can replace the catalog.
const distribution = await fetchDistribution(this.fetch);
const next = prepareSnapshot(distribution.entries, distribution.resources);
// Publish only after full validation, clones and every token succeeded.
this.entries = next.entries;
this.publicEntries = next.publicEntries;
this.resources = next.resources;
this.source = "registry";
this.error = null;
return true;
} catch (error) {
if (this.source === "registry") this.source = "cached";
this.error = String(error.message ?? error).slice(0, 1024);
return false;
}
})();
try { return await this.refreshing; } finally { this.refreshing = null; }
}
async list() {
await this.refresh();
return {
entries: structuredClone(this.publicEntries),
source: this.source,
error: this.error,
};
}
selected(id, token) {
const entry = this.get(id);
if (typeof token !== "string" || token !== this.publicEntries.find((item) => item.id === id)?.selectionToken) {
throw new Error("Catalog entry changed. Refresh and review this version before continuing.");
}
return entry;
}
async readme(id, token) {
const entry = this.selected(id, token);
const resource = this.resources.get(tuple(entry))?.readme;
if (!resource) throw new Error("No published README snapshot is available. Refresh the catalog or open its source link.");
return (await fetchVerified(resource, this.fetch)).toString("utf8");
}
async install(id, token) {
this.selected(id, token);
if (!await this.refresh()) throw new Error("Registry refresh failed. Cached catalogs are browse-only; retry when online.");
this.selected(id, token);
return super.install(id);
}
}
module.exports = { RegistryCatalogService, REGISTRY_URL, MAX_CATALOG_BYTES };
@@ -0,0 +1,93 @@
"use strict";
const assert = require("node:assert/strict");
const test = require("node:test");
const { RegistryCatalogService, REGISTRY_URL } = require("./registryCatalogService.cjs");
const { catalog, realFiles, makeFiles, responseFor } = require("./fixtures/staticDistributionFixture.cjs");
test("registry starts at a fixed anonymous pointer and retains only complete validated snapshots", async () => {
let files = realFiles();
const service = new RegistryCatalogService({ fetch: async (url, options) => {
assert.equal(new URL(url).origin, "https://plugins.netcatty.app");
assert.equal(options.credentials, "omit"); assert.equal(options.redirect, "manual");
assert.equal(options.referrerPolicy, "no-referrer"); assert.ok(options.signal);
return responseFor(files, url);
} });
const good = await service.list();
assert.equal(good.source, "registry"); assert.equal(good.entries.length, 2);
assert.match(good.entries[0].selectionToken, /^[a-f0-9]{64}$/);
for (const invalid of ["{", "{}", JSON.stringify({ schemaVersion: 2 }), '{"schemaVersion":1e999}', " ".repeat(16 * 1024 + 1)]) {
files = realFiles(); files.set("/v1/snapshot.json", Buffer.from(invalid));
const cached = await service.list();
assert.equal(cached.source, "cached"); assert.ok(cached.error);
assert.deepEqual(cached.entries, good.entries);
}
files = makeFiles([]);
assert.deepEqual((await service.list()).entries, []);
});
test("offline first launch returns bundled source examples with no GitHub README fallback", async () => {
let calls = 0;
const service = new RegistryCatalogService({ fetch: async (url) => { calls++; assert.equal(url, REGISTRY_URL); throw new Error("offline"); }, manager: {
install() { assert.fail("offline must not install"); },
} });
const snapshot = await service.list();
assert.equal(snapshot.source, "builtin"); assert.equal(snapshot.entries.length, 2);
await assert.rejects(service.readme(snapshot.entries[0].id, snapshot.entries[0].selectionToken), /No published README/);
assert.equal(calls, 1);
await assert.rejects(service.install(snapshot.entries[0].id, snapshot.entries[0].selectionToken), /browse-only/);
});
test("selection binds source and delivery metadata; changes and withdrawal cannot silently install", async () => {
let files = realFiles(); const requests = [];
const service = new RegistryCatalogService({ fetch: async (url) => { requests.push(url); return responseFor(files, url); }, manager: { install() { assert.fail("source-only must not install"); } } });
const selected = (await service.list()).entries[0];
assert.match(await service.readme(selected.id, selected.selectionToken), /Starter/);
await assert.rejects(service.readme(selected.id, "bad"), /changed/);
const before = requests.length;
await assert.rejects(service.install(selected.id), /changed/); assert.equal(requests.length, before);
await assert.rejects(service.install(selected.id, selected.selectionToken), /no published package/);
const changed = structuredClone(catalog); changed[0].description += " changed";
files = makeFiles(changed);
await assert.rejects(service.install(selected.id, selected.selectionToken), /changed/);
await assert.rejects(service.readme(selected.id, selected.selectionToken), /changed/);
files = makeFiles([]);
assert.deepEqual((await service.list()).entries, []);
await assert.rejects(service.install(selected.id, selected.selectionToken), /curated catalog/);
});
test("concurrent list refreshes share one pointer request and complete document set", async () => {
let finish; const requests = []; const files = realFiles();
const service = new RegistryCatalogService({ fetch: async (url) => { requests.push(url); if (url === REGISTRY_URL) return new Promise((resolve) => { finish = resolve; }); return responseFor(files, url); } });
const first = service.list(); const second = service.list();
finish(responseFor(files, REGISTRY_URL));
assert.deepEqual(await first, await second); assert.equal(requests.length, 4);
});
test("invalid sources and unknown JSON fields cannot poison good offline snapshots", async () => {
let files = realFiles(); let offline = false;
const service = new RegistryCatalogService({ fetch: async (url) => { if (offline) throw new Error("offline"); return responseFor(files, url); } });
const good = await service.list();
let nested = null; for (let i = 0; i < 130; i++) nested = { child: nested };
const bad = [
{ ...catalog[0], readme: "\t../main/README.md" }, { ...catalog[0], repository: "example/.." },
{ ...catalog[0], unknownNestedField: nested }, { ...catalog[0], unknownField: true },
{ ...catalog[0], readmeAsset: { url: "https://plugins.netcatty.app/a", sha256: "a".repeat(64) } },
];
for (const entry of bad) {
files = makeFiles([entry]); offline = false;
const rejected = await service.list(); assert.equal(rejected.source, "cached"); assert.ok(rejected.error);
assert.deepEqual(rejected.entries, good.entries);
offline = true; assert.deepEqual((await service.list()).entries, good.entries);
}
});
test("snapshot preparation failure leaves private resources and public tokens unchanged", async () => {
const service = new RegistryCatalogService({ fetch: async (url) => responseFor(realFiles(), url) });
const good = await service.list(); const resources = service.resources;
const originalClone = global.structuredClone;
try { global.structuredClone = () => { throw new Error("synthetic clone failure"); }; assert.equal(await service.refresh(), false); }
finally { global.structuredClone = originalClone; }
assert.equal(service.resources, resources);
service.fetch = async () => { throw new Error("offline"); };
assert.deepEqual((await service.list()).entries, good.entries);
});
+5 -10
View File
@@ -1,5 +1,7 @@
"use strict";
const { describePluginRuntime } = require("../../domain/pluginRuntimePolicy.cjs");
const { randomUUID } = require("node:crypto");
const path = require("node:path");
const { pathToFileURL } = require("node:url");
@@ -43,14 +45,7 @@ function freezeJson(value) {
}
function resolveDefaultRuntimeKind({ plugin }) {
const requiresPrivilegedTerminalPipeline = plugin.manifest.contributes?.providers?.some((provider) => (
provider.kind === "terminal.interceptor.input"
|| provider.kind === "terminal.interceptor.output"
));
if (plugin.manifest.companionExecutables?.length || requiresPrivilegedTerminalPipeline) {
return "utility";
}
return plugin.manifest.main.browser ? "browser" : "utility";
return describePluginRuntime(plugin.manifest).defaultRuntimeKind;
}
class RuntimeSupervisor {
@@ -356,7 +351,7 @@ class RuntimeSupervisor {
pluginId,
pluginVersion: plugin.activeVersion,
netcattyVersion: this.netcattyVersion,
apiVersion: this.apiVersion,
apiVersion: compatibility.apiVersion,
supportedFeatures: this.supportedFeatures,
enabledFeatures: compatibility.enabledFeatures,
environment,
@@ -367,7 +362,7 @@ class RuntimeSupervisor {
if (
initialized.pluginId !== pluginId
|| initialized.pluginVersion !== plugin.activeVersion
|| initialized.apiVersion !== this.apiVersion
|| initialized.apiVersion !== compatibility.apiVersion
|| JSON.stringify([...initialized.enabledFeatures].sort())
!== JSON.stringify([...compatibility.enabledFeatures].sort())
) {
@@ -951,3 +951,19 @@ test("a stale runtime crash cannot quarantine or overwrite a replacement version
assert.equal(priorVersion.runtime.status, "error");
assert.match(priorVersion.runtime.lastError, /stale process crashed/);
});
test('new host initializes an exact legacy API plugin with its negotiated legacy revision', async (context) => {
const observed = [];
const fixture = createFixture(context, () => ({
async start(config) { observed.push(config.apiVersion); return { pluginId: config.pluginId, pluginVersion: config.pluginVersion, apiVersion: config.apiVersion, enabledFeatures: config.enabledFeatures }; },
async stop() {},
}), { apiVersion: '0.1.0-internal.1', supportedFeatures: ['metadata.brand-icon'] });
fixture.database.installVersion({
pluginId: fixture.manifest.id, version: fixture.manifest.version,
manifest: { ...fixture.manifest, engines: { netcatty: '>=0.0.0', api: '0.1.0-internal' } },
archiveSha256: 'a'.repeat(64), packageRelativePath: `${fixture.manifest.id}/${fixture.manifest.version}/package`,
}, { enable: true });
await fixture.supervisor.start(fixture.manifest.id);
assert.deepEqual(observed, ['0.1.0-internal']);
await fixture.supervisor.stop(fixture.manifest.id);
});
@@ -0,0 +1,32 @@
const assert = require('node:assert/strict');
const test = require('node:test');
const { readFile } = require('node:fs/promises');
const { resolve } = require('node:path');
const { pathToFileURL } = require('node:url');
const { MessageChannel } = require('node:worker_threads');
const { PluginRpcRouter } = require('./rpcRouter.cjs');
test('actual settings-view fixture default export initializes and activates through the runtime peer', async (t) => {
const root = resolve(__dirname, '../../examples/plugins/settings-view-fixture');
const manifest = JSON.parse(await readFile(resolve(root, 'netcatty.plugin.json'), 'utf8'));
assert.deepEqual(manifest.permissions.required, ['views']);
assert.equal(manifest.contributes.views[0].location, 'settings');
const html = await readFile(resolve(root, 'view.html'), 'utf8');
assert.doesNotMatch(html, /<script|https?:\/\/|<iframe/i);
const { startPluginRuntime } = await import('./runtime/runtimePeer.mjs');
const { port1, port2 } = new MessageChannel();
t.after(() => { port1.close(); port2.close(); });
const host = new PluginRpcRouter({ pluginId: manifest.id, send: (message) => port1.postMessage(message) });
t.after(() => host.close());
port1.on('message', (message) => host.accept(message));
const peer = await startPluginRuntime({
port: port2,
config: { pluginId: manifest.id, pluginVersion: manifest.version, netcattyVersion: '0.0.0', apiVersion: '0.1.0-internal', enabledFeatures: [] },
loadPlugin: () => import(pathToFileURL(resolve(root, manifest.main.browser)).href),
});
t.after(() => peer.dispose());
const initialized = await host.request('plugin.initialize', { netcattyVersion: '0.0.0', apiVersion: '0.1.0-internal', supportedFeatures: [] });
assert.equal(initialized.pluginId, manifest.id);
await host.request('plugin.activate', {});
await host.request('plugin.deactivate', {});
});
+42
View File
@@ -0,0 +1,42 @@
"use strict";
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const vm = require("node:vm");
const { pathToFileURL } = require("node:url");
const test = require("node:test");
test("Starter panel renders the actual command result as text and reports denial", async () => {
const root = path.resolve(__dirname, "../../examples/plugins/starter-netcatty");
const plugin = (await import(pathToFileURL(path.join(root, "dist/index.js")).href)).default;
let command;
let receive;
let click;
const elements = new Map(["greet", "result", "title", "description"].map((id) => [id, {
textContent: "", disabled: false, addEventListener(_event, callback) { click = callback; },
}]));
const greeting = '<img src=x onerror="alert(1)"> synthetic hello';
await plugin.activate({
subscriptions: { add() {} },
commands: { registerCommand(id, handler) { assert.equal(id, "com.netcatty.starter.greet"); command = handler; } },
settings: { get: async () => greeting },
views: { postMessage: (_id, message) => receive?.(message) },
});
let deny = false;
vm.runInNewContext(fs.readFileSync(path.join(root, "panel.js"), "utf8"), {
document: { getElementById: (id) => elements.get(id), documentElement: { style: {} } },
window: { netcattyView: {
onDidChangeEnvironment: (listener) => listener({ locale: "en", theme: "light" }),
onDidReceiveMessage: (listener) => { receive = listener; },
executeCommand: async (id) => { assert.equal(id, "com.netcatty.starter.greet"); if (deny) throw new Error("Denied"); return command(); },
} },
});
await click();
assert.equal(elements.get("result").textContent, greeting);
assert.equal(elements.get("greet").disabled, false);
deny = true;
await click();
assert.match(elements.get("result").textContent, /Check permission approval/);
assert.equal(elements.get("greet").disabled, false);
});
@@ -0,0 +1,106 @@
"use strict";
const assert = require("node:assert/strict");
const test = require("node:test");
const { assertDownloadUrl, fetchBounded, validateCatalog } = require("./catalogService.cjs");
const { RegistryCatalogService, REGISTRY_URL } = require("./registryCatalogService.cjs");
const { fetchVerified, assertAsset } = require("./distributionSnapshot.cjs");
const { read, digest, catalog, delivery, realFiles, makeFiles, responseFor } = require("./fixtures/staticDistributionFixture.cjs");
test("exact registry PR3 bootstrap hashes and all README/icon assets verify without GitHub requests", async () => {
assert.equal(digest(read("snapshot.json")), "2878885057a01585e34897c226bcfd81615345d843ea94297980de7e397b4617");
assert.equal(digest(read("delivery.json")), "53e5189d2cb58a02826931d5d6321ad3e556b924ef80d38636dc9e66a7df8c55");
const requests = []; const files = realFiles();
const fetch = async (url, options) => {
requests.push(url); assert.equal(new URL(url).origin, "https://plugins.netcatty.app");
assert.equal(options.credentials, "omit"); assert.equal(options.redirect, "manual");
return responseFor(files, url);
};
const service = new RegistryCatalogService({ fetch });
const snapshot = await service.list();
assert.equal(snapshot.error, null); assert.equal(snapshot.source, "registry");
assert.equal(requests.length, 4); assert.equal(requests[0], REGISTRY_URL);
for (const entry of snapshot.entries) {
const resource = delivery.plugins.find((item) => item.id === entry.id);
assert.equal(await service.readme(entry.id, entry.selectionToken), read(resource.readme.path.slice(1)).toString());
for (const icon of [resource.iconLight, resource.iconDark]) if (icon) {
assertAsset(icon, ["png", "webp"]);
const bytes = await fetchVerified(icon, fetch);
assert.equal(bytes.length, icon.size); assert.equal(digest(bytes), icon.sha256);
}
assert.equal(entry.artifact, undefined);
assert.equal(resource.avatar, null); assert.equal(resource.artifact, null);
}
assert.ok(requests.every((url) => !/github/u.test(url)));
});
test("GitHub, tracking and noncanonical URLs are rejected before any upstream request", async () => {
for (const url of [
"https://api.github.com/repos/a/b", "https://raw.githubusercontent.com/a/b/main/README.md",
"https://avatars.githubusercontent.com/u/1", "https://github.com/a/b/releases/download/v1/a.ncpkg",
"https://release-assets.githubusercontent.com/a", "https://objects.githubusercontent.com/a",
"https://plugins.netcatty.app.attacker.invalid/a", "https://plugins.netcatty.app@attacker.invalid/a",
"http://plugins.netcatty.app/a", "https://plugins.netcatty.app/a?track=1", "https://plugins.netcatty.app/a#fragment",
"https://plugins.netcatty.app/a/../b", "https://plugins.netcatty.app/%2e%2e/b", "https://plugins.netcatty.app/\ta",
]) {
assert.throws(() => assertDownloadUrl(url));
assert.throws(() => validateCatalog([{ ...catalog[0], artifact: { url, sha256: "a".repeat(64) } }]));
await assert.rejects(fetchBounded(url, 1024, () => assert.fail("invalid source must not be requested")));
}
});
test("official responses never redirect to GitHub or another official path", async () => {
for (const location of ["https://github.com/a", "https://raw.githubusercontent.com/a/b/main/catalog.json", "https://plugins.netcatty.app/another.json"]) {
const requests = [];
await assert.rejects(fetchBounded(REGISTRY_URL, 1024, async (url) => {
requests.push(url); return new Response(null, { status: 302, headers: { location } });
}), /redirects/);
assert.deepEqual(requests, [REGISTRY_URL]);
}
});
test("cross-document mismatches, withdrawn versions and unsupported assets preserve the complete good cache", async () => {
let files = realFiles(); const service = new RegistryCatalogService({ fetch: async (url) => responseFor(files, url) });
const good = await service.list(); const goodResources = service.resources;
const mutations = [
(index, sidecar) => { sidecar.registryCommit = "a".repeat(40); },
(index, sidecar) => { sidecar.plugins[0].commit = "a".repeat(40); },
(index, sidecar) => { sidecar.plugins.push(sidecar.plugins[0]); },
(index, sidecar) => { sidecar.plugins.pop(); },
(index, sidecar) => { sidecar.plugins[0].readme.path = "https://raw.githubusercontent.com/a/b/main/README.md"; },
(index, sidecar) => { sidecar.plugins[0].readme.path = `/assets/${sidecar.plugins[0].readme.sha256}.png`; },
(index, sidecar) => { sidecar.plugins[0].iconLight.path = `/assets/${sidecar.plugins[0].iconLight.sha256}.md`; },
(index, sidecar) => { sidecar.plugins[0].avatar = { path: "/assets/avatar.png" }; },
(index, sidecar) => { sidecar.plugins[0].artifact = { path: "/assets/package.ncpkg" }; },
(index) => { index.plugins[0].versions[0].withdrawal = { at: "2026-10-26T00:00:00Z", reason: "synthetic withdrawal", advisory: "https://github.com/example/advisory" }; },
(index) => { index.plugins[0].versions[0].entry.description += " mismatch"; },
(index) => { index.plugins[0].current = "9.0.0"; },
(index) => { index.plugins.push(index.plugins[0]); },
(index, sidecar) => { sidecar.unknown = true; },
];
for (const mutate of mutations) {
files = makeFiles(catalog, mutate);
// Preserve the pointer's trusted registry identity when testing delivery mismatch.
const pointer = JSON.parse(files.get("/v1/snapshot.json")); pointer.registryCommit = delivery.registryCommit;
files.set("/v1/snapshot.json", Buffer.from(JSON.stringify(pointer)));
const rejected = await service.list();
assert.equal(rejected.source, "cached"); assert.ok(rejected.error);
assert.deepEqual(rejected.entries, good.entries); assert.equal(service.resources, goodResources);
}
});
test("pointer/document/resource byte mismatches and partial availability never publish partial state", async () => {
let files = realFiles(); const service = new RegistryCatalogService({ fetch: async (url) => responseFor(files, url) });
const good = await service.list();
const pointer = JSON.parse(read("snapshot.json"));
for (const name of ["catalog", "index", "delivery"]) {
files = realFiles(); files.set(pointer[name].path, Buffer.from("{}"));
const rejected = await service.list(); assert.equal(rejected.source, "cached"); assert.deepEqual(rejected.entries, good.entries);
files = realFiles(); files.delete(pointer[name].path);
assert.deepEqual((await service.list()).entries, good.entries);
}
files = realFiles(); const altered = structuredClone(pointer); altered.catalog.path = "/catalog.json";
files.set("/v1/snapshot.json", Buffer.from(JSON.stringify(altered)));
assert.match((await service.list()).error, /resource reference/);
files = realFiles(); files.set(delivery.plugins[0].readme.path, Buffer.from("x".repeat(delivery.plugins[0].readme.size)));
await assert.rejects(service.readme(good.entries[0].id, good.entries[0].selectionToken), /SHA-256 mismatch/);
});
+4
View File
@@ -99,6 +99,9 @@ function createPreloadApi(ctx) {
with (ctx) {
return {
getPluginRuntimeStatus: () => ipcRenderer.invoke("netcatty:plugins:status"),
listPluginCatalog: () => ipcRenderer.invoke("netcatty:plugins:catalog"),
getPluginCatalogReadme: (id, selectionToken) => ipcRenderer.invoke("netcatty:plugins:catalog-readme", { id, selectionToken }),
installCatalogPlugin: (id, selectionToken) => ipcRenderer.invoke("netcatty:plugins:catalog-install", { id, selectionToken }),
listPlugins: () => ipcRenderer.invoke("netcatty:plugins:list"),
installPluginPackage: (archivePath, options) => ipcRenderer.invoke("netcatty:plugins:install", {
archivePath,
@@ -111,6 +114,7 @@ function createPreloadApi(ctx) {
restartPlugin: (pluginId) => ipcRenderer.invoke("netcatty:plugins:restart", { pluginId }),
uninstallPlugin: (pluginId) => ipcRenderer.invoke("netcatty:plugins:uninstall", { pluginId }),
getPluginContributions: (options) => ipcRenderer.invoke("netcatty:plugins:contributions", options ?? {}),
getPluginBrandIcon: (pluginId) => ipcRenderer.invoke("netcatty:plugins:brand-icon", { pluginId }),
getPluginContributionIcon: (pluginId, icon) => ipcRenderer.invoke("netcatty:plugins:contribution-icon", { pluginId, icon }),
executePluginCommand: (command, args, context) => ipcRenderer.invoke("netcatty:plugins:execute-command", {
command,
+4
View File
@@ -6,6 +6,10 @@ import reactHooks from "eslint-plugin-react-hooks";
import globals from "globals";
export default [
{
files: ["examples/plugins/starter-netcatty/panel.js"],
languageOptions: { globals: globals.browser },
},
// The recommended preset has no file scope of its own, so scope it off all of
// electron/ — that main-process tree is historically unlinted. The bridges
// get a focused rule set in the dedicated block at the end of this config;
+7 -3
View File
@@ -1,7 +1,6 @@
# Hello Netcatty
This package is the runnable internal example for the plugin API. With
`NETCATTY_PLUGIN_DEV=1`, package and install it to exercise native settings,
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.
@@ -24,7 +23,7 @@ terminal input/output streams.
## Import and inspect
Launch a development build with `NETCATTY_PLUGIN_DEV=1`, using a fresh Electron
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
@@ -47,3 +46,8 @@ 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.
+1 -1
View File
@@ -7,6 +7,6 @@
"build": "tsc -p tsconfig.json"
},
"dependencies": {
"@netcatty/plugin-sdk": "0.1.0-internal"
"@netcatty/plugin-sdk": "0.1.0-internal.1"
}
}
@@ -0,0 +1,23 @@
# Settings view handoff fixture
First-party synthetic native QA fixture, compatible with the manager-dialog
baseline `614b7c997a75afff2c64b796a9f11c3eec804b43`. This is not a marketplace
release. It declares only `views`, has an empty browser activation function and
one static `location: settings` HTML panel. It has no commands, host, terminal,
network, filesystem, secret or sync operation. Declarations are not grants.
```sh
node packages/plugin-cli/dist/cli.js validate examples/plugins/settings-view-fixture
node packages/plugin-cli/dist/cli.js pack examples/plugins/settings-view-fixture --out /tmp/settings-view-fixture.ncpkg
```
In a synthetic Electron profile, import disabled, inspect the manifest, then
Enable. In Installed, find **Static Settings View** and click its **Open** button
(the command palette is not this regression path). Review the normal permission
prompt; use only a specifically approved temporary grant. Denying must not open
a view. If allowed, the plugin manager must close before the external Settings
view appears. Verify its host-provided title/close button is clickable and can
receive focus; close it, then reopen plugin management. Do not use existing
Starter modal permission approval as authorization for this separate fixture.
Version 0.1.1 corrects the default-export entrypoint contract. It retains the same sole `views` declaration and static HTML. Version 0.1.0 failed before activation and must not be used for successful native acceptance.
@@ -0,0 +1 @@
export default { activate() {} };
@@ -0,0 +1,21 @@
{
"manifestVersion": 1,
"id": "com.netcatty.settings-view-fixture",
"name": "settings-view-fixture",
"displayName": { "en": "Settings View QA", "zh-CN": "设置视图验收" },
"version": "0.1.1",
"publisher": "netcatty",
"license": "GPL-3.0-or-later",
"engines": { "netcatty": ">=0.0.0", "api": ">=0.1.0-internal <0.2.0" },
"main": { "browser": "index.js" },
"activationEvents": ["onView:com.netcatty.settings-view-fixture.panel"],
"permissions": { "required": ["views"] },
"contributes": {
"views": [{
"id": "com.netcatty.settings-view-fixture.panel",
"title": { "en": "Static Settings View", "zh-CN": "静态设置视图" },
"location": "settings",
"entry": "view.html"
}]
}
}
@@ -0,0 +1,3 @@
<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Static Settings View</title></head>
<body><h1>Settings view handoff / 设置视图交接</h1><p>This first-party static panel has no script, external resource or host data.</p><p>Close this view with Netcatty's header button. The manager should already be dismissed.</p></body></html>
Loaded 100 of 129 files, more files were not shown because too many files have changed in this diff. Show more