mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-10-29 18:11:40 +00:00
Compare commits
41
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d0e2c905cb | ||
|
|
c11cde9bb2 | ||
|
|
2838f90b03 | ||
|
|
494470810a | ||
|
|
bf91894ab5 | ||
|
|
b39f7a3244 | ||
|
|
e5bd294cdd | ||
|
|
b48567d448 | ||
|
|
933bc93fc3 | ||
|
|
45ed193579 | ||
|
|
f471a17a75 | ||
|
|
b9293a7bb5 | ||
|
|
257660bcc1 | ||
|
|
eaf8c9a385 | ||
|
|
ff42d0c1b9 | ||
|
|
0adb7b822e | ||
|
|
311765eaa1 | ||
|
|
d2a8398b7a | ||
|
|
69307cee16 | ||
|
|
4445c292ea | ||
|
|
6fad272afe | ||
|
|
a7f8480da1 | ||
|
|
e7e57f2a52 | ||
|
|
ab2f95837e | ||
|
|
f3d04a1912 | ||
|
|
f4a6b205ad | ||
|
|
614b7c997a | ||
|
|
aaef0d2ee1 | ||
|
|
5128b51702 | ||
|
|
f5c951f9a1 | ||
|
|
8de33d9b5a | ||
|
|
c727cca643 | ||
|
|
ba4fab5679 | ||
|
|
27e5a10bfc | ||
|
|
3efabf2b0f | ||
|
|
74a2f99b08 | ||
|
|
80525d9b2d | ||
|
|
741ea4e9c7 | ||
|
|
4336f8fd53 | ||
|
|
aa8989c515 | ||
|
|
d5fd56ecdf |
No files matched your search
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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}.',
|
||||
|
||||
@@ -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}.',
|
||||
|
||||
@@ -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} 上下文中打开此设置。',
|
||||
|
||||
@@ -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': '請按下組合鍵',
|
||||
|
||||
@@ -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());
|
||||
});
|
||||
@@ -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); }
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -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, /<script>/);
|
||||
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\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, /<script>/);
|
||||
});
|
||||
@@ -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; }
|
||||
});
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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; }
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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`.
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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 };
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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/);
|
||||
}
|
||||
});
|
||||
@@ -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,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 };
|
||||
@@ -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 };
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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),
|
||||
}));
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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/"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
+53
@@ -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.
|
||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 873 B |
+37
@@ -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).
|
||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 873 B |
@@ -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"
|
||||
|
||||
@@ -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 };
|
||||
@@ -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: {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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]);
|
||||
});
|
||||
@@ -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 });
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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']]);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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', {});
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
@@ -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
Reference in new issue
Block a user