mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-10-30 02:21:47 +00:00
Compare commits
26
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9b853130ca | ||
|
|
e3cc23bca8 | ||
|
|
91b8927c56 | ||
|
|
f868221b5f | ||
|
|
fd45463d98 | ||
|
|
fdeeaf74bb | ||
|
|
ffabb9e9c7 | ||
|
|
733f44cfdc | ||
|
|
a8adeb86fe | ||
|
|
0c097a1260 | ||
|
|
5c9286775e | ||
|
|
618257e593 | ||
|
|
cde2be236e | ||
|
|
342941b618 | ||
|
|
8e4dd0c052 | ||
|
|
3a96cd2537 | ||
|
|
0a692dfc72 | ||
|
|
1d369bf322 | ||
|
|
0d77a2e56c | ||
|
|
5f06fd3cec | ||
|
|
d4ca123999 | ||
|
|
3ac4772087 | ||
|
|
71d455d5de | ||
|
|
ff3734d161 | ||
|
|
185eac3849 | ||
|
|
9af123e5d7 |
No files matched your search
@@ -12,6 +12,7 @@ import { ToastProvider } from './components/ui/toast';
|
||||
import { TooltipProvider } from './components/ui/tooltip';
|
||||
import { ScriptAutomationRoot } from './components/scripts/ScriptAutomationRoot';
|
||||
import { ExternalMcpApprovalsHost } from './components/ai/ExternalMcpApprovalsHost';
|
||||
import { PluginHostInteractionHost } from './components/plugins/PluginHostInteractionHost';
|
||||
import { PluginAuthenticationHost } from './components/plugins/PluginAuthenticationHost';
|
||||
import { MultilinePasteConfirmHost } from './components/terminal/MultilinePasteConfirmDialog';
|
||||
import { useExternalMcpGrantPersister } from './components/ai/useExternalMcpGrantPersister';
|
||||
@@ -86,6 +87,7 @@ function AppWithProviders({ settings, appLock }: { settings: SettingsState; appL
|
||||
<ScriptAutomationRoot />
|
||||
<ExternalMcpApprovalsHost />
|
||||
<PluginAuthenticationHost />
|
||||
<PluginHostInteractionHost />
|
||||
<MultilinePasteConfirmHost />
|
||||
<VaultPublisher>
|
||||
<SessionPublisher persistSessionRestore={!isPeerSessionWindow}>
|
||||
|
||||
@@ -167,7 +167,8 @@ function AppPluginKeybindingThemeHost({
|
||||
sessions: any[];
|
||||
workspaces: any[];
|
||||
}) {
|
||||
const appThemeStyle = useAppThemeStyle();
|
||||
// View environment is global in the current host: publish committed colors only.
|
||||
const appThemeStyle = useAppThemeStyle(false);
|
||||
const { resolvedTheme, uiLanguage } = useSettingsChromeStore();
|
||||
const pluginThemeTokens = useMemo(
|
||||
() => selectPluginThemeTokens(appThemeStyle as Record<string, unknown>),
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { usePluginThemeRevision } from '../state/usePluginThemeRuntime';
|
||||
import type React from 'react';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
@@ -12,17 +13,20 @@ import { getUiThemeById } from '../../infrastructure/config/uiThemes';
|
||||
* settingsChromeStore so accent drags only re-render the leaf that applies the
|
||||
* vars, never the App shell.
|
||||
*/
|
||||
export function useAppThemeStyle(): React.CSSProperties {
|
||||
export function useAppThemeStyle(includePreview = true): React.CSSProperties {
|
||||
const pluginThemeRevision = usePluginThemeRevision();
|
||||
const { accentMode, customAccent } = useAppearanceChromeStore();
|
||||
const { resolvedTheme, darkUiThemeId, lightUiThemeId } = useSettingsChromeStore();
|
||||
return useMemo(() => {
|
||||
void pluginThemeRevision; // getUiThemeById reads this external registry revision.
|
||||
const tokens = getUiThemeById(
|
||||
resolvedTheme,
|
||||
resolvedTheme === 'dark' ? darkUiThemeId : lightUiThemeId,
|
||||
includePreview,
|
||||
).tokens;
|
||||
return {
|
||||
...buildAppThemeCssVars(tokens, accentMode, customAccent),
|
||||
colorScheme: resolvedTheme,
|
||||
} as React.CSSProperties;
|
||||
}, [accentMode, customAccent, darkUiThemeId, lightUiThemeId, resolvedTheme]);
|
||||
}, [accentMode, customAccent, darkUiThemeId, lightUiThemeId, resolvedTheme, pluginThemeRevision, includePreview]);
|
||||
}
|
||||
@@ -1245,11 +1245,54 @@ Highlight the focused split pane:
|
||||
"settings.plugins.manager.disable": "Disable",
|
||||
"settings.plugins.manager.remove": "Uninstall",
|
||||
"settings.plugins.manager.removeNote": "Uninstall removes package code and local KV data. User-owned settings, secrets and sync sidecars are retained.",
|
||||
"settings.plugins.permissions.statusUnknown": "Status unknown",
|
||||
"plugins.sidebar.extensions": "Extension pages",
|
||||
"plugins.sidebar.selectTerminal": "Select an open terminal first.",
|
||||
"plugins.sidebar.loading": "Loading extension page…",
|
||||
"plugins.vault.loading": "Loading plugin page…",
|
||||
"plugins.vault.extensions": "Extensions",
|
||||
"plugins.vault.back": "Back to hosts",
|
||||
"plugins.interaction.title": "Plugin request",
|
||||
"plugins.interaction.sharing": "Text you share becomes available to this plugin and its granted capabilities, including network access. Reading does not approve a new network destination.",
|
||||
"plugins.interaction.input": "Input text",
|
||||
"plugins.interaction.filter": "Filter choices",
|
||||
"plugins.interaction.preview": "Preview clipboard",
|
||||
"plugins.interaction.previewNote": "The clipboard is read only after you choose Preview. You can then inspect the text and separately confirm sharing it.",
|
||||
"plugins.interaction.previewAction": "Preview clipboard",
|
||||
"plugins.interaction.share": "Share with plugin",
|
||||
"plugins.interaction.copy": "Copy to clipboard",
|
||||
"plugins.interaction.failed": "This request expired or could not be completed.",
|
||||
"settings.plugins.permissions.current": "Matches the current activation",
|
||||
"settings.plugins.permissions.inactive": "Inactive for the current activation",
|
||||
"settings.plugins.permissions.title": "Saved grants",
|
||||
"settings.plugins.permissions.refresh": "Refresh",
|
||||
"settings.plugins.permissions.loading": "Loading grants...",
|
||||
"settings.plugins.permissions.empty": "No saved grants. One-use decisions are not retained.",
|
||||
"settings.plugins.permissions.always": "Until revoked",
|
||||
"settings.plugins.permissions.application": "Until Netcatty exits",
|
||||
"settings.plugins.permissions.session": "Until the session closes",
|
||||
"settings.plugins.permissions.audit": "Recent security events (up to 100)",
|
||||
"settings.plugins.permissions.revoke": "Disable and revoke all grants",
|
||||
"settings.plugins.permissions.revokeNote": "This disables the plugin, cancels its work, and removes saved grants. Enable it again to request new access.",
|
||||
"settings.plugins.permissions.unavailable": "Plugin permission management is unavailable",
|
||||
"settings.plugins.manager.permissions": "Declared permissions",
|
||||
"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.themes.title": "Plugin themes",
|
||||
"settings.plugins.themes.description": "Preview changes only this window. Apply saves this device’s selection. Personal accent, fonts and terminal overrides stay in control.",
|
||||
"settings.plugins.themes.empty": "No enabled plugin themes for this mode.",
|
||||
"settings.plugins.themes.choose": "Choose a theme",
|
||||
"settings.plugins.themes.unavailable": "Saved theme unavailable; using the host default",
|
||||
"settings.plugins.themes.applied": "Applied",
|
||||
"settings.plugins.themes.preview": "Preview",
|
||||
"settings.plugins.themes.apply": "Apply",
|
||||
"settings.plugins.themes.cancel": "Cancel preview",
|
||||
"settings.plugins.themes.restore": "Restore default",
|
||||
"settings.plugins.themes.invalid": "Some theme palettes were rejected or unavailable.",
|
||||
"settings.plugins.themes.failed": "Could not apply theme.",
|
||||
"settings.plugins.manager.dataTheme": "Theme pack: validated colors, no executable runtime.",
|
||||
"settings.plugins.manager.browser": "Browser runtime: privileged access goes through host permission brokers.",
|
||||
"settings.plugins.manager.advanced": "Advanced runtime: full Node filesystem and network authority. Broker permissions cannot restrict this access. Development preview only.",
|
||||
"settings.plugins.manager.data": "Local KV stays on this device. Only non-secret settings explicitly marked for sync participate in encrypted cloud sidecars. Packages and permission grants do not sync.",
|
||||
|
||||
@@ -1245,11 +1245,54 @@ Resalta el panel dividido enfocado:
|
||||
"settings.plugins.manager.disable": "Desactivar",
|
||||
"settings.plugins.manager.remove": "Desinstalar",
|
||||
"settings.plugins.manager.removeNote": "La desinstalación elimina el código y los datos KV locales. Se conservan los ajustes del usuario, los secretos y los datos auxiliares de sincronización.",
|
||||
"settings.plugins.permissions.statusUnknown": "Estado desconocido",
|
||||
"plugins.sidebar.extensions": "Páginas de extensiones",
|
||||
"plugins.sidebar.selectTerminal": "Selecciona primero un terminal abierto.",
|
||||
"plugins.sidebar.loading": "Cargando página de extensión…",
|
||||
"plugins.vault.loading": "Cargando página del plugin…",
|
||||
"plugins.vault.extensions": "Extensiones",
|
||||
"plugins.vault.back": "Volver a hosts",
|
||||
"plugins.interaction.title": "Solicitud de complemento",
|
||||
"plugins.interaction.sharing": "El texto compartido estará disponible para este complemento y sus capacidades autorizadas, incluida la red. Leer no autoriza un nuevo destino de red.",
|
||||
"plugins.interaction.input": "Introducir texto",
|
||||
"plugins.interaction.filter": "Filtrar opciones",
|
||||
"plugins.interaction.preview": "Vista previa del portapapeles",
|
||||
"plugins.interaction.previewNote": "El portapapeles se lee al elegir Vista previa. Revise el texto y confirme por separado que desea compartirlo.",
|
||||
"plugins.interaction.previewAction": "Vista previa del portapapeles",
|
||||
"plugins.interaction.share": "Compartir con el complemento",
|
||||
"plugins.interaction.copy": "Copiar al portapapeles",
|
||||
"plugins.interaction.failed": "La solicitud caducó o no pudo completarse.",
|
||||
"settings.plugins.permissions.current": "Coincide con la ejecución actual",
|
||||
"settings.plugins.permissions.inactive": "No activa para la ejecución actual",
|
||||
"settings.plugins.permissions.title": "Autorizaciones guardadas",
|
||||
"settings.plugins.permissions.refresh": "Actualizar",
|
||||
"settings.plugins.permissions.loading": "Cargando autorizaciones...",
|
||||
"settings.plugins.permissions.empty": "No hay autorizaciones guardadas. Las decisiones de un solo uso no se conservan.",
|
||||
"settings.plugins.permissions.always": "Hasta su revocación",
|
||||
"settings.plugins.permissions.application": "Hasta cerrar Netcatty",
|
||||
"settings.plugins.permissions.session": "Hasta cerrar la sesión",
|
||||
"settings.plugins.permissions.audit": "Eventos de seguridad recientes (hasta 100)",
|
||||
"settings.plugins.permissions.revoke": "Desactivar y revocar todas las autorizaciones",
|
||||
"settings.plugins.permissions.revokeNote": "Desactiva el complemento, cancela su trabajo y elimina las autorizaciones guardadas. Actívelo para solicitar acceso de nuevo.",
|
||||
"settings.plugins.permissions.unavailable": "La gestión de permisos no está disponible",
|
||||
"settings.plugins.manager.permissions": "Permisos declarados",
|
||||
"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.themes.title": "Temas de plugins",
|
||||
"settings.plugins.themes.description": "La vista previa solo cambia esta ventana. Aplicar guarda la selección en este dispositivo. Se conservan los ajustes personales.",
|
||||
"settings.plugins.themes.empty": "No hay temas habilitados para este modo.",
|
||||
"settings.plugins.themes.choose": "Elegir tema",
|
||||
"settings.plugins.themes.unavailable": "Tema guardado no disponible; se usa el predeterminado",
|
||||
"settings.plugins.themes.applied": "Aplicado",
|
||||
"settings.plugins.themes.preview": "Vista previa",
|
||||
"settings.plugins.themes.apply": "Aplicar",
|
||||
"settings.plugins.themes.cancel": "Cancelar vista previa",
|
||||
"settings.plugins.themes.restore": "Restaurar predeterminado",
|
||||
"settings.plugins.themes.invalid": "Algunas paletas no están disponibles o no son válidas.",
|
||||
"settings.plugins.themes.failed": "No se pudo aplicar el tema.",
|
||||
"settings.plugins.manager.dataTheme": "Paquete de temas: colores validados, sin ejecución de código.",
|
||||
"settings.plugins.manager.browser": "Entorno de navegador: el acceso privilegiado pasa por los controles de permisos del anfitrión.",
|
||||
"settings.plugins.manager.advanced": "Entorno avanzado: acceso completo de Node al sistema de archivos y a la red. Los permisos del intermediario no restringen este acceso. Solo para desarrollo.",
|
||||
"settings.plugins.manager.data": "Los datos KV permanecen en este dispositivo. Solo los ajustes no secretos marcados para sincronización participan en los datos auxiliares cifrados. Los paquetes y las autorizaciones no se sincronizan.",
|
||||
|
||||
@@ -1140,11 +1140,54 @@ export const ruCoreMessages: Messages = {
|
||||
"settings.plugins.manager.disable": "Отключить",
|
||||
"settings.plugins.manager.remove": "Удалить",
|
||||
"settings.plugins.manager.removeNote": "Удаление очищает код пакета и локальные данные KV. Настройки пользователя, секреты и данные синхронизации сохраняются.",
|
||||
"settings.plugins.permissions.statusUnknown": "Состояние неизвестно",
|
||||
"plugins.sidebar.extensions": "Страницы расширений",
|
||||
"plugins.sidebar.selectTerminal": "Сначала выберите открытый терминал.",
|
||||
"plugins.sidebar.loading": "Загрузка страницы расширения…",
|
||||
"plugins.vault.loading": "Загрузка страницы плагина…",
|
||||
"plugins.vault.extensions": "Расширения",
|
||||
"plugins.vault.back": "К хостам",
|
||||
"plugins.interaction.title": "Запрос плагина",
|
||||
"plugins.interaction.sharing": "Переданный текст доступен плагину и его разрешённым возможностям, включая сеть. Чтение не разрешает новый сетевой адрес.",
|
||||
"plugins.interaction.input": "Ввести текст",
|
||||
"plugins.interaction.filter": "Фильтр вариантов",
|
||||
"plugins.interaction.preview": "Предпросмотр буфера обмена",
|
||||
"plugins.interaction.previewNote": "Буфер читается только после нажатия предпросмотра. Затем проверьте текст и отдельно подтвердите передачу.",
|
||||
"plugins.interaction.previewAction": "Предпросмотр буфера обмена",
|
||||
"plugins.interaction.share": "Передать плагину",
|
||||
"plugins.interaction.copy": "Копировать в буфер обмена",
|
||||
"plugins.interaction.failed": "Запрос истёк или не выполнен.",
|
||||
"settings.plugins.permissions.current": "Соответствует текущему запуску",
|
||||
"settings.plugins.permissions.inactive": "Не действует для текущего запуска",
|
||||
"settings.plugins.permissions.title": "Сохранённые разрешения",
|
||||
"settings.plugins.permissions.refresh": "Обновить",
|
||||
"settings.plugins.permissions.loading": "Загрузка разрешений...",
|
||||
"settings.plugins.permissions.empty": "Сохранённых разрешений нет. Одноразовые решения не сохраняются.",
|
||||
"settings.plugins.permissions.always": "До отзыва",
|
||||
"settings.plugins.permissions.application": "До выхода из Netcatty",
|
||||
"settings.plugins.permissions.session": "До закрытия сеанса",
|
||||
"settings.plugins.permissions.audit": "Последние события безопасности (до 100)",
|
||||
"settings.plugins.permissions.revoke": "Отключить и отозвать все разрешения",
|
||||
"settings.plugins.permissions.revokeNote": "Плагин будет отключён, его задачи отменены, а сохранённые разрешения удалены. Включите его для нового запроса доступа.",
|
||||
"settings.plugins.permissions.unavailable": "Управление разрешениями недоступно",
|
||||
"settings.plugins.manager.permissions": "Заявленные разрешения",
|
||||
"settings.plugins.manager.permissionNote": "Из манифеста, а не предоставленные права. Включение допускает отложенную активацию; обязательные разрешения запрашиваются перед ней, необязательные — при использовании. Чувствительные операции проверяются приложением.",
|
||||
"settings.plugins.manager.noPermissions": "Привилегированные разрешения не заявлены.",
|
||||
"settings.plugins.manager.required": "Обязательно",
|
||||
"settings.plugins.manager.optional": "Необязательно",
|
||||
"settings.plugins.themes.title": "Темы плагинов",
|
||||
"settings.plugins.themes.description": "Предпросмотр меняет только это окно. Применение сохраняет выбор на этом устройстве. Личные настройки сохраняются.",
|
||||
"settings.plugins.themes.empty": "Нет включённых тем для этого режима.",
|
||||
"settings.plugins.themes.choose": "Выберите тему",
|
||||
"settings.plugins.themes.unavailable": "Сохранённая тема недоступна; используется стандартная палитра",
|
||||
"settings.plugins.themes.applied": "Применено",
|
||||
"settings.plugins.themes.preview": "Предпросмотр",
|
||||
"settings.plugins.themes.apply": "Применить",
|
||||
"settings.plugins.themes.cancel": "Отменить предпросмотр",
|
||||
"settings.plugins.themes.restore": "Сбросить тему",
|
||||
"settings.plugins.themes.invalid": "Некоторые палитры недоступны или отклонены.",
|
||||
"settings.plugins.themes.failed": "Не удалось применить тему.",
|
||||
"settings.plugins.manager.dataTheme": "Пакет тем: проверенные цвета без выполнения кода.",
|
||||
"settings.plugins.manager.browser": "Браузерная среда: привилегированный доступ проходит через проверку разрешений приложения.",
|
||||
"settings.plugins.manager.advanced": "Расширенная среда: полный доступ Node к файловой системе и сети. Разрешения посредника не ограничивают этот доступ. Только для разработки.",
|
||||
"settings.plugins.manager.data": "Данные KV остаются на устройстве. Синхронизируются только явно отмеченные несекретные настройки в зашифрованных данных. Пакеты и разрешения не синхронизируются.",
|
||||
|
||||
@@ -1160,11 +1160,54 @@ Vaults 标签保持扁平,不使用这块背景。
|
||||
"settings.plugins.manager.disable": "停用",
|
||||
"settings.plugins.manager.remove": "卸载",
|
||||
"settings.plugins.manager.removeNote": "卸载会移除插件代码和本地 KV 数据,保留用户设置、秘密存储和同步 sidecar。",
|
||||
"settings.plugins.permissions.statusUnknown": "状态未知",
|
||||
"plugins.sidebar.extensions": "扩展页面",
|
||||
"plugins.sidebar.selectTerminal": "请先选择已打开的终端。",
|
||||
"plugins.sidebar.loading": "正在加载扩展页面…",
|
||||
"plugins.vault.loading": "正在加载插件页面…",
|
||||
"plugins.vault.extensions": "扩展",
|
||||
"plugins.vault.back": "返回主机",
|
||||
"plugins.interaction.title": "插件请求",
|
||||
"plugins.interaction.sharing": "分享的文本将交给此插件及其已授权的能力(包括网络访问)。读取文本不代表批准新的网络目标。",
|
||||
"plugins.interaction.input": "输入文本",
|
||||
"plugins.interaction.filter": "筛选选项",
|
||||
"plugins.interaction.preview": "剪贴板预览",
|
||||
"plugins.interaction.previewNote": "仅在点击“预览剪贴板”后读取。检查文本后,还需单独确认才能分享给插件。",
|
||||
"plugins.interaction.previewAction": "预览剪贴板",
|
||||
"plugins.interaction.share": "分享给插件",
|
||||
"plugins.interaction.copy": "复制到剪贴板",
|
||||
"plugins.interaction.failed": "此请求已过期或无法完成。",
|
||||
"settings.plugins.permissions.current": "匹配当前运行实例",
|
||||
"settings.plugins.permissions.inactive": "对当前运行实例不生效",
|
||||
"settings.plugins.permissions.title": "已保存的授权",
|
||||
"settings.plugins.permissions.refresh": "刷新",
|
||||
"settings.plugins.permissions.loading": "正在读取授权...",
|
||||
"settings.plugins.permissions.empty": "没有已保存的授权。单次决定不会保留。",
|
||||
"settings.plugins.permissions.always": "直到撤销",
|
||||
"settings.plugins.permissions.application": "直到退出 Netcatty",
|
||||
"settings.plugins.permissions.session": "直到会话关闭",
|
||||
"settings.plugins.permissions.audit": "最近的安全事件(最多 100 条)",
|
||||
"settings.plugins.permissions.revoke": "停用并撤销全部授权",
|
||||
"settings.plugins.permissions.revokeNote": "此操作会停用插件、取消其工作并删除已保存的授权。重新启用后可申请新授权。",
|
||||
"settings.plugins.permissions.unavailable": "插件授权管理不可用",
|
||||
"settings.plugins.manager.permissions": "声明的权限",
|
||||
"settings.plugins.manager.permissionNote": "来自 manifest,不代表已授权。启用表示允许按需激活;必需权限在首次激活前申请,可选权限在用到时申请。敏感操作仍受宿主检查。",
|
||||
"settings.plugins.manager.noPermissions": "未声明特权权限。",
|
||||
"settings.plugins.manager.required": "必需",
|
||||
"settings.plugins.manager.optional": "可选",
|
||||
"settings.plugins.themes.title": "插件主题",
|
||||
"settings.plugins.themes.description": "预览仅影响当前窗口;应用后保存在本设备。个人强调色、字体和终端覆盖设置仍然优先。",
|
||||
"settings.plugins.themes.empty": "当前模式没有已启用的插件主题。",
|
||||
"settings.plugins.themes.choose": "选择主题",
|
||||
"settings.plugins.themes.unavailable": "已保存的主题不可用,正在使用宿主默认配色",
|
||||
"settings.plugins.themes.applied": "已应用",
|
||||
"settings.plugins.themes.preview": "预览",
|
||||
"settings.plugins.themes.apply": "应用",
|
||||
"settings.plugins.themes.cancel": "取消预览",
|
||||
"settings.plugins.themes.restore": "恢复默认",
|
||||
"settings.plugins.themes.invalid": "部分主题配色未通过验证或不可用。",
|
||||
"settings.plugins.themes.failed": "无法应用主题。",
|
||||
"settings.plugins.manager.dataTheme": "主题包:已验证配色,不启动代码运行时。",
|
||||
"settings.plugins.manager.browser": "浏览器运行时:特权访问通过宿主权限代理。",
|
||||
"settings.plugins.manager.advanced": "高级运行时:拥有完整 Node 文件系统和网络能力,代理权限无法限制这些访问。仅供开发预览。",
|
||||
"settings.plugins.manager.data": "本地 KV 仅保留在此设备。只有明确标记为同步的非秘密设置进入加密云 sidecar。插件包和权限授权不参与同步。",
|
||||
|
||||
@@ -1163,11 +1163,54 @@ Vaults 分頁維持扁平,不使用這塊背景。
|
||||
"settings.plugins.manager.disable": "停用",
|
||||
"settings.plugins.manager.remove": "卸載",
|
||||
"settings.plugins.manager.removeNote": "卸載會移除插件代碼和本地 KV 數據,保留用戶設置、秘密存儲和同步 sidecar。",
|
||||
"settings.plugins.permissions.statusUnknown": "狀態不明",
|
||||
"plugins.sidebar.extensions": "擴充功能頁面",
|
||||
"plugins.sidebar.selectTerminal": "請先選擇已開啟的終端。",
|
||||
"plugins.sidebar.loading": "正在載入擴充功能頁面…",
|
||||
"plugins.vault.loading": "正在載入擴充功能頁面…",
|
||||
"plugins.vault.extensions": "擴充功能",
|
||||
"plugins.vault.back": "返回主機",
|
||||
"plugins.interaction.title": "外掛請求",
|
||||
"plugins.interaction.sharing": "分享的文字會交給此外掛及其已授權能力(包括網路存取)。讀取文字不代表核准新的網路目的地。",
|
||||
"plugins.interaction.input": "輸入文字",
|
||||
"plugins.interaction.filter": "篩選選項",
|
||||
"plugins.interaction.preview": "剪貼簿預覽",
|
||||
"plugins.interaction.previewNote": "僅在點選「預覽剪貼簿」後讀取。檢查文字後,還需另外確認才能分享給外掛。",
|
||||
"plugins.interaction.previewAction": "預覽剪貼簿",
|
||||
"plugins.interaction.share": "分享給外掛",
|
||||
"plugins.interaction.copy": "複製到剪貼簿",
|
||||
"plugins.interaction.failed": "此請求已過期或無法完成。",
|
||||
"settings.plugins.permissions.current": "符合目前執行個體",
|
||||
"settings.plugins.permissions.inactive": "對目前執行個體不生效",
|
||||
"settings.plugins.permissions.title": "已儲存的授權",
|
||||
"settings.plugins.permissions.refresh": "重新整理",
|
||||
"settings.plugins.permissions.loading": "正在讀取授權...",
|
||||
"settings.plugins.permissions.empty": "沒有已儲存的授權。單次決定不會保留。",
|
||||
"settings.plugins.permissions.always": "直到撤銷",
|
||||
"settings.plugins.permissions.application": "直到退出 Netcatty",
|
||||
"settings.plugins.permissions.session": "直到工作階段關閉",
|
||||
"settings.plugins.permissions.audit": "最近的安全事件(最多 100 筆)",
|
||||
"settings.plugins.permissions.revoke": "停用並撤銷全部授權",
|
||||
"settings.plugins.permissions.revokeNote": "此操作會停用外掛、取消其工作並刪除已儲存的授權。重新啟用後可申請新授權。",
|
||||
"settings.plugins.permissions.unavailable": "外掛授權管理無法使用",
|
||||
"settings.plugins.manager.permissions": "聲明的權限",
|
||||
"settings.plugins.manager.permissionNote": "來自 manifest,不代表已授權。啟用表示允許按需激活;必需權限在首次激活前申請,可選權限在用到時申請。敏感操作仍受宿主檢查。",
|
||||
"settings.plugins.manager.noPermissions": "未聲明特權權限。",
|
||||
"settings.plugins.manager.required": "必需",
|
||||
"settings.plugins.manager.optional": "可選",
|
||||
"settings.plugins.themes.title": "外掛主題",
|
||||
"settings.plugins.themes.description": "預覽僅影響目前視窗;套用後儲存於本機。個人強調色、字型和終端覆寫設定仍然優先。",
|
||||
"settings.plugins.themes.empty": "目前模式沒有已啟用的外掛主題。",
|
||||
"settings.plugins.themes.choose": "選擇主題",
|
||||
"settings.plugins.themes.unavailable": "已儲存的主題無法使用,正在使用宿主預設配色",
|
||||
"settings.plugins.themes.applied": "已套用",
|
||||
"settings.plugins.themes.preview": "預覽",
|
||||
"settings.plugins.themes.apply": "套用",
|
||||
"settings.plugins.themes.cancel": "取消預覽",
|
||||
"settings.plugins.themes.restore": "恢復預設",
|
||||
"settings.plugins.themes.invalid": "部分主題配色未通過驗證或無法使用。",
|
||||
"settings.plugins.themes.failed": "無法套用主題。",
|
||||
"settings.plugins.manager.dataTheme": "主題套件:已驗證配色,不啟動程式執行環境。",
|
||||
"settings.plugins.manager.browser": "瀏覽器運行時:特權訪問通過宿主權限代理。",
|
||||
"settings.plugins.manager.advanced": "高級運行時:擁有完整 Node 文件系統和網絡能力,代理權限無法限制這些訪問。僅供開發預覽。",
|
||||
"settings.plugins.manager.data": "本地 KV 僅保留在此設備。只有明確標記為同步的非秘密設置進入加密雲 sidecar。插件包和權限授權不參與同步。",
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
export const PLUGIN_VAULT_PAGE_PREFIX = 'plugin-vault:';
|
||||
|
||||
export interface PluginVaultPage {
|
||||
key: `plugin-vault:${string}`;
|
||||
pluginId: string;
|
||||
pluginName: string;
|
||||
version: string;
|
||||
viewId: string;
|
||||
title: string;
|
||||
icon?: NetcattyPluginIconReference;
|
||||
order: number;
|
||||
}
|
||||
|
||||
export function collectPluginVaultPages(plugins: NetcattyPluginContributionSnapshot['plugins']): PluginVaultPage[] {
|
||||
return plugins.flatMap((plugin) => plugin.views
|
||||
.filter((view) => view.location === 'vault-page' && view.visible)
|
||||
.map((view): PluginVaultPage => ({
|
||||
key: `${PLUGIN_VAULT_PAGE_PREFIX}${view.id}`,
|
||||
pluginId: plugin.id, pluginName: plugin.displayName, version: plugin.version,
|
||||
viewId: view.id, title: view.title, icon: view.icon, order: view.order ?? 0,
|
||||
})))
|
||||
.sort((left, right) => left.order - right.order || left.viewId.localeCompare(right.viewId));
|
||||
}
|
||||
|
||||
export function resolvePluginVaultPage(pages: readonly PluginVaultPage[], section: string): PluginVaultPage | null {
|
||||
return pages.find((page) => page.key === section) ?? null;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
/** Native child views must yield to host dialogs and overlapping portals. */
|
||||
export function isPluginViewOccluded(mount: HTMLElement): boolean {
|
||||
const rect = mount.getBoundingClientRect();
|
||||
if (!mount.isConnected || mount.closest('[hidden], [inert], [aria-hidden="true"]')) return true;
|
||||
const overlays = mount.ownerDocument.querySelectorAll(
|
||||
'[data-app-lock-overlay], [role="dialog"], [role="alertdialog"], '
|
||||
+ '[role="menu"][data-state="open"], [data-radix-popper-content-wrapper] > *, [data-native-view-occluder]',
|
||||
);
|
||||
return [...overlays].some((overlay) => {
|
||||
if (mount.contains(overlay)) return false;
|
||||
const style = getComputedStyle(overlay);
|
||||
if (style.display === 'none' || style.visibility === 'hidden' || overlay.closest('[hidden], [inert]')) return false;
|
||||
if (overlay.matches('[data-app-lock-overlay], [role="dialog"][aria-modal="true"], [role="alertdialog"]')) return true;
|
||||
const other = overlay.getBoundingClientRect();
|
||||
return other.width > 0 && other.height > 0 && other.x < rect.right && other.right > rect.x
|
||||
&& other.y < rect.bottom && other.bottom > rect.y;
|
||||
});
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { pluginThemeRegistry } from '../../infrastructure/config/pluginThemeRegistry';
|
||||
import {
|
||||
DEFAULT_SHIFT_ENTER_NEWLINE_TEXT,
|
||||
isLegacyDefaultShiftEnterNewlineText,
|
||||
@@ -252,6 +253,9 @@ export const applyThemeTokens = (
|
||||
accentOverride: string,
|
||||
) => {
|
||||
const root = window.document.documentElement;
|
||||
root.toggleAttribute('data-plugin-theme-active', pluginThemeRegistry.isActive(resolvedTheme));
|
||||
const securityVars = buildAppThemeCssVars(pluginThemeRegistry.securityTokens(resolvedTheme), 'theme', '');
|
||||
for (const [key, value] of Object.entries(securityVars)) root.style.setProperty('--host-security' + key.slice(1), value);
|
||||
root.classList.remove('light', 'dark');
|
||||
root.classList.add(resolvedTheme);
|
||||
const cssVars = buildAppThemeCssVars(tokens, accentMode, accentOverride);
|
||||
|
||||
@@ -13,11 +13,11 @@ import {
|
||||
} from '../../infrastructure/config/storageKeys';
|
||||
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
|
||||
import { useToolbarItemLayout } from './useToolbarItemLayout';
|
||||
import type { SidePanelTool } from '../../domain/sidePanelLayout';
|
||||
import type { BuiltinSidePanelTool, SidePanelTool } from '../../domain/sidePanelLayout';
|
||||
|
||||
export type TerminalSidePanelTabId = SidePanelTool;
|
||||
|
||||
export const TERMINAL_SIDE_PANEL_TAB_DEFAULT_ORDER: TerminalSidePanelTabId[] = [
|
||||
export const TERMINAL_SIDE_PANEL_TAB_DEFAULT_ORDER: BuiltinSidePanelTool[] = [
|
||||
'sftp',
|
||||
'browser',
|
||||
'scripts',
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import { pluginThemeRegistry } from '../../infrastructure/config/pluginThemeRegistry';
|
||||
import { usePluginThemeRevision } from './usePluginThemeRuntime';
|
||||
import { useSettingsChromeStore } from './settingsChromeStore';
|
||||
import { useLayoutEffect, useRef } from "react";
|
||||
import type { TerminalTheme } from "../../domain/models";
|
||||
import {
|
||||
@@ -271,12 +274,15 @@ export function useActiveChromeTheme({
|
||||
activeTheme: TerminalTheme | null;
|
||||
applyAppTheme: () => void;
|
||||
}) {
|
||||
const pluginThemeRevision = usePluginThemeRevision();
|
||||
const { resolvedTheme } = useSettingsChromeStore();
|
||||
const effectiveTheme = pluginThemeRegistry.isActive(resolvedTheme) ? null : activeTheme;
|
||||
const applyAppThemeRef = useRef(applyAppTheme);
|
||||
applyAppThemeRef.current = applyAppTheme;
|
||||
|
||||
useLayoutEffect(() => {
|
||||
syncActiveChromeTheme(activeTheme, applyAppTheme);
|
||||
}, [activeTheme, applyAppTheme]);
|
||||
syncActiveChromeTheme(effectiveTheme, applyAppTheme);
|
||||
}, [effectiveTheme, applyAppTheme, pluginThemeRevision]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
return () => {
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { PluginViewLifecycleController, type HostedPluginViewState } from './pluginViewLifecycle';
|
||||
import { resolvePluginViewWindowScope } from './pluginViewScopes';
|
||||
import { isPluginViewOccluded } from './pluginViewOcclusion';
|
||||
import type { UsePluginContributionsResult } from './usePluginContributions';
|
||||
|
||||
type ViewAdapter = Pick<UsePluginContributionsResult, 'openView' | 'closeView' | 'focusView' | 'setViewBounds' | 'setViewVisibility' | 'onViewClosed' | 'onViewFocused'>;
|
||||
|
||||
/** Host-owned mounting only. No plugin router, DOM, session or vault data API. */
|
||||
export function useEmbeddedPluginView({ viewId, version, slot, active, context, adapter, subscribeHidden, unavailable, focused, focusOnReveal = false }: {
|
||||
viewId: string;
|
||||
version: string;
|
||||
slot: string;
|
||||
active: boolean;
|
||||
context: Record<string, unknown>;
|
||||
adapter: ViewAdapter;
|
||||
subscribeHidden(listener: () => void): () => void;
|
||||
unavailable(): void;
|
||||
focused?(): void;
|
||||
focusOnReveal?: boolean;
|
||||
}) {
|
||||
const mountRef = useRef<HTMLDivElement>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const adapterRef = useRef(adapter);
|
||||
const unavailableRef = useRef(unavailable);
|
||||
const focusedRef = useRef(focused);
|
||||
const revealFocusRef = useRef(focusOnReveal);
|
||||
const requestFocusRef = useRef<() => void>(() => {});
|
||||
revealFocusRef.current = focusOnReveal;
|
||||
focusedRef.current = focused;
|
||||
adapterRef.current = adapter;
|
||||
unavailableRef.current = unavailable;
|
||||
useEffect(() => {
|
||||
const mount = mountRef.current;
|
||||
if (!active || !mount) return;
|
||||
let cancelled = false;
|
||||
const lifecycle = new PluginViewLifecycleController<HostedPluginViewState>();
|
||||
const instanceId = crypto.randomUUID();
|
||||
const scopeId = resolvePluginViewWindowScope({ pathname: window.location.pathname,
|
||||
search: window.location.search, hash: `${window.location.hash}|plugin-slot:${slot}` });
|
||||
const { openView, closeView, focusView, setViewBounds, setViewVisibility, onViewClosed, onViewFocused } = adapterRef.current;
|
||||
const close = () => {
|
||||
if (cancelled) return;
|
||||
cancelled = true;
|
||||
lifecycle.markViewClosed(viewId);
|
||||
void closeView(instanceId).catch(() => {});
|
||||
};
|
||||
const failCurrent = () => {
|
||||
if (cancelled) return;
|
||||
close();
|
||||
unavailableRef.current();
|
||||
};
|
||||
const unsubscribe = onViewClosed((event) => {
|
||||
lifecycle.handleHostClose(event.instanceId);
|
||||
if (event.instanceId === instanceId && !cancelled) {
|
||||
close();
|
||||
unavailableRef.current();
|
||||
}
|
||||
});
|
||||
const stopOnHide = subscribeHidden(close);
|
||||
const stopOnFocus = onViewFocused((event) => {
|
||||
if (!cancelled && event.instanceId === instanceId) focusedRef.current?.();
|
||||
});
|
||||
const bounds = () => {
|
||||
const rect = mount.getBoundingClientRect();
|
||||
return { x: Math.round(rect.x), y: Math.round(rect.y), width: Math.max(1, Math.round(rect.width)), height: Math.max(1, Math.round(rect.height)) };
|
||||
};
|
||||
let opened = false;
|
||||
let lastBounds = '';
|
||||
let visible = false;
|
||||
const focus = () => {
|
||||
if (!cancelled && opened && visible && revealFocusRef.current && !isPluginViewOccluded(mount)) void focusView(instanceId).catch(() => {});
|
||||
};
|
||||
requestFocusRef.current = focus;
|
||||
const updateVisibility = () => {
|
||||
if (!opened || cancelled) return;
|
||||
const rect = mount.getBoundingClientRect();
|
||||
const next = rect.width > 0 && rect.height > 0 && !isPluginViewOccluded(mount);
|
||||
if (visible === next) return;
|
||||
visible = next;
|
||||
void setViewVisibility(instanceId, next).then(() => { if (next) focus(); }).catch(failCurrent);
|
||||
};
|
||||
const resize = () => {
|
||||
if (opened && !cancelled) {
|
||||
const next = bounds();
|
||||
const key = JSON.stringify(next);
|
||||
if (key !== lastBounds) { lastBounds = key; void setViewBounds(instanceId, next).catch(failCurrent); }
|
||||
}
|
||||
updateVisibility();
|
||||
};
|
||||
const observer = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(resize);
|
||||
observer?.observe(mount);
|
||||
const mutations = new MutationObserver(resize);
|
||||
mutations.observe(document.body, { childList: true, subtree: true, attributes: true,
|
||||
attributeFilter: ['class', 'style', 'hidden', 'inert', 'role', 'aria-modal', 'aria-hidden', 'data-state', 'data-app-lock-overlay', 'data-radix-popper-content-wrapper', 'data-native-view-occluder'] });
|
||||
window.addEventListener('resize', resize);
|
||||
document.addEventListener('scroll', resize, true);
|
||||
setLoading(true);
|
||||
const opening = lifecycle.beginOpen({ viewKey: viewId });
|
||||
void openView({ viewId, instanceId, scopeId, bounds: bounds(), context }).then(async (result) => {
|
||||
if (cancelled || lifecycle.shouldCloseOpen(opening) || lifecycle.consumeHostClose(result.instanceId)) {
|
||||
await closeView(result.instanceId);
|
||||
return;
|
||||
}
|
||||
opened = true;
|
||||
lifecycle.setCurrent({ id: result.instanceId, viewId, scopeId, retainContextWhenHidden: false });
|
||||
resize();
|
||||
setLoading(false);
|
||||
}).catch(() => {
|
||||
if (!cancelled) { close(); unavailableRef.current(); }
|
||||
}).finally(() => lifecycle.finishOpen({ token: opening, viewKey: viewId }));
|
||||
return () => {
|
||||
close(); unsubscribe(); stopOnHide(); stopOnFocus(); observer?.disconnect(); mutations.disconnect();
|
||||
window.removeEventListener('resize', resize); document.removeEventListener('scroll', resize, true);
|
||||
};
|
||||
}, [active, context, slot, subscribeHidden, version, viewId]);
|
||||
useEffect(() => { if (focusOnReveal) requestFocusRef.current(); }, [focusOnReveal]);
|
||||
return { mountRef, loading };
|
||||
}
|
||||
@@ -88,10 +88,12 @@ export interface UsePluginContributionsResult {
|
||||
selectSettingPath(kind: 'file' | 'directory', title: string, defaultPath?: string): Promise<string | null>;
|
||||
openView(payload: NetcattyPluginViewOpenRequest): Promise<{ instanceId: string }>;
|
||||
closeView(instanceId: string): Promise<void>;
|
||||
focusView(instanceId: string): Promise<void>;
|
||||
setViewBounds(instanceId: string, bounds: { x: number; y: number; width: number; height: number }): Promise<void>;
|
||||
setViewVisibility(instanceId: string, visible: boolean): Promise<void>;
|
||||
setEnvironment(environment: NetcattyPluginEnvironment): Promise<void>;
|
||||
onViewClosed(callback: (event: NetcattyPluginViewClosedEvent) => void): () => void;
|
||||
onViewFocused(callback: (event: import('@netcatty/plugin-contract').PluginViewFocusEvent) => void): () => void;
|
||||
}
|
||||
|
||||
export function usePluginContributions(
|
||||
@@ -196,6 +198,9 @@ export function usePluginContributions(
|
||||
if (!bridge?.closePluginView) return;
|
||||
await bridge.closePluginView(instanceId);
|
||||
}, [bridge]);
|
||||
const focusView = useCallback(async (instanceId: string) => {
|
||||
await bridge?.focusPluginView?.({ instanceId });
|
||||
}, [bridge]);
|
||||
|
||||
const setViewBounds = useCallback(async (
|
||||
instanceId: string,
|
||||
@@ -218,6 +223,9 @@ export function usePluginContributions(
|
||||
const onViewClosed = useCallback((callback: (event: NetcattyPluginViewClosedEvent) => void) => (
|
||||
bridge?.onPluginViewClosed?.(callback) ?? (() => {})
|
||||
), [bridge]);
|
||||
const onViewFocused = useCallback((callback: (event: import('@netcatty/plugin-contract').PluginViewFocusEvent) => void) => (
|
||||
bridge?.onPluginViewFocused?.(callback) ?? (() => {})
|
||||
), [bridge]);
|
||||
|
||||
const currentLoadState = resolvePluginContributionLoadState({
|
||||
currentQueryKey: queryKey,
|
||||
@@ -239,9 +247,11 @@ export function usePluginContributions(
|
||||
selectSettingPath,
|
||||
openView,
|
||||
closeView,
|
||||
focusView,
|
||||
setViewBounds,
|
||||
setViewVisibility,
|
||||
setEnvironment,
|
||||
onViewClosed,
|
||||
onViewFocused,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import type { HostInteractionPrompt, HostInteractionResponse } from '@netcatty/plugin-contract';
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
|
||||
export function usePluginHostInteractions() {
|
||||
const [prompt, setPrompt] = useState<HostInteractionPrompt | null>(null);
|
||||
const [value, setValue] = useState('');
|
||||
const [error, setError] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const current = useRef<HostInteractionPrompt | null>(null);
|
||||
const pending = useRef(false);
|
||||
useEffect(() => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.onPluginInteraction || !bridge.setPluginInteractionReady) return;
|
||||
const unsubscribe = bridge.onPluginInteraction((event) => {
|
||||
if ('closed' in event) {
|
||||
if (current.current?.requestId !== event.requestId) return;
|
||||
current.current = null; setPrompt(null);
|
||||
} else {
|
||||
current.current = event; setPrompt(event);
|
||||
setValue(event.request.kind === 'input' ? event.request.initialValue ?? '' : '');
|
||||
}
|
||||
pending.current = false; setBusy(false); setError(false);
|
||||
});
|
||||
void bridge.setPluginInteractionReady(true).catch(() => {});
|
||||
return () => {
|
||||
current.current = null;
|
||||
unsubscribe();
|
||||
void bridge.setPluginInteractionReady?.(false).catch(() => {});
|
||||
};
|
||||
}, []);
|
||||
const respond = useCallback(async (action: HostInteractionResponse['action']) => {
|
||||
const active = current.current;
|
||||
if (!active || pending.current) return;
|
||||
pending.current = true; setBusy(true); setError(false);
|
||||
try {
|
||||
const bridge = netcattyBridge.require();
|
||||
if (!bridge.respondPluginInteraction) throw new Error('Host interactions unavailable');
|
||||
const accepted = await bridge.respondPluginInteraction({
|
||||
requestId: active.requestId, actionToken: active.actionToken, action,
|
||||
...(action === 'submit' && (active.request.kind === 'input' || active.request.kind === 'quickPick') ? { value } : {}),
|
||||
});
|
||||
if (!accepted && current.current?.actionToken === active.actionToken) {
|
||||
current.current = null; setPrompt(null);
|
||||
}
|
||||
} catch {
|
||||
if (current.current?.actionToken === active.actionToken) setError(true);
|
||||
} finally {
|
||||
if (current.current?.actionToken === active.actionToken) { pending.current = false; setBusy(false); }
|
||||
}
|
||||
}, [value]);
|
||||
return { prompt, value, setValue, error, busy, respond };
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
|
||||
export function usePluginLogs() {
|
||||
const [status, setStatus] = useState<NetcattyPluginLogStatus | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const pending = useRef(false);
|
||||
const mounted = useRef(true);
|
||||
const run = useCallback(async (action: 'status' | 'configure' | 'open' | 'export' | 'clear', settings?: { saveToDisk: boolean; verbose: boolean }) => {
|
||||
if (pending.current) return;
|
||||
pending.current = true;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const bridge = netcattyBridge.require();
|
||||
const result = await (action === 'configure' ? bridge.configurePluginLogs?.(settings!)
|
||||
: action === 'open' ? bridge.openPluginLogFolder?.()
|
||||
: action === 'export' ? bridge.exportPluginLogs?.()
|
||||
: action === 'clear' ? bridge.clearPluginLogs?.() : bridge.getPluginLogStatus?.());
|
||||
if (!result) throw new Error('Local plugin logging is unavailable');
|
||||
if (mounted.current) setStatus(result);
|
||||
} catch (cause) {
|
||||
let message = cause instanceof Error ? cause.message : String(cause);
|
||||
if (action === 'configure') {
|
||||
// Configuration fails closed before disk I/O. Show actual host state,
|
||||
// even when persisting a setting failed, rather than keeping stale ON.
|
||||
try {
|
||||
const actual = await netcattyBridge.require().getPluginLogStatus?.();
|
||||
if (!actual) throw new Error('Local plugin log status is unavailable');
|
||||
if (mounted.current) setStatus(actual);
|
||||
} catch (refreshError) {
|
||||
if (mounted.current) setStatus(null);
|
||||
message += `\n${refreshError instanceof Error ? refreshError.message : String(refreshError)}`;
|
||||
}
|
||||
}
|
||||
if (mounted.current) setError(message);
|
||||
} finally {
|
||||
pending.current = false;
|
||||
if (mounted.current) setBusy(false);
|
||||
}
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
mounted.current = true;
|
||||
void run('status');
|
||||
return () => { mounted.current = false; };
|
||||
}, [run]);
|
||||
return { status, error, busy, run };
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
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 { usePluginManager } from './usePluginManager';
|
||||
|
||||
test('failed manager refresh marks previous enabled state unknown until a successful read', async (context) => {
|
||||
const previous = Object.getOwnPropertyDescriptor(globalThis, 'window');
|
||||
const actPrevious = Object.getOwnPropertyDescriptor(globalThis, 'IS_REACT_ACT_ENVIRONMENT');
|
||||
let fail = false;
|
||||
const plugin = { id: 'com.example.synthetic', enabled: true } as NetcattyInstalledPlugin;
|
||||
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', { configurable: true, value: true });
|
||||
Object.defineProperty(globalThis, 'window', { configurable: true, value: {
|
||||
addEventListener() {}, removeEventListener() {}, netcatty: {
|
||||
async getPluginRuntimeStatus() { return { available: true }; },
|
||||
async listPlugins() { if (fail) throw new Error('synthetic status read failed'); return [plugin]; },
|
||||
},
|
||||
} });
|
||||
context.after(() => {
|
||||
if (previous) Object.defineProperty(globalThis, 'window', previous); else Reflect.deleteProperty(globalThis, 'window');
|
||||
if (actPrevious) Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', actPrevious); else Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT');
|
||||
});
|
||||
let state!: ReturnType<typeof usePluginManager>;
|
||||
function Probe() { state = usePluginManager(); return null; }
|
||||
let renderer!: ReactTestRenderer;
|
||||
await act(async () => { renderer = create(React.createElement(Probe)); });
|
||||
try {
|
||||
assert.equal(state.statusKnown, true);
|
||||
assert.equal(state.plugins[0].enabled, true);
|
||||
fail = true;
|
||||
await act(async () => { await assert.rejects(state.refresh(), /synthetic status read failed/); });
|
||||
assert.equal(state.statusKnown, false);
|
||||
assert.match(state.error ?? '', /synthetic status read failed/);
|
||||
fail = false; plugin.enabled = false;
|
||||
await act(async () => { await state.refresh(); });
|
||||
assert.equal(state.statusKnown, true);
|
||||
assert.equal(state.plugins[0].enabled, false);
|
||||
} finally { await act(async () => { renderer.unmount(); }); }
|
||||
});
|
||||
@@ -4,6 +4,7 @@ import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
export function usePluginManager() {
|
||||
const [plugins, setPlugins] = useState<NetcattyInstalledPlugin[]>([]);
|
||||
const [available, setAvailable] = useState(false);
|
||||
const [statusKnown, setStatusKnown] = useState(false);
|
||||
const [credentialStorageAvailable, setCredentialStorageAvailable] = useState<boolean | undefined>();
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -11,13 +12,22 @@ export function usePluginManager() {
|
||||
const mutation = useRef(false);
|
||||
const refresh = useCallback(async () => {
|
||||
const current = ++generation.current;
|
||||
const bridge = netcattyBridge.get();
|
||||
const status = await bridge?.getPluginRuntimeStatus?.();
|
||||
const list = status?.available ? await bridge?.listPlugins?.() ?? [] : [];
|
||||
if (current !== generation.current) return;
|
||||
setAvailable(status?.available === true);
|
||||
setCredentialStorageAvailable(status?.credentialStorageAvailable);
|
||||
setPlugins(list);
|
||||
try {
|
||||
const bridge = netcattyBridge.get();
|
||||
const status = await bridge?.getPluginRuntimeStatus?.();
|
||||
const list = status?.available ? await bridge?.listPlugins?.() ?? [] : [];
|
||||
if (current !== generation.current) return;
|
||||
setAvailable(status?.available === true);
|
||||
setStatusKnown(true);
|
||||
setCredentialStorageAvailable(status?.credentialStorageAvailable);
|
||||
setPlugins(list);
|
||||
} catch (cause) {
|
||||
if (current === generation.current) {
|
||||
setStatusKnown(false);
|
||||
setError(cause instanceof Error ? cause.message : String(cause));
|
||||
}
|
||||
throw cause;
|
||||
}
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
const update = () => { void refresh().catch((cause) => setError(String(cause))); };
|
||||
@@ -41,7 +51,7 @@ export function usePluginManager() {
|
||||
}
|
||||
}, [refresh]);
|
||||
return {
|
||||
plugins, available, busy, error, credentialStorageAvailable,
|
||||
plugins, available, statusKnown, busy, error, credentialStorageAvailable, refresh,
|
||||
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'] }]);
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { useEffect, useMemo, useRef, useSyncExternalStore } from 'react';
|
||||
import { useI18n } from '../i18n/I18nProvider';
|
||||
import { toast } from '../../components/ui/toast';
|
||||
import { collectSidePanelPanes, isPluginSidePanelTool, type SidePanelLayout } from '../../domain/sidePanelLayout';
|
||||
import { activeTabStore, useActiveTabId } from './activeTabStore';
|
||||
import { getSidePanelLiveSnapshot, subscribeSidePanelLiveSnapshot } from './sidePanelLiveStore';
|
||||
import { usePluginContributions } from './usePluginContributions';
|
||||
import { OPEN_PLUGIN_VIEW_EVENT, type OpenPluginViewDetail } from './usePluginViewLifecycle';
|
||||
|
||||
export const OPEN_PLUGIN_TERMINAL_SIDEBAR_EVENT = 'netcatty:open-plugin-terminal-sidebar';
|
||||
export const PLUGIN_TERMINAL_SIDEBAR_CONTEXT = Object.freeze({ 'netcatty.surface': 'terminal-sidebar' });
|
||||
/** Required production view-context projection, not a plugin-facing API. */
|
||||
export interface PluginSidebarHostActions {
|
||||
getActiveTerminalSessionId(): string | null;
|
||||
handleSwitchSidePanelTab(tool: import('../../domain/sidePanelLayout').SidePanelTool): void;
|
||||
handleWithdrawPluginSidebarPane(tabId: string, paneId: string, expectedTool: import('../../domain/sidePanelLayout').SidePanelTool): void;
|
||||
}
|
||||
export interface PluginTerminalSidebarPage {
|
||||
tool: `plugin-view:${string}`;
|
||||
viewId: string;
|
||||
pluginId: string;
|
||||
pluginName: string;
|
||||
version: string;
|
||||
title: string;
|
||||
icon?: NetcattyPluginIconReference;
|
||||
order: number;
|
||||
}
|
||||
export function collectPluginTerminalSidebarPages(plugins: NetcattyPluginContributionSnapshot['plugins']): PluginTerminalSidebarPage[] {
|
||||
return plugins.flatMap((plugin) => plugin.views.filter((view) => view.location === 'terminal-sidebar' && view.visible)
|
||||
.map((view): PluginTerminalSidebarPage => ({ tool: `plugin-view:${view.id}`, viewId: view.id, pluginId: plugin.id,
|
||||
pluginName: plugin.displayName, version: plugin.version, title: view.title, icon: view.icon, order: view.order ?? 0 })))
|
||||
.sort((a, b) => a.order - b.order || a.viewId.localeCompare(b.viewId));
|
||||
}
|
||||
export function requestOpenPluginTerminalSidebar(viewId: string, sessionId: string) {
|
||||
window.dispatchEvent(new CustomEvent(OPEN_PLUGIN_TERMINAL_SIDEBAR_EVENT, { detail: { viewId, sessionId } }));
|
||||
}
|
||||
export function usePluginTerminalSidebar(host: {
|
||||
layouts: ReadonlyMap<string, SidePanelLayout>;
|
||||
getSession(): string | null;
|
||||
isTerminalTab(tabId: string): boolean;
|
||||
select(tool: `plugin-view:${string}`): void;
|
||||
withdraw(tabId: string, paneId: string, tool: `plugin-view:${string}`): void;
|
||||
}) {
|
||||
const { locale, t } = useI18n();
|
||||
const activeTabId = useActiveTabId();
|
||||
// Follow workspace focus without subscribing the whole sidebar to cwd/title
|
||||
// or forwarding the host's live session DTO to a plugin.
|
||||
useSyncExternalStore((listener) => subscribeSidePanelLiveSnapshot(true, listener),
|
||||
() => getSidePanelLiveSnapshot(true).focusedSessionId, () => null);
|
||||
const hostRef = useRef(host); hostRef.current = host;
|
||||
const contributions = usePluginContributions({ locale, context: PLUGIN_TERMINAL_SIDEBAR_CONTEXT });
|
||||
const pages = useMemo(() => collectPluginTerminalSidebarPages(contributions.snapshot.plugins), [contributions.snapshot.plugins]);
|
||||
const sessionId = activeTabId && host.isTerminalTab(activeTabId) ? host.getSession() : null;
|
||||
useEffect(() => {
|
||||
const open = (event: Event) => {
|
||||
const detail = (event as CustomEvent<OpenPluginViewDetail & { sessionId?: string }>).detail;
|
||||
const page = pages.find((item) => item.viewId === detail?.viewId);
|
||||
if (!page) return;
|
||||
const tabId = activeTabStore.getActiveTabId();
|
||||
const current = hostRef.current.getSession();
|
||||
if (!hostRef.current.isTerminalTab(tabId) || !current || (detail.sessionId && detail.sessionId !== current)) {
|
||||
toast.info(t('plugins.sidebar.selectTerminal'));
|
||||
return;
|
||||
}
|
||||
hostRef.current.select(page.tool);
|
||||
};
|
||||
window.addEventListener(OPEN_PLUGIN_TERMINAL_SIDEBAR_EVENT, open);
|
||||
window.addEventListener(OPEN_PLUGIN_VIEW_EVENT, open);
|
||||
return () => { window.removeEventListener(OPEN_PLUGIN_TERMINAL_SIDEBAR_EVENT, open); window.removeEventListener(OPEN_PLUGIN_VIEW_EVENT, open); };
|
||||
}, [pages, t]);
|
||||
useEffect(() => {
|
||||
if (contributions.loading) return;
|
||||
const allowed = new Set(pages.map((page) => page.tool));
|
||||
for (const [tabId, layout] of host.layouts) for (const pane of collectSidePanelPanes(layout.root)) {
|
||||
if (isPluginSidePanelTool(pane.tool) && !allowed.has(pane.tool)) host.withdraw(tabId, pane.id, pane.tool);
|
||||
}
|
||||
}, [contributions.loading, host, pages]);
|
||||
return { pages, contributions, tabId: activeTabId, sessionId };
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { useEffect, useSyncExternalStore } from "react";
|
||||
import { netcattyBridge } from "../../infrastructure/services/netcattyBridge";
|
||||
import { pluginThemeRegistry } from "../../infrastructure/config/pluginThemeRegistry";
|
||||
export function usePluginThemeRevision() {
|
||||
return useSyncExternalStore(
|
||||
pluginThemeRegistry.subscribe,
|
||||
pluginThemeRegistry.getRevision,
|
||||
pluginThemeRegistry.getRevision,
|
||||
);
|
||||
}
|
||||
/** Host-owned selection; never stored in plugin KV or cloud sidecars. */
|
||||
let runtimeUsers = 0;
|
||||
let disconnectRuntime: (() => void) | undefined;
|
||||
function connectRuntime() {
|
||||
const bridge = netcattyBridge.get();
|
||||
let active = true,
|
||||
request = 0;
|
||||
const refresh = async () => {
|
||||
const current = ++request;
|
||||
try {
|
||||
const catalog = await bridge?.getPluginThemes?.();
|
||||
if (active && current === request && catalog)
|
||||
pluginThemeRegistry.setCatalog(catalog);
|
||||
} catch {
|
||||
/* Keep the last host-validated selection; never apply unvalidated data. */
|
||||
}
|
||||
};
|
||||
void refresh();
|
||||
const dispose = bridge?.onPluginThemesChanged?.(() => {
|
||||
pluginThemeRegistry.invalidate();
|
||||
void refresh();
|
||||
});
|
||||
const forced = window.matchMedia?.("(forced-colors: active)"),
|
||||
contrast = window.matchMedia?.("(prefers-contrast: more)");
|
||||
const update = () =>
|
||||
pluginThemeRegistry.setHighContrast(
|
||||
Boolean(forced?.matches || contrast?.matches),
|
||||
);
|
||||
update();
|
||||
forced?.addEventListener?.("change", update);
|
||||
contrast?.addEventListener?.("change", update);
|
||||
return () => {
|
||||
active = false;
|
||||
request++;
|
||||
dispose?.();
|
||||
forced?.removeEventListener?.("change", update);
|
||||
contrast?.removeEventListener?.("change", update);
|
||||
};
|
||||
}
|
||||
export function usePluginThemeRuntime() {
|
||||
const revision = usePluginThemeRevision();
|
||||
useEffect(() => {
|
||||
if (runtimeUsers++ === 0) disconnectRuntime = connectRuntime();
|
||||
return () => {
|
||||
if (--runtimeUsers === 0) {
|
||||
disconnectRuntime?.();
|
||||
disconnectRuntime = undefined;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
return revision;
|
||||
}
|
||||
export async function applyPluginThemeSelection(
|
||||
mode: "light" | "dark",
|
||||
choice: import("@netcatty/plugin-contract").ThemePackChoice | null,
|
||||
) {
|
||||
if (choice === null && !pluginThemeRegistry.isActive(mode))
|
||||
return pluginThemeRegistry.getCatalog();
|
||||
const bridge = netcattyBridge.require();
|
||||
if (!bridge.applyPluginTheme) throw new Error("Plugin themes unavailable");
|
||||
const next = await bridge.applyPluginTheme({
|
||||
mode,
|
||||
choice,
|
||||
expectedRevision: pluginThemeRegistry.getCatalog().selection.revision,
|
||||
});
|
||||
return next; // The host change event refreshes every window; never publish a potentially stale apply response.
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
import { activeTabStore, useActiveTabId } from './activeTabStore';
|
||||
import { collectPluginVaultPages, PLUGIN_VAULT_PAGE_PREFIX, resolvePluginVaultPage } from './pluginVaultPages';
|
||||
import { usePluginContributions } from './usePluginContributions';
|
||||
import { OPEN_PLUGIN_VIEW_EVENT, type OpenPluginViewDetail } from './usePluginViewLifecycle';
|
||||
|
||||
const CONTEXT = Object.freeze({ 'netcatty.surface': 'vault-page' });
|
||||
|
||||
export function usePluginVaultPages({ locale, section, select }: {
|
||||
locale: string;
|
||||
section: string;
|
||||
select(section: 'hosts' | `plugin-vault:${string}`): void;
|
||||
}) {
|
||||
const active = useActiveTabId() === 'vault';
|
||||
const sectionRef = useRef(section);
|
||||
const focusFrame = useRef<number | null>(null);
|
||||
sectionRef.current = section;
|
||||
useEffect(() => () => { if (focusFrame.current !== null) cancelAnimationFrame(focusFrame.current); }, []);
|
||||
const contributions = usePluginContributions({ locale, context: CONTEXT });
|
||||
const pages = useMemo(() => collectPluginVaultPages(contributions.snapshot.plugins), [contributions.snapshot.plugins]);
|
||||
const selected = resolvePluginVaultPage(pages, section);
|
||||
const fallback = useCallback(() => {
|
||||
select('hosts');
|
||||
if (focusFrame.current !== null) cancelAnimationFrame(focusFrame.current);
|
||||
focusFrame.current = requestAnimationFrame(() => {
|
||||
focusFrame.current = null;
|
||||
if (sectionRef.current === 'hosts' && activeTabStore.getActiveTabId() === 'vault') {
|
||||
document.querySelector<HTMLButtonElement>('[data-plugin-vault-fallback]')?.focus();
|
||||
}
|
||||
});
|
||||
}, [select]);
|
||||
useEffect(() => {
|
||||
if (!contributions.loading && section.startsWith(PLUGIN_VAULT_PAGE_PREFIX) && !selected) fallback();
|
||||
}, [contributions.loading, fallback, section, selected]);
|
||||
useEffect(() => {
|
||||
const open = (event: Event) => {
|
||||
const detail = (event as CustomEvent<OpenPluginViewDetail>).detail;
|
||||
const page = pages.find((item) => item.viewId === detail?.viewId);
|
||||
if (!page) return;
|
||||
activeTabStore.setActiveTabId('vault');
|
||||
select(page.key);
|
||||
};
|
||||
window.addEventListener(OPEN_PLUGIN_VIEW_EVENT, open);
|
||||
return () => window.removeEventListener(OPEN_PLUGIN_VIEW_EVENT, open);
|
||||
}, [pages, select]);
|
||||
return { pages, selected, active, select, fallback, contributions, context: CONTEXT };
|
||||
}
|
||||
@@ -82,7 +82,7 @@ export function usePluginViewLifecycle({
|
||||
const mountRef = useRef<HTMLDivElement>(null);
|
||||
const resolvedActiveView = useMemo(() => viewContributions.snapshot.plugins
|
||||
.flatMap((plugin) => plugin.views.map((view) => ({ plugin, view })))
|
||||
.find(({ view }) => view.id === effectiveRequested?.viewId && view.visible) ?? null,
|
||||
.find(({ view }) => view.id === effectiveRequested?.viewId && view.visible && !['vault-page', 'terminal-sidebar'].includes(view.location)) ?? null,
|
||||
[effectiveRequested?.viewId, viewContributions.snapshot.plugins]);
|
||||
const stableActiveViewRef = useRef<PluginViewSnapshotSelection<ResolvedPluginView> | null>(null);
|
||||
const activeView = resolvePluginViewSnapshotSelection({
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { usePluginThemeRuntime } from './usePluginThemeRuntime';
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type SetStateAction } from 'react';
|
||||
|
||||
import { runThemeTransition, type ThemeTransitionMode } from './themeTransition';
|
||||
@@ -217,6 +218,7 @@ import {
|
||||
} from './appearanceSync';
|
||||
|
||||
export const useSettingsState = (options: { enableSettingsSync?: boolean; enableSystemEffects?: boolean } = {}) => {
|
||||
const pluginThemeRevision = usePluginThemeRuntime();
|
||||
const enableSettingsSync = options.enableSettingsSync !== false;
|
||||
const enableSystemEffects = options.enableSystemEffects !== false;
|
||||
const initialCustomKeyBindingsRecord =
|
||||
@@ -1212,7 +1214,7 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
notifySettingsChanged(STORAGE_KEY_COLOR, customAccentRecord);
|
||||
}
|
||||
}
|
||||
}, [theme, resolvedTheme, lightUiThemeId, darkUiThemeId, accentMode, customAccent, customAccentRecord, notifySettingsChanged]);
|
||||
}, [theme, resolvedTheme, lightUiThemeId, darkUiThemeId, accentMode, customAccent, customAccentRecord, notifySettingsChanged, pluginThemeRevision]);
|
||||
|
||||
// Listen for OS color scheme changes to keep systemPreference in sync
|
||||
useEffect(() => {
|
||||
@@ -1979,9 +1981,10 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
}, [setTerminalSettings]);
|
||||
|
||||
const applyAppTheme = useCallback(() => {
|
||||
void pluginThemeRevision; // Invalidate the closure when host theme data changes.
|
||||
const tokens = getUiThemeById(resolvedTheme, resolvedTheme === 'dark' ? darkUiThemeId : lightUiThemeId).tokens;
|
||||
applyThemeTokens(theme, resolvedTheme, tokens, accentMode, customAccent);
|
||||
}, [theme, resolvedTheme, lightUiThemeId, darkUiThemeId, accentMode, customAccent]);
|
||||
}, [theme, resolvedTheme, lightUiThemeId, darkUiThemeId, accentMode, customAccent, pluginThemeRevision]);
|
||||
|
||||
const setEditorWordWrap = useCallback((enabled: boolean) => {
|
||||
setEditorWordWrapState(enabled);
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
import React from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { JSDOM } from 'jsdom';
|
||||
import { compile } from '@tailwindcss/node';
|
||||
import postcss from 'postcss';
|
||||
import HostDetailsPanel from './HostDetailsPanel';
|
||||
import { Card } from './ui/card';
|
||||
import { TooltipProvider } from './ui/tooltip';
|
||||
import { PluginPermissionPanel } from './settings/tabs/PluginPermissionPanel';
|
||||
import { I18nProvider } from '../application/i18n/I18nProvider';
|
||||
|
||||
test('real Host form cards, inputs and Save consume scoped security aliases without freezing an ordinary card', async () => {
|
||||
const markup = renderToStaticMarkup(<I18nProvider locale="en"><TooltipProvider>
|
||||
<HostDetailsPanel availableKeys={[]} identities={[]} groups={[]} terminalThemeId="default" terminalFontSize={14} onSave={() => assert.fail('test never saves a host')} onCancel={() => {}} />
|
||||
<PluginPermissionPanel pluginId="com.example.synthetic" onChanged={async () => {}} />
|
||||
<Card id="ordinary-card">Outside the security surface</Card>
|
||||
</TooltipProvider></I18nProvider>);
|
||||
const dom = new JSDOM(`<!doctype html><html data-plugin-theme-active><body>${markup}</body></html>`);
|
||||
try {
|
||||
const panel = dom.window.document.querySelector('[data-section="host-details-panel"]')!;
|
||||
assert.ok(panel, 'exercise the actual HostDetailsPanel and AsidePanel');
|
||||
assert.ok(panel.querySelector('.bg-card'), 'General/Address/Credentials use real Card components');
|
||||
assert.ok(panel.querySelector('input.bg-background'), 'exercise actual Input utilities');
|
||||
assert.ok(panel.querySelector('button.bg-primary'), 'exercise the actual Save button');
|
||||
const permissions = dom.window.document.querySelector('[data-host-security-surface]')!;
|
||||
assert.ok(permissions.querySelector('[role="status"]'), 'exercise the real grant-panel security root without loading or changing any grants');
|
||||
const source = await readFile(new URL('../index.css', import.meta.url), 'utf8');
|
||||
const compiler = await compile(source, {base: path.resolve('.'), onDependency() {}});
|
||||
const classes = [...new Set([...dom.window.document.querySelectorAll('[class]')].flatMap(el => [...el.classList]))];
|
||||
const css = compiler.build(classes);
|
||||
const parsed = postcss.parse(css);
|
||||
const rules: postcss.Rule[] = [];
|
||||
parsed.walkRules(rule => { rules.push(rule); });
|
||||
const rule = rules.find(node => node.selector.startsWith(':root[data-plugin-theme-active]'));
|
||||
assert.ok(rule && rule.type === 'rule');
|
||||
const matches = [...dom.window.document.querySelectorAll(rule.selector)];
|
||||
assert.ok(matches.includes(panel));
|
||||
assert.ok(matches.includes(permissions));
|
||||
assert.ok(!matches.includes(dom.window.document.getElementById('ordinary-card')!), 'security selector stays within the existing marked surfaces');
|
||||
const declarations = new Map(rule.nodes.filter(node => node.type === 'decl').map(node => [node.prop, node]));
|
||||
for (const name of ['background','foreground','card','card-foreground','popover','popover-foreground','primary','primary-foreground','secondary','secondary-foreground','muted','muted-foreground','accent','accent-foreground','destructive','destructive-foreground','border','input','ring']) {
|
||||
const declaration = declarations.get(`--color-${name}`);
|
||||
assert.equal(declaration?.value, `hsl(var(--host-security-${name}))`, `Tailwind alias ${name} cannot inherit the plugin value`);
|
||||
assert.equal(declaration.important, true);
|
||||
}
|
||||
for (const name of ['card','background','primary']) {
|
||||
const utility = rules.find(node => node.selector === `.bg-${name}`);
|
||||
assert.ok(utility && utility.type === 'rule');
|
||||
assert.ok(utility.nodes.some(node => node.type === 'decl' && node.prop === 'background-color' && node.value === `var(--color-${name})`), 'verify what the actual compiled utility consumes');
|
||||
}
|
||||
const mutedText = rules.find(node => node.selector === '.text-muted-foreground');
|
||||
assert.ok(mutedText?.nodes.some(node => node.type === 'decl' && node.prop === 'color' && node.value === 'var(--color-muted-foreground)'));
|
||||
} finally { dom.window.close(); }
|
||||
});
|
||||
@@ -882,6 +882,7 @@ echo $3 >> "$FILE"`);
|
||||
{/* Slide-out Panel */}
|
||||
{panel.type !== "closed" && (
|
||||
<AsidePanel
|
||||
dataSection="keychain-security-panel"
|
||||
open={true}
|
||||
onClose={closePanel}
|
||||
title={panelTitle}
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { JSDOM } from 'jsdom';
|
||||
|
||||
test('host dialog drives choice/input/copy and the two-phase clipboard share using real service action tokens', 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 } = await import('react');
|
||||
const { createRoot } = await import('react-dom/client');
|
||||
const { I18nProvider } = await import('../application/i18n/I18nProvider');
|
||||
const { PluginHostInteractionHost } = await import('./plugins/PluginHostInteractionHost');
|
||||
const { PluginHostInteractionService, assertInteractionRequest } = await import('../electron/plugins/hostInteractionService.cjs');
|
||||
let listener: ((event: unknown) => void) | null = null;
|
||||
let reads = 0;
|
||||
const writes: string[] = [];
|
||||
const audits: unknown[] = [];
|
||||
const sender = Object.assign(new EventEmitter(), { id: 7, isDestroyed: () => false, send(_channel: string, payload: unknown) { listener?.(payload); } });
|
||||
const owner = Object.assign(new EventEmitter(), { webContents: sender, isFocused: () => true, isVisible: () => true, isDestroyed: () => false });
|
||||
const service = new PluginHostInteractionService({
|
||||
getWindow: () => owner, isLocked: () => false,
|
||||
permissionEngine: { onDidRevoke() { return { dispose() {} }; } },
|
||||
database: { recordSecurityAudit(...args: unknown[]) { audits.push(args); } },
|
||||
quotaManager: { chargeBytes() {} },
|
||||
clipboard: { readText() { reads++; return 'synthetic private text'; }, writeText(value: string) { writes.push(value); } },
|
||||
});
|
||||
Object.assign(dom.window, { netcatty: {
|
||||
onPluginInteraction(callback: (event: unknown) => void) { listener = callback; return () => { listener = null; }; },
|
||||
async setPluginInteractionReady(ready: boolean) { return service.setReady(sender, ready); },
|
||||
async respondPluginInteraction(response: unknown) { return service.respond(sender, response); },
|
||||
} });
|
||||
const context = { pluginId: 'com.example.synthetic', pluginVersion: '1.0.0', runtimeId: 'runtime-synthetic', signal: new AbortController().signal, async assertActive() {} };
|
||||
const root = createRoot(document.getElementById('root')!);
|
||||
const settle = () => new Promise((resolve) => setTimeout(resolve, 20));
|
||||
const button = (text: string) => Array.from(document.querySelectorAll<HTMLButtonElement>('button')).find((item) => item.textContent === text)!;
|
||||
const click = async (element: HTMLElement) => { assert.ok(element); await act(async () => { element.click(); await settle(); }); };
|
||||
const begin = async (params: unknown) => {
|
||||
let promise!: Promise<unknown>;
|
||||
await act(async () => { promise = service.request(assertInteractionRequest(params), context); void promise.catch(() => {}); await settle(); });
|
||||
return { promise };
|
||||
};
|
||||
try {
|
||||
await act(async () => root.render(<I18nProvider locale="en"><PluginHostInteractionHost /></I18nProvider>));
|
||||
const choice = await begin({ kind: 'quickPick', title: 'Synthetic choice', items: [{ id: 'compose', label: 'Compose' }] });
|
||||
assert.match(document.querySelector('[role="dialog"]')?.textContent ?? '', /com.example.synthetic/);
|
||||
assert.equal(button('Continue').disabled, true);
|
||||
await click(document.querySelector<HTMLInputElement>('input[type="radio"]')!);
|
||||
await click(button('Continue'));
|
||||
assert.deepEqual(await choice.promise, { cancelled: false, value: 'compose' });
|
||||
await assert.rejects(begin({ kind: 'input', title: 'Invalid hidden newline', initialValue: 'first\nsecond' }), /invalid/);
|
||||
assert.equal(document.querySelector('[role="dialog"]'), null, 'invalid initial text never creates a misleading single-line dialog');
|
||||
const input = await begin({ kind: 'input', title: 'Synthetic input', initialValue: 'synthetic output' });
|
||||
assert.equal(document.querySelector<HTMLInputElement>('input')?.value, 'synthetic output');
|
||||
await click(button('Continue'));
|
||||
assert.deepEqual(await input.promise, { cancelled: false, value: 'synthetic output' });
|
||||
const copy = await begin({ kind: 'clipboard.write', title: 'Copy result', text: 'synthetic output' });
|
||||
assert.deepEqual(writes, []);
|
||||
assert.equal(document.querySelector('textarea')?.value, 'synthetic output');
|
||||
await click(button('Copy to clipboard'));
|
||||
assert.deepEqual(await copy.promise, { cancelled: false, value: true });
|
||||
assert.deepEqual(writes, ['synthetic output']);
|
||||
const share = await begin({ kind: 'clipboard.read', title: 'Share clipboard' });
|
||||
assert.equal(reads, 0);
|
||||
assert.equal(document.querySelector('textarea'), null);
|
||||
await click(button('Preview clipboard'));
|
||||
assert.equal(reads, 1);
|
||||
assert.equal(document.querySelector('textarea')?.value, 'synthetic private text');
|
||||
await click(button('Share with plugin'));
|
||||
assert.deepEqual(await share.promise, { cancelled: false, value: 'synthetic private text' });
|
||||
assert.equal(JSON.stringify(audits).includes('synthetic private text'), false);
|
||||
const cancelled = await begin({ kind: 'clipboard.write', title: 'Cancelled copy', text: 'must not copy' });
|
||||
await click(button('Cancel'));
|
||||
assert.deepEqual(await cancelled.promise, { cancelled: true });
|
||||
assert.deepEqual(writes, ['synthetic output']);
|
||||
assert.equal(service.pending.size, 0);
|
||||
} finally {
|
||||
await act(async () => { root.unmount(); await settle(); }); service.shutdown(); dom.window.close();
|
||||
for (const [key, descriptor] of previous) { if (descriptor) Object.defineProperty(globalThis, key, descriptor); else Reflect.deleteProperty(globalThis, key); }
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,128 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { JSDOM } from 'jsdom';
|
||||
|
||||
test('terminal extension pages use actual split panes, focus and session ownership without sharing session data', async () => {
|
||||
const dom = new JSDOM('<!doctype html><html><body><div id="root"></div></body></html>', { pretendToBeVisual: true, url: 'http://localhost' });
|
||||
const previous = new Map<string, PropertyDescriptor | undefined>();
|
||||
const install = (key: string, value: unknown) => {
|
||||
previous.set(key, Object.getOwnPropertyDescriptor(globalThis, key)); Object.defineProperty(globalThis, key, { configurable: true, writable: true, value });
|
||||
};
|
||||
for (const key of ['window', 'document', 'navigator', 'HTMLElement', 'HTMLInputElement', 'Element', 'SVGElement', 'Node', 'NodeFilter', 'MutationObserver', 'CustomEvent', 'Event', 'KeyboardEvent', 'MouseEvent']) {
|
||||
install(key, key === 'window' ? dom.window : dom.window[key as keyof typeof dom.window]);
|
||||
}
|
||||
install('PointerEvent', dom.window.MouseEvent);
|
||||
install('getComputedStyle', dom.window.getComputedStyle.bind(dom.window));
|
||||
install('requestAnimationFrame', dom.window.requestAnimationFrame.bind(dom.window)); install('cancelAnimationFrame', dom.window.cancelAnimationFrame.bind(dom.window)); install('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
let rectX = 200;
|
||||
dom.window.HTMLElement.prototype.getBoundingClientRect = () => new dom.window.DOMRect(rectX, 100, 400, 300);
|
||||
const { default: React, act, useCallback, useRef, useState } = await import('react');
|
||||
const { createRoot } = await import('react-dom/client');
|
||||
const { I18nProvider } = await import('../application/i18n/I18nProvider');
|
||||
const { activeTabStore } = await import('../application/state/activeTabStore');
|
||||
const { sidePanelLiveStore, SIDE_PANEL_INACTIVE_LIVE_SNAPSHOT } = await import('../application/state/sidePanelLiveStore');
|
||||
const { useTerminalSidePanelLayoutState } = await import('../application/state/useTerminalSidePanelLayoutState');
|
||||
const { usePluginTerminalSidebar } = await import('../application/state/usePluginTerminalSidebar');
|
||||
const { PluginTerminalSidebarLauncher, PluginTerminalSidebarPageHost } = await import('./plugins/PluginTerminalSidebar');
|
||||
const { SidePanelLayoutTree } = await import('./terminalLayer/TerminalLayerSidePanelSection');
|
||||
const { PersistentSidePanelPortal } = await import('./terminalLayer/terminalLayerSidePanelSlots');
|
||||
const { collectSidePanelPanes } = await import('../domain/sidePanelLayout');
|
||||
type Tool = import('../domain/sidePanelLayout').SidePanelTool;
|
||||
let selectedSession = 'synthetic-session-a';
|
||||
const getSession = () => selectedSession;
|
||||
const a = { id: 'com.example.sidebar.first', title: 'First page', location: 'terminal-sidebar', entry: 'page.html', visible: true };
|
||||
const b = { ...a, id: 'com.example.sidebar.second', title: 'Second page' };
|
||||
let snapshot = { locale: 'en', plugins: [{ id: 'com.example.sidebar', displayName: 'Synthetic plugin', version: '1.0.0', commands: [], keybindings: [], menus: [], settings: [], views: [a, b] }] };
|
||||
const changed = new Set<() => void>(), focusListeners = new Set<(event: { instanceId: string }) => void>();
|
||||
const opens: NetcattyPluginViewOpenRequest[] = [], closes: string[] = [], focus: string[] = [], bounds: Array<{ instanceId: string; x: number }> = [];
|
||||
const visible = new Map<string, boolean>();
|
||||
Object.assign(dom.window, { netcatty: {
|
||||
async getPluginRuntimeStatus() { return { available: true }; }, async getPluginContributions() { return snapshot; },
|
||||
onPluginContributionsChanged(fn: () => void) { changed.add(fn); return () => { changed.delete(fn); }; },
|
||||
onPluginViewClosed() { return () => {}; },
|
||||
onPluginViewFocused(fn: (event: { instanceId: string }) => void) { focusListeners.add(fn); return () => { focusListeners.delete(fn); }; },
|
||||
async openPluginView(payload: NetcattyPluginViewOpenRequest) { opens.push(payload); return { instanceId: payload.instanceId! }; },
|
||||
async closePluginView(id: string) { closes.push(id); },
|
||||
async setPluginViewBounds(id: string, rect: { x: number }) { bounds.push({ instanceId: id, x: rect.x }); },
|
||||
async setPluginViewVisibility(id: string, value: boolean) { visible.set(id, value); },
|
||||
async focusPluginView(event: { instanceId: string }) { focus.push(event.instanceId); for (const fn of focusListeners) fn(event); },
|
||||
} });
|
||||
let split!: () => void;
|
||||
function Fixture() {
|
||||
const state = useTerminalSidePanelLayoutState(); const latest = useRef(state); latest.current = state;
|
||||
const model = usePluginTerminalSidebar({ layouts: state.sidePanelLayouts, getSession, isTerminalTab: (id) => id === 'workspace-synthetic' || id === 'synthetic-local-terminal',
|
||||
select: (tool) => state.selectSidePanelToolForTab(activeTabStore.getActiveTabId(), tool),
|
||||
withdraw: (tabId, paneId, tool) => {
|
||||
const layout = state.sidePanelLayoutsRef.current.get(tabId);
|
||||
if (layout && collectSidePanelPanes(layout.root).some((pane) => pane.id === paneId && pane.tool === tool)) state.closePane(tabId, paneId);
|
||||
} });
|
||||
const [hosts, setHosts] = useState(new Map<Tool, HTMLElement>()); const parking = useRef<HTMLDivElement>(null);
|
||||
const register = useCallback((tool: Tool, host: HTMLElement | null) => setHosts((prior) => {
|
||||
if ((prior.get(tool) ?? null) === host) return prior;
|
||||
const next = new Map(prior); if (host) next.set(tool, host); else next.delete(tool); return next;
|
||||
}), []);
|
||||
const focusPane = useCallback((id: string) => latest.current.focusPane(activeTabStore.getActiveTabId(), id), []);
|
||||
const closePane = useCallback((id: string) => { latest.current.closePane(activeTabStore.getActiveTabId(), id); }, []);
|
||||
const resize = useCallback(() => {}, []);
|
||||
const layout = model.tabId ? state.sidePanelLayouts.get(model.tabId) : null;
|
||||
const panes = layout ? collectSidePanelPanes(layout.root) : [];
|
||||
split = () => state.splitPane('workspace-synthetic', `plugin-view:${b.id}`, 'vertical', { paneId: 'second-pane', splitId: 'plugin-split' }, 600);
|
||||
return <><PluginTerminalSidebarLauncher sessionId={selectedSession} />
|
||||
{layout && <SidePanelLayoutTree node={layout.root} layout={layout} paneCount={panes.length} labels={new Map(model.pages.map((page) => [page.tool, page.title]))}
|
||||
onClose={closePane} onFocus={focusPane} onHostChange={register} onResize={resize} separator="#777" accent="#4488cc" closePaneLabel="Close pane" resizeLabel="Resize" />}
|
||||
<div ref={parking} hidden aria-hidden="true" />
|
||||
{model.sessionId && panes.map((pane) => {
|
||||
const page = model.pages.find((item) => item.tool === pane.tool); const target = hosts.get(pane.tool) ?? parking.current;
|
||||
if (!page || !target) return null;
|
||||
return <PersistentSidePanelPortal key={`${pane.id}:${model.sessionId}`} portalKey={`plugin-${pane.id}`} target={target}>
|
||||
<PluginTerminalSidebarPageHost page={page} tabId={model.tabId!} sessionId={model.sessionId} getSession={getSession} adapter={model.contributions}
|
||||
focusOnReveal={layout?.focusedPaneId === pane.id} focused={() => focusPane(pane.id)} unavailable={() => closePane(pane.id)} />
|
||||
</PersistentSidePanelPortal>;
|
||||
})}</>;
|
||||
}
|
||||
const root = createRoot(document.getElementById('root')!); const settle = () => new Promise((resolve) => setTimeout(resolve, 40));
|
||||
try {
|
||||
activeTabStore.setActiveTabId('workspace-synthetic'); sidePanelLiveStore.update({ ...SIDE_PANEL_INACTIVE_LIVE_SNAPSHOT, focusedSessionId: selectedSession });
|
||||
await act(async () => { root.render(<I18nProvider locale="en"><Fixture /></I18nProvider>); await settle(); });
|
||||
await act(async () => { document.querySelector<HTMLButtonElement>('button[aria-label="Extension pages"]')!.click(); await settle(); });
|
||||
const firstButton = [...document.querySelectorAll('button')].find((button) => button.textContent?.includes('First page'))!;
|
||||
await act(async () => { firstButton.click(); await settle(); });
|
||||
assert.equal(document.querySelectorAll('[data-section="terminal-side-panel-pane"]').length, 1);
|
||||
assert.ok(document.querySelector('[data-pane-tool] [data-plugin-view-slot="terminal-sidebar"]'));
|
||||
assert.equal(opens.length, 1); assert.deepEqual(opens[0].context, { 'netcatty.surface': 'terminal-sidebar' });
|
||||
await act(async () => { split(); await settle(); });
|
||||
assert.equal(document.querySelectorAll('[data-section="terminal-side-panel-pane"]').length, 2);
|
||||
assert.equal(opens.length, 2);
|
||||
const firstId = opens.find((item) => item.viewId === a.id)!.instanceId!;
|
||||
await act(async () => { for (const fn of focusListeners) fn({ instanceId: firstId }); await settle(); });
|
||||
assert.equal(document.querySelector(`[data-pane-tool="plugin-view:${a.id}"]`)?.getAttribute('data-focused'), 'true');
|
||||
const dialog = document.createElement('div'); dialog.setAttribute('role', 'dialog'); dialog.setAttribute('aria-modal', 'true');
|
||||
await act(async () => { document.body.append(dialog); await settle(); });
|
||||
assert.ok(opens.every((item) => visible.get(item.instanceId!) === false));
|
||||
focus.length = 0;
|
||||
await act(async () => { dialog.remove(); await settle(); });
|
||||
assert.ok(focus.length > 0); assert.ok(focus.every((id) => id === firstId), 'restoring panes focuses only the host-selected pane');
|
||||
const old = opens.map((item) => item.instanceId!);
|
||||
await act(async () => { selectedSession = 'synthetic-session-b'; sidePanelLiveStore.update({ ...SIDE_PANEL_INACTIVE_LIVE_SNAPSHOT, focusedSessionId: selectedSession });
|
||||
assert.ok(old.every((id) => closes.includes(id)), 'workspace focus cancels old native owners synchronously'); await settle(); });
|
||||
assert.equal(opens.length, 4);
|
||||
assert.notEqual(opens[0].scopeId, opens[2].scopeId);
|
||||
assert.ok(opens.every((item) => JSON.stringify(item.context) === JSON.stringify({ 'netcatty.surface': 'terminal-sidebar' })));
|
||||
await act(async () => { rectX = 260; snapshot = { ...snapshot, plugins: [{ ...snapshot.plugins[0], views: [{ ...a, visible: false }, b] }] }; for (const fn of changed) fn(); await settle(); });
|
||||
assert.equal(document.querySelectorAll('[data-section="terminal-side-panel-pane"]').length, 1);
|
||||
assert.equal(document.querySelector('[data-pane-tool]')?.getAttribute('data-pane-tool'), `plugin-view:${b.id}`);
|
||||
assert.ok(bounds.some((item) => item.x === 260), 'portal relocation updates native geometry');
|
||||
const last = opens.at(-1)!.instanceId!;
|
||||
await act(async () => { activeTabStore.setActiveTabId('vault'); assert.ok(closes.includes(last)); await settle(); });
|
||||
assert.equal(document.querySelector('[data-plugin-terminal-sidebar]'), null);
|
||||
await act(async () => { selectedSession = 'synthetic-local-terminal'; activeTabStore.setActiveTabId(selectedSession); await settle(); });
|
||||
await act(async () => { document.querySelector<HTMLButtonElement>('button[aria-label="Extension pages"]')!.click(); await settle(); });
|
||||
const localPage = [...document.querySelectorAll('button')].find((button) => button.textContent?.includes('Second page'))!;
|
||||
await act(async () => { localPage.click(); await settle(); });
|
||||
assert.ok(document.querySelector('[data-plugin-view-slot="terminal-sidebar"]'), 'standalone Local Terminal is a valid navigation owner');
|
||||
assert.deepEqual(opens.at(-1)!.context, { 'netcatty.surface': 'terminal-sidebar' });
|
||||
} finally {
|
||||
await act(async () => { root.unmount(); await settle(); }); dom.window.close();
|
||||
for (const [key, descriptor] of previous) { if (descriptor) Object.defineProperty(globalThis, key, descriptor); else Reflect.deleteProperty(globalThis, key); }
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { JSDOM } from 'jsdom';
|
||||
|
||||
test('Vault navigation mounts a real content slot, yields to host dialogs, and withdraws stale pages', async () => {
|
||||
const dom = new JSDOM('<!doctype html><html><body><div id="root"></div></body></html>', { pretendToBeVisual: true, url: 'http://localhost' });
|
||||
const previous = new Map<string, PropertyDescriptor | undefined>();
|
||||
for (const key of ['window', 'document', 'navigator', 'HTMLElement', 'Element', 'Node', 'MutationObserver', 'CustomEvent', 'Event']) {
|
||||
previous.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
|
||||
Object.defineProperty(globalThis, key, { configurable: true, writable: true, value: key === 'window' ? dom.window : dom.window[key as keyof Window] });
|
||||
}
|
||||
for (const [key, value] of Object.entries({ getComputedStyle: dom.window.getComputedStyle.bind(dom.window),
|
||||
requestAnimationFrame: dom.window.requestAnimationFrame.bind(dom.window), cancelAnimationFrame: dom.window.cancelAnimationFrame.bind(dom.window), IS_REACT_ACT_ENVIRONMENT: true })) {
|
||||
previous.set(key, Object.getOwnPropertyDescriptor(globalThis, key)); Object.defineProperty(globalThis, key, { configurable: true, writable: true, value });
|
||||
}
|
||||
dom.window.HTMLElement.prototype.getBoundingClientRect = () => ({ x: 200, y: 100, width: 640, height: 480, top: 100, left: 200, right: 840, bottom: 580, toJSON() {} });
|
||||
const { default: React, act, useState } = await import('react');
|
||||
const { createRoot } = await import('react-dom/client');
|
||||
const { usePluginVaultPages } = await import('../application/state/usePluginVaultPages');
|
||||
const { PluginVaultNavigation, PluginVaultPage } = await import('./plugins/PluginVaultPages');
|
||||
const { activeTabStore } = await import('../application/state/activeTabStore');
|
||||
const { requestOpenPluginView } = await import('../application/state/usePluginViewLifecycle');
|
||||
const { I18nProvider } = await import('../application/i18n/I18nProvider');
|
||||
const page = { id: 'com.example.page.main', title: 'Synthetic page', location: 'vault-page', entry: 'page.html', visible: true };
|
||||
let snapshot = { locale: 'en', plugins: [{ id: 'com.example.page', version: '1.0.0', displayName: 'Synthetic plugin', commands: [], keybindings: [], menus: [], settings: [], views: [page] }] };
|
||||
let changed: (() => void) | undefined;
|
||||
const listeners = new Set<(event: NetcattyPluginViewClosedEvent) => void>();
|
||||
const opens: NetcattyPluginViewOpenRequest[] = [], closes: string[] = [], visibility: boolean[] = [];
|
||||
let defer: ((value: { instanceId: string }) => void) | undefined;
|
||||
let deferOpen = false;
|
||||
let delayLayout = false;
|
||||
const rejectOldLayout: Array<(error: Error) => void> = [];
|
||||
const layoutResult = () => delayLayout ? new Promise<void>((_resolve, reject) => { rejectOldLayout.push(reject); }) : Promise.resolve();
|
||||
Object.assign(dom.window, { netcatty: {
|
||||
async getPluginRuntimeStatus() { return { available: true }; },
|
||||
async getPluginContributions() { return snapshot; },
|
||||
onPluginContributionsChanged(fn: () => void) { changed = fn; return () => { changed = undefined; }; },
|
||||
onPluginViewClosed(fn: (event: NetcattyPluginViewClosedEvent) => void) { listeners.add(fn); return () => { listeners.delete(fn); }; },
|
||||
async openPluginView(payload: NetcattyPluginViewOpenRequest) { opens.push(payload); return deferOpen ? new Promise<{ instanceId: string }>((resolve) => { defer = resolve; }) : { instanceId: payload.instanceId! }; },
|
||||
async closePluginView(id: string) { closes.push(id); }, async setPluginViewBounds() { await layoutResult(); },
|
||||
async setPluginViewVisibility(_id: string, visible: boolean) { visibility.push(visible); await layoutResult(); },
|
||||
} });
|
||||
let selectSection!: (section: 'hosts' | `plugin-vault:${string}`) => void;
|
||||
function Fixture() {
|
||||
const [section, select] = useState<'hosts' | `plugin-vault:${string}`>('hosts'); selectSection = select;
|
||||
const model = usePluginVaultPages({ locale: 'en', section, select });
|
||||
return <><aside><button data-plugin-vault-fallback onClick={() => select('hosts')}>Hosts</button><PluginVaultNavigation model={model} collapsed={false} /></aside>
|
||||
<main data-section="vault-main">{model.selected ? <PluginVaultPage key={model.selected.key} model={model} /> : <p>Built-in hosts</p>}</main></>;
|
||||
}
|
||||
const root = createRoot(document.getElementById('root')!);
|
||||
const settle = () => new Promise((resolve) => setTimeout(resolve, 35));
|
||||
const clickPage = async () => { await act(async () => { document.querySelector<HTMLButtonElement>('button[aria-label="Synthetic page — Synthetic plugin"]')!.click(); await settle(); }); };
|
||||
try {
|
||||
activeTabStore.setActiveTabId('vault');
|
||||
await act(async () => { root.render(<I18nProvider locale="en"><Fixture /></I18nProvider>); await settle(); });
|
||||
assert.equal(opens.length, 0, 'catalog enumeration does not activate the plugin');
|
||||
await clickPage();
|
||||
assert.ok(document.querySelector('[data-section="vault-main"] [data-plugin-view-slot="vault-page"]'));
|
||||
assert.equal(opens.length, 1);
|
||||
assert.deepEqual(opens[0].context, { 'netcatty.surface': 'vault-page' }, 'no Vault/session data accompanies navigation');
|
||||
assert.equal(visibility.at(-1), true);
|
||||
const dialog = document.createElement('div'); dialog.setAttribute('role', 'dialog'); dialog.setAttribute('aria-modal', 'true');
|
||||
await act(async () => { document.body.append(dialog); await settle(); });
|
||||
assert.equal(visibility.at(-1), false, 'native content yields to host security/settings dialogs');
|
||||
await act(async () => { dialog.remove(); await settle(); });
|
||||
assert.equal(visibility.at(-1), true);
|
||||
await act(async () => { activeTabStore.setActiveTabId('terminal:synthetic'); assert.ok(closes.includes(opens[0].instanceId!)); await settle(); });
|
||||
await act(async () => { activeTabStore.setActiveTabId('vault'); await settle(); });
|
||||
assert.equal(opens.length, 2);
|
||||
await act(async () => { snapshot = { ...snapshot, plugins: [] }; changed?.(); await settle(); });
|
||||
await act(async () => { await settle(); });
|
||||
assert.ok(closes.includes(opens[1].instanceId!));
|
||||
assert.equal(document.querySelector('[data-plugin-vault-page]'), null);
|
||||
assert.equal(document.activeElement?.textContent, 'Hosts');
|
||||
await act(async () => { selectSection('plugin-vault:missing'); await settle(); });
|
||||
assert.match(document.querySelector('main')!.textContent!, /Built-in hosts/);
|
||||
await act(async () => { snapshot = { ...snapshot, plugins: [{ id: 'com.example.page', version: '1.0.0', displayName: 'Synthetic plugin', commands: [], keybindings: [], menus: [], settings: [], views: [page] }] }; changed?.(); await settle(); });
|
||||
deferOpen = true;
|
||||
await act(async () => { requestOpenPluginView({ viewId: page.id }); await settle(); });
|
||||
assert.equal(opens.length, 3);
|
||||
await act(async () => { selectSection('hosts'); await settle(); });
|
||||
assert.ok(closes.includes(opens[2].instanceId!), 'cancel reserved instance before its open response');
|
||||
await act(async () => { defer!({ instanceId: opens[2].instanceId! }); await settle(); });
|
||||
assert.equal(document.querySelector('[data-plugin-vault-page]'), null, 'late open cannot restore withdrawn page');
|
||||
assert.ok(closes.filter((id) => id === opens[2].instanceId).length >= 2, 'late native response is also closed');
|
||||
deferOpen = false;
|
||||
const second = { ...page, id: 'com.example.page.second', title: 'Second page' };
|
||||
await act(async () => { snapshot = { ...snapshot, plugins: [{ ...snapshot.plugins[0], views: [page, second] }] }; changed?.(); await settle(); });
|
||||
delayLayout = true;
|
||||
await clickPage();
|
||||
assert.ok(rejectOldLayout.length >= 2, 'old page has both bounds and visibility IPC in flight');
|
||||
await act(async () => { selectSection('hosts'); await settle(); });
|
||||
delayLayout = false;
|
||||
await act(async () => { document.querySelector<HTMLButtonElement>('button[aria-label="Second page — Synthetic plugin"]')!.click(); await settle(); });
|
||||
await act(async () => { for (const reject of rejectOldLayout) reject(new Error('old page layout failed')); await settle(); });
|
||||
assert.equal(document.querySelector('[data-plugin-vault-page]')?.getAttribute('aria-label'), 'Second page', 'old bounds/visibility failures cannot withdraw the newly selected page');
|
||||
} finally {
|
||||
await act(async () => { root.unmount(); await settle(); }); dom.window.close();
|
||||
for (const [key, descriptor] of previous) { if (descriptor) Object.defineProperty(globalThis, key, descriptor); else Reflect.deleteProperty(globalThis, key); }
|
||||
}
|
||||
});
|
||||
@@ -1,3 +1,5 @@
|
||||
import { useAppThemeStyle } from "../application/app/useAppThemeStyle";
|
||||
import { selectPluginThemeTokens } from "../application/state/pluginContributionEnvironment";
|
||||
/**
|
||||
* Settings Page - Standalone settings window content
|
||||
* This component is rendered in a separate Electron window
|
||||
@@ -322,6 +324,9 @@ const SettingsSyncTabWithVault: React.FC<{ onSettingsApplied?: () => void }> = (
|
||||
};
|
||||
|
||||
const SettingsPageContent: React.FC<{ settings: SettingsState; appLock?: AppLockState }> = ({ settings, appLock }) => {
|
||||
// This publisher also runs in detached Settings: never broadcast a local preview.
|
||||
const committedAppThemeStyle = useAppThemeStyle(false);
|
||||
const pluginThemeTokens = useMemo(() => selectPluginThemeTokens(committedAppThemeStyle as Record<string, unknown>), [committedAppThemeStyle]);
|
||||
const { t } = useI18n();
|
||||
const { request, clearFocus, openSearch } = useSettingsFocus();
|
||||
const { notifyRendererReady, closeSettingsWindow, onWindowCommandCloseRequested } = useWindowControls();
|
||||
@@ -715,7 +720,7 @@ const SettingsPageContent: React.FC<{ settings: SettingsState; appLock?: AppLock
|
||||
</Tabs>
|
||||
</div>
|
||||
<ExternalMcpApprovalsHost />
|
||||
<PluginContributionHost locale={settings.uiLanguage} theme={settings.resolvedTheme} />
|
||||
<PluginContributionHost locale={settings.uiLanguage} theme={settings.resolvedTheme} themeTokens={pluginThemeTokens} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -123,6 +123,7 @@ import {
|
||||
import { classifyDistroId, hostRestrictsExtraSshChannels, shouldProbeSessionCwd } from '../domain/host';
|
||||
import {
|
||||
collectSidePanelPanes,
|
||||
isPluginSidePanelTool,
|
||||
sidePanelLayoutHasTool,
|
||||
type SidePanelLayout,
|
||||
type SidePanelSplitDirection,
|
||||
@@ -1933,6 +1934,16 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
resizeSidePanelSplitForTab(tabId, splitId, sizes);
|
||||
}, [resizeSidePanelSplitForTab]);
|
||||
|
||||
const handleWithdrawPluginSidebarPane = useCallback((tabId: string, paneId: string, expectedTool: SidePanelTab) => {
|
||||
if (!isPluginSidePanelTool(expectedTool)) return;
|
||||
const layout = sidePanelLayoutsRef.current.get(tabId);
|
||||
const pane = layout && collectSidePanelPanes(layout.root).find((item) => item.id === paneId);
|
||||
if (pane?.tool !== expectedTool) return;
|
||||
setMagnifiedPane((current) => current?.tabId === tabId && current.target.kind === 'side-panel'
|
||||
&& current.target.paneId === paneId ? null : current);
|
||||
closeSidePanelPaneForTab(tabId, paneId);
|
||||
}, [closeSidePanelPaneForTab, sidePanelLayoutsRef]);
|
||||
|
||||
// Toggle SFTP from activity bar header
|
||||
const handleToggleSftpFromBar = useCallback(() => {
|
||||
handleSwitchSidePanelTab('sftp');
|
||||
@@ -2617,6 +2628,9 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
handleOpenAI,
|
||||
handleOpenSystem,
|
||||
handleOpenNotes,
|
||||
getActiveTerminalSessionId,
|
||||
handleSwitchSidePanelTab,
|
||||
handleWithdrawPluginSidebarPane,
|
||||
handleOpenBrowser,
|
||||
handleFocusSidePanelPane,
|
||||
handleMagnifySidePanelPane,
|
||||
|
||||
@@ -161,6 +161,7 @@ import { VaultViewLayout } from "./vault/VaultViewLayout";
|
||||
import { useVaultHostCollections } from "./vault/useVaultHostCollections";
|
||||
import { useVaultImportHandlers } from "../application/state/useVaultImportHandlers";
|
||||
import { useVaultGroupDragHandlers } from "./vault/useVaultGroupDragHandlers";
|
||||
import { usePluginVaultPages } from "../application/state/usePluginVaultPages";
|
||||
|
||||
const LazyProtocolSelectDialog = lazy(() => import("./ProtocolSelectDialog"));
|
||||
const LazyConnectionLogsManager = lazy(() => import("./ConnectionLogsManager"));
|
||||
@@ -178,7 +179,8 @@ export type VaultSection =
|
||||
| "notes"
|
||||
| "port"
|
||||
| "knownhosts"
|
||||
| "logs";
|
||||
| "logs"
|
||||
| `plugin-vault:${string}`;
|
||||
|
||||
const haveSameHostOrderResult = (previous: Host[], next: Host[]) => {
|
||||
if (previous.length !== next.length) return false;
|
||||
@@ -365,11 +367,12 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
onVaultFocusRequestHandled,
|
||||
terminalSettings,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const { t, locale } = useI18n();
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const hostsRef = useRef(hosts);
|
||||
hostsRef.current = hosts;
|
||||
const [currentSection, setCurrentSection] = useState<VaultSection>("hosts");
|
||||
const pluginVault = usePluginVaultPages({ locale, section: currentSection, select: setCurrentSection });
|
||||
const [search, setSearch] = useState("");
|
||||
const [selectedGroupPath, setSelectedGroupPath] = useState<string | null>(
|
||||
null,
|
||||
@@ -1522,6 +1525,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
ContextMenuTrigger,
|
||||
Copy,
|
||||
currentSection,
|
||||
pluginVault,
|
||||
customGroups,
|
||||
deleteGroupPath,
|
||||
deleteGroupPaths,
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import React, { useState } from 'react';
|
||||
import { PLUGIN_HOST_INTERACTION_LIMITS } from '@netcatty/plugin-contract';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import { usePluginHostInteractions } from '../../application/state/usePluginHostInteractions';
|
||||
import { Button } from '../ui/button';
|
||||
import { Input } from '../ui/input';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '../ui/dialog';
|
||||
|
||||
export function PluginHostInteractionHost() {
|
||||
const state = usePluginHostInteractions();
|
||||
if (!state.prompt) return null;
|
||||
return <HostInteractionDialog key={state.prompt.requestId} state={state} prompt={state.prompt} />;
|
||||
}
|
||||
|
||||
function HostInteractionDialog({ state, prompt }: {
|
||||
state: ReturnType<typeof usePluginHostInteractions>;
|
||||
prompt: NonNullable<ReturnType<typeof usePluginHostInteractions>['prompt']>;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const [search, setSearch] = useState('');
|
||||
const request = prompt.request;
|
||||
const previewClipboard = request.kind === 'clipboard.read' && prompt.phase === 'request';
|
||||
const textBytes = new TextEncoder().encode(state.value).length;
|
||||
const canSubmit = request.kind === 'quickPick' ? request.items.some((item) => item.id === state.value) : textBytes <= PLUGIN_HOST_INTERACTION_LIMITS.textBytes;
|
||||
return <Dialog open onOpenChange={(open) => { if (!open) void state.respond('cancel'); }}>
|
||||
<DialogContent className="sm:max-w-[520px]" hideCloseButton>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('plugins.interaction.title')}</DialogTitle>
|
||||
<DialogDescription className="break-all">{prompt.pluginId} · {prompt.pluginVersion}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<p className="break-words text-sm font-medium">{request.title}</p>
|
||||
<p className="text-xs text-muted-foreground">{t('plugins.interaction.sharing')}</p>
|
||||
{request.kind === 'input' && <Input aria-label={t('plugins.interaction.input')} value={state.value} maxLength={PLUGIN_HOST_INTERACTION_LIMITS.textBytes} autoComplete="off" autoFocus disabled={state.busy} onChange={(event) => state.setValue(event.target.value)} />}
|
||||
{request.kind === 'quickPick' && <>
|
||||
<Input aria-label={t('plugins.interaction.filter')} value={search} onChange={(event) => setSearch(event.target.value)} autoFocus disabled={state.busy} />
|
||||
<div className="max-h-64 space-y-2 overflow-y-auto" role="radiogroup" aria-label={request.title}>
|
||||
{request.items.filter((item) => `${item.label} ${item.description ?? ''}`.toLocaleLowerCase().includes(search.toLocaleLowerCase())).map((item) => <label key={item.id} className="flex items-start gap-3 rounded-md border p-3">
|
||||
<input type="radio" name="plugin-host-choice" value={item.id} checked={state.value === item.id} disabled={state.busy} onChange={() => state.setValue(item.id)} />
|
||||
<span className="min-w-0 break-words text-sm">{item.label}{item.description && <span className="block text-xs text-muted-foreground">{item.description}</span>}</span>
|
||||
</label>)}
|
||||
</div>
|
||||
</>}
|
||||
{previewClipboard && <p className="text-sm">{t('plugins.interaction.previewNote')}</p>}
|
||||
{(request.kind === 'clipboard.write' || prompt.phase === 'preview') && <textarea aria-label={t('plugins.interaction.preview')} readOnly className="min-h-28 max-h-64 w-full resize-none rounded-md border bg-muted/30 p-3 font-mono text-xs" value={request.kind === 'clipboard.write' ? request.text : prompt.previewText ?? ''} />}
|
||||
{state.error && <p role="alert" className="text-sm text-destructive">{t('plugins.interaction.failed')}</p>}
|
||||
<DialogFooter>
|
||||
<Button variant="outline" disabled={state.busy} onClick={() => void state.respond('cancel')}>{t('common.cancel')}</Button>
|
||||
<Button disabled={state.busy || !canSubmit} onClick={() => void state.respond(previewClipboard ? 'preview' : 'submit')}>
|
||||
{t(previewClipboard ? 'plugins.interaction.previewAction' : request.kind === 'clipboard.read' ? 'plugins.interaction.share' : request.kind === 'clipboard.write' ? 'plugins.interaction.copy' : 'common.continue')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>;
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Blocks } from 'lucide-react';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import { activeTabStore } from '../../application/state/activeTabStore';
|
||||
import { subscribeSidePanelLiveSnapshot } from '../../application/state/sidePanelLiveStore';
|
||||
import { useEmbeddedPluginView } from '../../application/state/useEmbeddedPluginView';
|
||||
import { usePluginContributions } from '../../application/state/usePluginContributions';
|
||||
import { collectPluginTerminalSidebarPages, PLUGIN_TERMINAL_SIDEBAR_CONTEXT, requestOpenPluginTerminalSidebar,
|
||||
type PluginTerminalSidebarPage } from '../../application/state/usePluginTerminalSidebar';
|
||||
import { PluginContributionIcon } from './PluginContributionIcon';
|
||||
import { Button } from '../ui/button';
|
||||
import { Popover, PopoverClose, PopoverContent, PopoverTrigger } from '../ui/popover';
|
||||
|
||||
export function PluginTerminalSidebarLauncher({ sessionId }: { sessionId: string }) {
|
||||
const { locale, t } = useI18n();
|
||||
const contributions = usePluginContributions({ locale, context: PLUGIN_TERMINAL_SIDEBAR_CONTEXT });
|
||||
const pages = collectPluginTerminalSidebarPages(contributions.snapshot.plugins);
|
||||
if (!pages.length) return null;
|
||||
return <Popover><PopoverTrigger asChild><Button variant="ghost" size="icon" className="h-6 w-6" aria-label={t('plugins.sidebar.extensions')}><Blocks size={14} /></Button></PopoverTrigger>
|
||||
<PopoverContent align="end" className="max-h-80 w-64 overflow-y-auto p-1">
|
||||
<p className="px-2 py-1 text-xs text-muted-foreground">{t('plugins.sidebar.extensions')}</p>
|
||||
{pages.map((page) => <PopoverClose asChild key={page.tool}><button type="button" className="flex w-full items-center gap-2 rounded px-2 py-2 text-left text-sm hover:bg-accent"
|
||||
onClick={() => requestOpenPluginTerminalSidebar(page.viewId, sessionId)}>
|
||||
<PluginContributionIcon pluginId={page.pluginId} icon={page.icon} /><span className="min-w-0"><span className="block truncate">{page.title}</span><span className="block truncate text-xs text-muted-foreground">{page.pluginName}</span></span>
|
||||
</button></PopoverClose>)}
|
||||
</PopoverContent></Popover>;
|
||||
}
|
||||
|
||||
export function PluginTerminalSidebarPageHost({ page, tabId, sessionId, adapter, getSession, unavailable, focused, focusOnReveal }: {
|
||||
page: PluginTerminalSidebarPage;
|
||||
tabId: string;
|
||||
sessionId: string;
|
||||
adapter: ReturnType<typeof usePluginContributions>;
|
||||
getSession(): string | null;
|
||||
unavailable(): void;
|
||||
focused(): void;
|
||||
focusOnReveal: boolean;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const subscribeHidden = useMemo(() => (hide: () => void) => {
|
||||
const stopTab = activeTabStore.subscribeSync((id) => { if (id !== tabId) hide(); });
|
||||
const stopSession = subscribeSidePanelLiveSnapshot(true, () => { if (getSession() !== sessionId) hide(); });
|
||||
return () => { stopTab(); stopSession(); };
|
||||
}, [getSession, sessionId, tabId]);
|
||||
const { mountRef, loading } = useEmbeddedPluginView({ viewId: page.viewId, version: page.version,
|
||||
slot: `terminal-sidebar:${tabId}:${sessionId}`, active: true, context: PLUGIN_TERMINAL_SIDEBAR_CONTEXT,
|
||||
adapter, subscribeHidden, unavailable, focused, focusOnReveal });
|
||||
// The native page never receives these internal ownership IDs. Its state is
|
||||
// scoped by the host; this does not authorize reading the terminal session.
|
||||
return <section className="flex h-full min-h-0 flex-col overflow-hidden" aria-label={`${page.title} — ${page.pluginName}`} data-plugin-terminal-sidebar>
|
||||
<header className="flex h-7 shrink-0 items-center gap-2 border-b border-border px-2 text-xs"><PluginContributionIcon pluginId={page.pluginId} icon={page.icon} size={12} /><span className="truncate">{page.title} · {page.pluginName}</span></header>
|
||||
<div ref={mountRef} className="relative min-h-0 flex-1 overflow-hidden" data-plugin-view-slot="terminal-sidebar">
|
||||
{loading && <p role="status" className="p-3 text-xs text-muted-foreground">{t('plugins.sidebar.loading')}</p>}
|
||||
</div>
|
||||
</section>;
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { activeTabStore } from '../../application/state/activeTabStore';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import { useEmbeddedPluginView } from '../../application/state/useEmbeddedPluginView';
|
||||
import type { usePluginVaultPages } from '../../application/state/usePluginVaultPages';
|
||||
import { PluginContributionIcon } from './PluginContributionIcon';
|
||||
import { Button } from '../ui/button';
|
||||
|
||||
type VaultPages = ReturnType<typeof usePluginVaultPages>;
|
||||
const subscribeHidden = (hide: () => void) => activeTabStore.subscribeSync((id) => { if (id !== 'vault') hide(); });
|
||||
|
||||
export function PluginVaultNavigation({ model, collapsed }: { model: VaultPages; collapsed: boolean }) {
|
||||
const { t } = useI18n();
|
||||
if (!model.pages.length) return null;
|
||||
return <div role="group" aria-label={t('plugins.vault.extensions')} className="mt-3 space-y-1 border-t border-border/50 pt-3">
|
||||
{!collapsed && <p className="px-2 text-xs text-muted-foreground">{t('plugins.vault.extensions')}</p>}
|
||||
{model.pages.map((page) => <Button key={page.key} type="button"
|
||||
variant={model.selected?.key === page.key ? 'secondary' : 'ghost'}
|
||||
className={`w-full ${collapsed ? 'justify-center px-0' : 'justify-start gap-3'}`}
|
||||
title={`${page.title} — ${page.pluginName}`}
|
||||
aria-label={`${page.title} — ${page.pluginName}`}
|
||||
aria-current={model.selected?.key === page.key ? 'page' : undefined}
|
||||
onClick={() => model.select(page.key)}>
|
||||
<PluginContributionIcon pluginId={page.pluginId} icon={page.icon} className="shrink-0" />
|
||||
{!collapsed && <span className="truncate">{page.title}</span>}
|
||||
</Button>)}
|
||||
</div>;
|
||||
}
|
||||
|
||||
export function PluginVaultPage({ model }: { model: VaultPages }) {
|
||||
const { t } = useI18n();
|
||||
const page = model.selected!;
|
||||
const { mountRef, loading } = useEmbeddedPluginView({ viewId: page.viewId, version: page.version, slot: 'vault-page',
|
||||
active: model.active, context: model.context, adapter: model.contributions, subscribeHidden, unavailable: model.fallback });
|
||||
return <section className="flex min-h-0 flex-1 flex-col" aria-label={page.title} data-plugin-vault-page={page.key}>
|
||||
<header className="app-no-drag flex h-12 shrink-0 items-center justify-between gap-3 border-b border-border px-4">
|
||||
<div className="min-w-0"><h2 className="truncate text-sm font-medium">{page.title}</h2>
|
||||
<p className="truncate text-xs text-muted-foreground">{page.pluginName} · {page.version}</p></div>
|
||||
<Button variant="ghost" onClick={model.fallback}>{t('plugins.vault.back')}</Button>
|
||||
</header>
|
||||
<div ref={mountRef} className="relative min-h-0 flex-1 overflow-hidden" data-plugin-view-slot="vault-page">
|
||||
{loading && <p role="status" className="p-4 text-sm text-muted-foreground">{t('plugins.vault.loading')}</p>}
|
||||
</div>
|
||||
</section>;
|
||||
}
|
||||
@@ -53,6 +53,11 @@ test('installed grid filters, opens one detail and restores search, scroll and c
|
||||
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.root.findByType('input').props.onChange({ target: { value: '' } }));
|
||||
await act(async () => renderer.update(<I18nProvider locale="en"><PluginPackageManager manager={{ ...manager, statusKnown: false, error: 'synthetic state unavailable' }} onSelectionChange={(id) => selections.push(id)} /></I18nProvider>));
|
||||
const unknown = renderer.root.findAllByType('button').filter((node) => node.children.includes('Status unknown'));
|
||||
assert.equal(unknown.length, 2);
|
||||
assert.ok(unknown.every((node) => node.props.disabled === true && node.props['aria-pressed'] === undefined));
|
||||
await act(async () => renderer.unmount());
|
||||
} finally { globals.IS_REACT_ACT_ENVIRONMENT = previous; globalThis.requestAnimationFrame = raf; }
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { JSDOM } from 'jsdom';
|
||||
|
||||
test('local logging controls default off, disclose verbose risk, and call host-only management actions', 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', 'Event', 'MouseEvent', 'MutationObserver', 'NodeFilter', 'DocumentFragment', 'CustomEvent', 'getComputedStyle']) {
|
||||
install(key, key === 'window' ? dom.window : dom.window[key as keyof typeof dom.window]);
|
||||
}
|
||||
install('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
const { default: React, act } = await import('react');
|
||||
const { createRoot } = await import('react-dom/client');
|
||||
const { I18nProvider } = await import('../../../application/i18n/I18nProvider');
|
||||
const { PluginLogSettings } = await import('./PluginLogSettings');
|
||||
const root = createRoot(document.getElementById('root')!);
|
||||
let status = { saveToDisk: false, verbose: false, droppedRecords: 2, writeErrors: 0, pendingBytes: 0, maxBytes: 3145728, retentionDays: 7 };
|
||||
const settings: Array<{ saveToDisk: boolean; verbose: boolean }> = [];
|
||||
const actions: string[] = [];
|
||||
let failConfigure = false;
|
||||
Object.assign(dom.window, { netcatty: {
|
||||
async getPluginLogStatus() { return status; },
|
||||
async configurePluginLogs(value: { saveToDisk: boolean; verbose: boolean }) { settings.push(value); status = { ...status, ...value }; if (failConfigure) throw new Error('synthetic preferences write failed'); return status; },
|
||||
async openPluginLogFolder() { actions.push('open'); return status; },
|
||||
async exportPluginLogs() { actions.push('export'); return status; },
|
||||
async clearPluginLogs() { actions.push('clear-confirmed-by-native-host'); return status; },
|
||||
} });
|
||||
const button = (label: string) => [...document.querySelectorAll<HTMLButtonElement>('button')].find((item) => item.textContent === label)!;
|
||||
const checkboxes = () => [...document.querySelectorAll<HTMLInputElement>('input[type="checkbox"]')];
|
||||
try {
|
||||
await act(async () => root.render(<React.StrictMode><I18nProvider locale="en"><PluginLogSettings /></I18nProvider></React.StrictMode>));
|
||||
assert.equal(checkboxes()[0].checked, false);
|
||||
assert.equal(checkboxes()[0].disabled, false, 'StrictMode initialization completes');
|
||||
assert.equal(checkboxes()[1].disabled, true);
|
||||
assert.match(document.body.textContent!, /no upload or sync/);
|
||||
assert.match(document.body.textContent!, /Dropped: 2/);
|
||||
await act(async () => checkboxes()[0].click());
|
||||
assert.deepEqual(settings, [{ saveToDisk: true, verbose: false }]);
|
||||
await act(async () => checkboxes()[1].click());
|
||||
assert.equal(settings.length, 1, 'opening disclosure does not enable verbose logs');
|
||||
assert.match(document.body.textContent!, /cannot make arbitrary text safe/);
|
||||
await act(async () => button('Enable temporarily').click());
|
||||
assert.deepEqual(settings[1], { saveToDisk: true, verbose: true });
|
||||
await act(async () => button('Open folder').click());
|
||||
await act(async () => button('Export').click());
|
||||
await act(async () => button('Clear logs…').click());
|
||||
assert.deepEqual(actions, ['open', 'export', 'clear-confirmed-by-native-host']);
|
||||
failConfigure = true;
|
||||
await act(async () => checkboxes()[0].click());
|
||||
assert.deepEqual(settings.at(-1), { saveToDisk: false, verbose: false });
|
||||
assert.equal(checkboxes()[0].checked, false, 'failed configuration refreshes actual OFF host state');
|
||||
assert.equal(checkboxes()[1].checked, false);
|
||||
assert.match(document.querySelector('[role=alert]')!.textContent!, /synthetic preferences write failed/);
|
||||
} 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,31 @@
|
||||
import React, { useState } from 'react';
|
||||
import { usePluginLogs } from '../../../application/state/usePluginLogs';
|
||||
import { useI18n } from '../../../application/i18n/I18nProvider';
|
||||
import { Button } from '../../ui/button';
|
||||
import { ConfirmDialog } from '../../ui/confirm-dialog';
|
||||
|
||||
/** Embed in the existing plugin settings surface, without a new marketplace tab. */
|
||||
export function PluginLogSettings() {
|
||||
const { resolvedLocale } = useI18n();
|
||||
const zh = resolvedLocale.startsWith('zh');
|
||||
const { status, error, busy, run } = usePluginLogs();
|
||||
const [confirmVerbose, setConfirmVerbose] = useState(false);
|
||||
const disclosure = zh
|
||||
? '插件编写的日志文字可能包含敏感数据。截断和字段过滤不能保证任意文字安全。只为排查问题临时开启,导出前请检查。重启应用后自动关闭详细日志。'
|
||||
: 'Plugin-authored text may contain sensitive data. Truncation and field filtering cannot make arbitrary text safe. Enable temporarily for troubleshooting and review files before sharing. Verbose diagnostics turn off after app restart.';
|
||||
return <section data-host-security-surface="" className="space-y-3 rounded-lg border border-border/70 p-4 text-sm" aria-label={zh ? '本地插件日志' : 'Local plugin logs'}>
|
||||
<h4 className="font-medium">{zh ? '本地插件日志' : 'Local plugin logs'}</h4>
|
||||
<p className="text-xs text-muted-foreground">{zh ? '默认不保存。仅保存在此设备,不上传或同步。权限面板中的有限安全历史独立保留。' : 'Saving is off by default. Files stay on this device with no upload or sync. The bounded security history in plugin permissions is separate.'}</p>
|
||||
{error && <p role="alert" className="text-xs text-destructive">{error}</p>}
|
||||
<label className="flex items-center gap-2"><input type="checkbox" checked={status?.saveToDisk ?? false} disabled={busy || !status} onChange={(event) => void run('configure', { saveToDisk: event.target.checked, verbose: false })} />{zh ? '将宿主审计日志保存到本地' : 'Save host audit logs locally'}</label>
|
||||
<label className="flex items-center gap-2"><input type="checkbox" checked={status?.verbose ?? false} disabled={busy || !status?.saveToDisk} onChange={(event) => event.target.checked ? setConfirmVerbose(true) : void run('configure', { saveToDisk: true, verbose: false })} />{zh ? '包含插件详细诊断文字(可能含敏感数据)' : 'Include verbose plugin text (may contain sensitive data)'}</label>
|
||||
{status && <p className="text-xs text-muted-foreground">{zh ? `最多 ${status.maxBytes / 1048576} MiB,最长保留 ${status.retentionDays} 天。丢弃 ${status.droppedRecords} 条,写入错误 ${status.writeErrors} 次。` : `Up to ${status.maxBytes / 1048576} MiB, up to ${status.retentionDays} days. Dropped: ${status.droppedRecords}. Write errors: ${status.writeErrors}.`}</p>}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="outline" size="sm" disabled={busy || !status} onClick={() => void run('open')}>{zh ? '打开文件夹' : 'Open folder'}</Button>
|
||||
<Button variant="outline" size="sm" disabled={busy || !status} onClick={() => void run('export')}>{zh ? '导出' : 'Export'}</Button>
|
||||
<Button variant="ghost" size="sm" disabled={busy || !status} onClick={() => void run('clear')}>{zh ? '清除日志…' : 'Clear logs…'}</Button>
|
||||
<Button variant="ghost" size="sm" disabled={busy} onClick={() => void run('status')}>{zh ? '刷新' : 'Refresh'}</Button>
|
||||
</div>
|
||||
<ConfirmDialog open={confirmVerbose} title={zh ? '开启插件详细诊断?' : 'Enable verbose plugin diagnostics?'} message={disclosure} confirmLabel={zh ? '临时开启' : 'Enable temporarily'} onOpenChange={setConfirmVerbose} onConfirm={() => { setConfirmVerbose(false); void run('configure', { saveToDisk: true, verbose: true }); }} />
|
||||
</section>;
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describePluginPermission } from '@/domain/pluginPermissionDescriptions.cjs';
|
||||
import { describePluginRuntime } from '@/domain/pluginRuntimePolicy.cjs';
|
||||
import React, { useRef, useState } from 'react';
|
||||
import { PluginPermissionPanel } from './PluginPermissionPanel';
|
||||
import { PluginBrandIcon } from '../../plugins/PluginBrandIcon';
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
import { usePluginManager } from '../../../application/state/usePluginManager';
|
||||
@@ -27,7 +28,7 @@ export function PluginPackageDetails({ plugin }: { plugin: NetcattyInstalledPlug
|
||||
<dt className="text-muted-foreground">API</dt><dd>{manifest.engines.api}</dd>
|
||||
<dt className="text-muted-foreground">SHA-256</dt><dd className="break-all font-mono">{plugin.archiveSha256 ?? '-'}</dd>
|
||||
</dl>
|
||||
<p className={!policy.browserOnly ? 'text-destructive' : 'text-muted-foreground'}>{t(`settings.plugins.manager.${runtimeLabel}`)}</p>
|
||||
<p className={!policy.browserOnly && !policy.dataOnly ? 'text-destructive' : 'text-muted-foreground'}>{policy.dataOnly ? t('settings.plugins.manager.dataTheme') : t(`settings.plugins.manager.${runtimeLabel}`)}</p>
|
||||
<div className="space-y-2">
|
||||
<h4 className="font-medium">{t('settings.plugins.manager.permissions')}</h4>
|
||||
<p className="text-xs text-muted-foreground">{t('settings.plugins.manager.permissionNote')}</p>
|
||||
@@ -89,13 +90,13 @@ export function PluginPackageManager({ manager, onSelectionChange }: { manager:
|
||||
|
||||
</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')}
|
||||
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 ${manager.statusKnown !== false && plugin.enabled ? 'bg-primary/10 hover:bg-primary/15' : 'bg-muted/60 text-muted-foreground hover:bg-muted hover:text-foreground'}`}
|
||||
style={manager.statusKnown !== false && plugin.enabled ? { color: 'color-mix(in srgb, hsl(var(--primary)) 40%, hsl(var(--foreground)))' } : undefined}
|
||||
aria-pressed={manager.statusKnown === false ? undefined : plugin.enabled} aria-busy={manager.busy}
|
||||
aria-label={`${t(manager.statusKnown === false ? 'settings.plugins.permissions.statusUnknown' : plugin.enabled ? 'settings.plugins.manager.disable' : 'settings.plugins.manager.enable')} ${plugin.manifest?.name ?? plugin.id}`}
|
||||
title={t(manager.statusKnown === false ? 'settings.plugins.permissions.statusUnknown' : plugin.enabled ? 'settings.plugins.manager.disable' : 'settings.plugins.manager.enable')}
|
||||
disabled={manager.busy || manager.statusKnown === false} onClick={() => void manager.setEnabled(plugin.id, !plugin.enabled)}>
|
||||
{t(manager.statusKnown === false ? 'settings.plugins.permissions.statusUnknown' : plugin.enabled ? 'settings.plugins.manager.enabled' : 'settings.plugins.manager.disabled')}
|
||||
</Button>
|
||||
</div>
|
||||
{plugin.manifest?.publisher && <p className="break-words text-xs text-muted-foreground">{plugin.manifest.publisher}</p>}
|
||||
@@ -107,6 +108,7 @@ export function PluginPackageManager({ manager, onSelectionChange }: { manager:
|
||||
{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} />
|
||||
<PluginPermissionPanel key={plugin.id} pluginId={plugin.id} onChanged={async () => { await manager.refresh(); }} />
|
||||
{removing === plugin.id ? <div className="space-y-2 rounded-lg border border-destructive/30 p-3">
|
||||
<p className="text-xs">{t('settings.plugins.manager.removeNote')}</p>
|
||||
<Button variant="destructive" size="sm" disabled={manager.busy} onClick={() => { void manager.uninstall(plugin.id); setRemoving(null); }}>{t('settings.plugins.manager.remove')}</Button>
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { JSDOM } from 'jsdom';
|
||||
|
||||
test('permission panel refreshes real grants, revokes once, and keeps stale reads from replacing new results', 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', 'Element', 'Node', 'Event', 'MouseEvent']) {
|
||||
install(key, key === 'window' ? dom.window : dom.window[key as keyof typeof dom.window]);
|
||||
}
|
||||
install('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
const { default: React, act } = await import('react');
|
||||
const { createRoot } = await import('react-dom/client');
|
||||
const { I18nProvider } = await import('../../../application/i18n/I18nProvider');
|
||||
const { PluginPermissionPanel } = await import('./PluginPermissionPanel');
|
||||
const root = createRoot(document.getElementById('root')!);
|
||||
const initial = {
|
||||
grants: [{ permission: 'network', resource: 'https://synthetic.invalid/<text>', resourceKind: 'exact', scope: 'always' as const, sessionId: null, grantedAt: 1, matchesCurrentActivation: false }],
|
||||
audit: [{ event: 'permission.used', createdAt: 2, permission: 'network' }],
|
||||
};
|
||||
let reads = 0;
|
||||
let revokes = 0;
|
||||
let changed = 0;
|
||||
let staleRead: (value: typeof initial) => void = () => {};
|
||||
let releaseRevoke: () => void = () => {};
|
||||
const revokePending = new Promise<void>((resolve) => { releaseRevoke = resolve; });
|
||||
Object.assign(dom.window, { netcatty: {
|
||||
async getPluginPermissions(id: string) {
|
||||
assert.equal(id, 'com.example.synthetic');
|
||||
reads++;
|
||||
if (reads === 2) return new Promise<typeof initial>((resolve) => { staleRead = resolve; });
|
||||
return reads > 2 ? { grants: [], audit: [] } : initial;
|
||||
},
|
||||
async revokePluginPermissions(id: string) { assert.equal(id, 'com.example.synthetic'); revokes++; await revokePending; },
|
||||
} });
|
||||
const button = (text: string) => Array.from(document.querySelectorAll<HTMLButtonElement>('button')).find((item) => item.textContent === text)!;
|
||||
try {
|
||||
await act(async () => root.render(<I18nProvider locale="en"><PluginPermissionPanel pluginId="com.example.synthetic" onChanged={async () => { changed++; }} /></I18nProvider>));
|
||||
assert.match(document.body.textContent!, /synthetic.invalid/);
|
||||
assert.equal(document.querySelector('text'), null, 'resource is rendered as plain text');
|
||||
assert.match(document.body.textContent!, /Until revoked/);
|
||||
assert.match(document.body.textContent!, /Inactive for the current activation/);
|
||||
await act(async () => { button('Refresh').click(); });
|
||||
await act(async () => {
|
||||
button('Disable and revoke all grants').click();
|
||||
button('Disable and revoke all grants').click();
|
||||
});
|
||||
assert.equal(revokes, 1);
|
||||
assert.equal(button('Refresh').disabled, true);
|
||||
await act(async () => { releaseRevoke(); });
|
||||
assert.equal(changed, 1);
|
||||
assert.match(document.body.textContent!, /No saved grants/);
|
||||
await act(async () => { staleRead(initial); });
|
||||
assert.doesNotMatch(document.body.textContent!, /synthetic.invalid/);
|
||||
Object.assign(dom.window, { netcatty: { async getPluginPermissions() { throw new Error('synthetic read failed'); } } });
|
||||
await act(async () => { button('Refresh').click(); });
|
||||
assert.match(document.querySelector('[role="alert"]')?.textContent ?? '', /synthetic read failed/);
|
||||
} 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);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('partial revocation failure refreshes actual manager and engine state without clearing the original error', async () => {
|
||||
const dom = new JSDOM('<!doctype html><html><body><div id="root"></div></body></html>', { pretendToBeVisual: true, url: 'http://localhost' });
|
||||
const previous = new Map<string, PropertyDescriptor | undefined>();
|
||||
for (const key of ['window', 'document', 'navigator', 'HTMLElement', 'Element', 'Node', 'Event', 'MouseEvent', 'IS_REACT_ACT_ENVIRONMENT']) {
|
||||
previous.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
|
||||
Object.defineProperty(globalThis, key, { configurable: true, writable: true, value: key === 'window' ? dom.window : key === 'IS_REACT_ACT_ENVIRONMENT' ? true : dom.window[key as keyof typeof dom.window] });
|
||||
}
|
||||
const { act } = await import('react');
|
||||
const { createRoot } = await import('react-dom/client');
|
||||
const { I18nProvider } = await import('../../../application/i18n/I18nProvider');
|
||||
const { PluginPermissionPanel } = await import('./PluginPermissionPanel');
|
||||
const { PluginManager } = await import('../../../electron/plugins/pluginManager.cjs');
|
||||
const { PluginPermissionEngine } = await import('../../../electron/plugins/permissionEngine.cjs');
|
||||
const { defaultSecurityPrincipal, normalizePermissionDeclarations, permissionDeclarationHash } = await import('../../../electron/plugins/permissionResources.cjs');
|
||||
const manifest = { id: 'com.example.synthetic', publisher: 'example', permissions: { optional: ['storage'] } };
|
||||
const principal = defaultSecurityPrincipal(manifest);
|
||||
const grant = { pluginId: manifest.id, permission: 'storage', resource: '*', resourceKind: 'exact', grantedAt: 1,
|
||||
declarationHash: permissionDeclarationHash(normalizePermissionDeclarations(manifest).get('storage'), principal) };
|
||||
let enabled = true, running = true, stopped = false, readFails = false;
|
||||
let grants = [grant];
|
||||
let reads = 0, changed = 0;
|
||||
const database = {
|
||||
getActivePlugin: () => ({ id: manifest.id, enabled, manifest }),
|
||||
listPlugins: () => [{ id: manifest.id, enabled, manifest }],
|
||||
setEnabled(_id: string, value: boolean) { enabled = value; },
|
||||
listPermissionGrants: () => grants,
|
||||
deleteAllPermissionGrants() { grants = []; },
|
||||
recordSecurityAudit() { throw new Error('synthetic audit write failed'); },
|
||||
};
|
||||
const engine = new PluginPermissionEngine({ database });
|
||||
const manager = new PluginManager({ database, permissionEngine: engine,
|
||||
packageStore: { async initialize() {} },
|
||||
contributionService: { async initialize() {}, onPluginDisabled() {} },
|
||||
runtimeSupervisor: { async stop() { running = false; stopped = true; } },
|
||||
});
|
||||
Object.assign(dom.window, { netcatty: {
|
||||
async getPluginPermissions() {
|
||||
reads++;
|
||||
if (readFails) throw new Error('synthetic grants refresh failed');
|
||||
const groups = engine.describeGrants(manifest.id, running ? { pluginId: manifest.id, manifest, securityPrincipal: principal } : null);
|
||||
return { grants: groups.always, audit: [] };
|
||||
},
|
||||
async revokePluginPermissions(id: string) { return manager.revokePermissions(id); },
|
||||
} });
|
||||
const root = createRoot(document.getElementById('root')!);
|
||||
const refreshManager = async () => { changed++; assert.equal((await manager.list())[0].enabled, false); };
|
||||
const revoke = () => Array.from(document.querySelectorAll<HTMLButtonElement>('button')).find((item) => item.textContent === 'Disable and revoke all grants')!;
|
||||
try {
|
||||
await act(async () => root.render(<I18nProvider locale="en"><PluginPermissionPanel key="first" pluginId={manifest.id} onChanged={refreshManager} /></I18nProvider>));
|
||||
assert.match(document.body.textContent!, /Matches the current activation/);
|
||||
await act(async () => { revoke().click(); });
|
||||
assert.equal(enabled, false); assert.equal(stopped, true);
|
||||
assert.equal(changed, 1); assert.equal(reads, 2);
|
||||
assert.match(document.querySelector('[role="alert"]')?.textContent ?? '', /synthetic audit write failed/);
|
||||
assert.match(document.body.textContent!, /No saved grants/);
|
||||
assert.doesNotMatch(document.body.textContent!, /Matches the current activation/);
|
||||
// A failed state read must also remove the old grant snapshot while
|
||||
// retaining the mutation error and reporting the refresh failure.
|
||||
enabled = true; running = true; stopped = false; grants = [grant];
|
||||
await act(async () => root.render(<I18nProvider locale="en"><PluginPermissionPanel key="second" pluginId={manifest.id} onChanged={async () => { await refreshManager(); readFails = true; throw new Error('synthetic manager refresh failed'); }} /></I18nProvider>));
|
||||
assert.match(document.body.textContent!, /Matches the current activation/);
|
||||
await act(async () => { revoke().click(); });
|
||||
assert.equal(enabled, false); assert.equal(stopped, true);
|
||||
assert.equal(changed, 2); assert.equal(reads, 4);
|
||||
const error = document.querySelector('[role="alert"]')?.textContent ?? '';
|
||||
assert.match(error, /synthetic audit write failed/);
|
||||
assert.match(error, /synthetic manager refresh failed/);
|
||||
assert.match(error, /synthetic grants refresh failed/);
|
||||
assert.doesNotMatch(document.body.textContent!, /Matches the current activation/);
|
||||
} finally {
|
||||
await act(async () => root.unmount()); engine.shutdown(); dom.window.close();
|
||||
for (const [key, descriptor] of previous) { if (descriptor) Object.defineProperty(globalThis, key, descriptor); else Reflect.deleteProperty(globalThis, key); }
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { netcattyBridge } from '../../../infrastructure/services/netcattyBridge';
|
||||
import { useI18n } from '../../../application/i18n/I18nProvider';
|
||||
import { describePluginPermission } from '../../../domain/pluginPermissionDescriptions.cjs';
|
||||
import { Button } from '../../ui/button';
|
||||
|
||||
/** Host UI only. Plugin views cannot query or mutate this security state. */
|
||||
export function PluginPermissionPanel({ pluginId, onChanged }: {
|
||||
pluginId: string;
|
||||
onChanged: () => Promise<void>;
|
||||
}) {
|
||||
const { t, resolvedLocale } = useI18n();
|
||||
const [snapshot, setSnapshot] = useState<NetcattyPluginPermissionSnapshot | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const generation = useRef(0);
|
||||
const pending = useRef(false);
|
||||
const refresh = useCallback(async (preserveError = false) => {
|
||||
const current = ++generation.current;
|
||||
if (!preserveError) setError(null);
|
||||
try {
|
||||
const bridge = netcattyBridge.require();
|
||||
if (!bridge.getPluginPermissions) throw new Error(t('settings.plugins.permissions.unavailable'));
|
||||
const result = await bridge.getPluginPermissions(pluginId);
|
||||
if (current === generation.current) setSnapshot(result);
|
||||
} catch (cause) {
|
||||
if (current === generation.current) {
|
||||
setSnapshot(null);
|
||||
const message = cause instanceof Error ? cause.message : String(cause);
|
||||
setError((previous) => preserveError && previous ? `${previous}\n${message}` : message);
|
||||
}
|
||||
}
|
||||
}, [pluginId, t]);
|
||||
useEffect(() => {
|
||||
const requestGeneration = generation;
|
||||
void refresh();
|
||||
return () => { requestGeneration.current++; };
|
||||
}, [refresh]);
|
||||
const revoke = async () => {
|
||||
if (pending.current) return;
|
||||
pending.current = true;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const bridge = netcattyBridge.require();
|
||||
if (!bridge.revokePluginPermissions) throw new Error(t('settings.plugins.permissions.unavailable'));
|
||||
await bridge.revokePluginPermissions(pluginId);
|
||||
} catch (cause) { setError(cause instanceof Error ? cause.message : String(cause)); }
|
||||
finally {
|
||||
try { await onChanged(); }
|
||||
catch (cause) {
|
||||
const message = cause instanceof Error ? cause.message : String(cause);
|
||||
setError((previous) => previous ? `${previous}\n${message}` : message);
|
||||
}
|
||||
await refresh(true);
|
||||
pending.current = false; setBusy(false);
|
||||
}
|
||||
};
|
||||
return <section data-host-security-surface="" className="space-y-3 border-t border-border/60 pt-4 text-sm" aria-label={t('settings.plugins.permissions.title')}>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<h4 className="font-medium">{t('settings.plugins.permissions.title')}</h4>
|
||||
<Button variant="ghost" size="sm" disabled={busy} onClick={() => void refresh()}>{t('settings.plugins.permissions.refresh')}</Button>
|
||||
</div>
|
||||
{error && <p role="alert" className="text-xs text-destructive">{error}</p>}
|
||||
{!snapshot && !error && <p role="status">{t('settings.plugins.permissions.loading')}</p>}
|
||||
{snapshot && <>
|
||||
{snapshot.grants.length === 0 && <p className="text-xs text-muted-foreground">{t('settings.plugins.permissions.empty')}</p>}
|
||||
{snapshot.grants.map((grant, index) => <div key={index} className="rounded-md bg-muted/40 p-3 text-xs">
|
||||
<p className="font-medium">{describePluginPermission(grant.permission, resolvedLocale).label}</p>
|
||||
<p className="whitespace-pre-wrap break-all font-mono">{grant.resource}</p>
|
||||
<p>{t(grant.matchesCurrentActivation ? 'settings.plugins.permissions.current' : 'settings.plugins.permissions.inactive')}</p>
|
||||
<p>{t(`settings.plugins.permissions.${grant.scope}`)}{grant.sessionId ? ` · ${grant.sessionId}` : ''}</p>
|
||||
<time dateTime={new Date(grant.grantedAt).toISOString()}>{new Date(grant.grantedAt).toLocaleString(resolvedLocale)}</time>
|
||||
</div>)}
|
||||
<details className="text-xs">
|
||||
<summary className="cursor-pointer">{t('settings.plugins.permissions.audit')}</summary>
|
||||
<ol className="mt-2 space-y-1">{snapshot.audit.map((entry, index) => <li key={index} className="break-words">
|
||||
<time dateTime={new Date(entry.createdAt).toISOString()}>{new Date(entry.createdAt).toLocaleString(resolvedLocale)}</time>{' · '}
|
||||
{entry.event}{entry.permission ? ` · ${entry.permission}` : ''}{entry.scope ? ` · ${entry.scope}` : ''}
|
||||
</li>)}</ol>
|
||||
</details>
|
||||
<p className="text-xs text-muted-foreground">{t('settings.plugins.permissions.revokeNote')}</p>
|
||||
<Button variant="destructive" size="sm" disabled={busy} onClick={() => void revoke()}>{t('settings.plugins.permissions.revoke')}</Button>
|
||||
</>}
|
||||
</section>;
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { JSDOM } from "jsdom";
|
||||
import type {
|
||||
ThemePackCatalog,
|
||||
ThemePackEntry,
|
||||
} from "@netcatty/plugin-contract";
|
||||
|
||||
test("host theme picker previews without persistence, applies, restores, and cancels stale owners safely", async () => {
|
||||
const dom = new JSDOM(
|
||||
'<!doctype html><html><body><div id="root"></div></body></html>',
|
||||
{ url: "http://localhost", pretendToBeVisual: true },
|
||||
);
|
||||
const previous = new Map<string, PropertyDescriptor | undefined>();
|
||||
for (const key of [
|
||||
"window",
|
||||
"document",
|
||||
"navigator",
|
||||
"HTMLElement",
|
||||
"Element",
|
||||
"Node",
|
||||
"Event",
|
||||
"MouseEvent",
|
||||
"IS_REACT_ACT_ENVIRONMENT",
|
||||
]) {
|
||||
previous.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
|
||||
Object.defineProperty(globalThis, key, {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value:
|
||||
key === "IS_REACT_ACT_ENVIRONMENT"
|
||||
? true
|
||||
: key === "window"
|
||||
? dom.window
|
||||
: dom.window[key as keyof typeof dom.window],
|
||||
});
|
||||
}
|
||||
const { default: React, act, useLayoutEffect } = await import("react");
|
||||
const { createRoot } = await import("react-dom/client");
|
||||
const { I18nProvider } = await import(
|
||||
"../../../application/i18n/I18nProvider"
|
||||
);
|
||||
const { PluginThemePicker } = await import("./PluginThemePicker");
|
||||
const { pluginThemeRegistry: registry } = await import(
|
||||
"../../../infrastructure/config/pluginThemeRegistry"
|
||||
);
|
||||
const { usePluginThemeRevision } = await import(
|
||||
"../../../application/state/usePluginThemeRuntime"
|
||||
);
|
||||
const { getUiThemeById } = await import(
|
||||
"../../../infrastructure/config/uiThemes"
|
||||
);
|
||||
const { applyThemeTokens } = await import(
|
||||
"../../../application/state/settingsStateDefaults"
|
||||
);
|
||||
const entry: ThemePackEntry = {
|
||||
ref: {
|
||||
pluginId: "com.example.theme",
|
||||
themeId: "com.example.theme.forest",
|
||||
version: "1.0.0",
|
||||
digest: "a".repeat(64),
|
||||
variant: "light",
|
||||
},
|
||||
label: "Forest",
|
||||
pluginName: "Synthetic Theme",
|
||||
dataOnly: true,
|
||||
palette: { formatVersion: 1, base: "light", colors: { accent: "#166534" } },
|
||||
};
|
||||
let writes = 0,
|
||||
restores = 0;
|
||||
let catalog: ThemePackCatalog = {
|
||||
selection: {
|
||||
formatVersion: 1,
|
||||
revision: registry.getCatalog().selection.revision + 1,
|
||||
light: null,
|
||||
dark: null,
|
||||
},
|
||||
entries: [entry],
|
||||
diagnostics: [],
|
||||
};
|
||||
registry.setCatalog(catalog);
|
||||
Object.assign(dom.window, {
|
||||
netcatty: {
|
||||
async applyPluginTheme(request: {
|
||||
expectedRevision: number;
|
||||
choice: ThemePackCatalog["selection"]["light"];
|
||||
}) {
|
||||
assert.equal(request.expectedRevision, catalog.selection.revision);
|
||||
writes++;
|
||||
catalog = {
|
||||
...catalog,
|
||||
selection: {
|
||||
...catalog.selection,
|
||||
revision: catalog.selection.revision + 1,
|
||||
light: request.choice,
|
||||
},
|
||||
};
|
||||
registry.setCatalog(catalog);
|
||||
return catalog;
|
||||
},
|
||||
},
|
||||
});
|
||||
function Fixture() {
|
||||
const revision = usePluginThemeRevision();
|
||||
useLayoutEffect(
|
||||
() =>
|
||||
applyThemeTokens(
|
||||
"light",
|
||||
"light",
|
||||
getUiThemeById("light", "snow").tokens,
|
||||
"theme",
|
||||
"",
|
||||
),
|
||||
[revision],
|
||||
);
|
||||
return (
|
||||
<I18nProvider locale="en">
|
||||
<PluginThemePicker mode="light" onRestore={() => restores++} />
|
||||
</I18nProvider>
|
||||
);
|
||||
}
|
||||
const root = createRoot(document.getElementById("root")!);
|
||||
const button = (label: string) =>
|
||||
[...document.querySelectorAll<HTMLButtonElement>("button")].find(
|
||||
(b) => b.textContent === label,
|
||||
)!;
|
||||
try {
|
||||
await act(async () => root.render(<Fixture />));
|
||||
const original =
|
||||
document.documentElement.style.getPropertyValue("--primary");
|
||||
await act(async () => {
|
||||
const select = document.querySelector("select")!;
|
||||
select.value = `${entry.ref.pluginId}/${entry.ref.themeId}`;
|
||||
select.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
});
|
||||
await act(async () => button("Preview").click());
|
||||
assert.equal(writes, 0);
|
||||
assert.equal(
|
||||
getUiThemeById("light", "snow", false).tokens.primary,
|
||||
original,
|
||||
"local preview does not enter the global plugin view environment",
|
||||
);
|
||||
assert.notEqual(
|
||||
document.documentElement.style.getPropertyValue("--primary"),
|
||||
original,
|
||||
);
|
||||
assert.ok(
|
||||
document.documentElement.hasAttribute("data-plugin-theme-active"),
|
||||
);
|
||||
assert.ok(
|
||||
document.documentElement.style.getPropertyValue(
|
||||
"--host-security-primary",
|
||||
),
|
||||
);
|
||||
await act(async () => button("Cancel preview").click());
|
||||
assert.equal(
|
||||
document.documentElement.style.getPropertyValue("--primary"),
|
||||
original,
|
||||
);
|
||||
assert.equal(writes, 0);
|
||||
await act(async () => {
|
||||
button("Preview").click();
|
||||
button("Apply").click();
|
||||
});
|
||||
assert.equal(writes, 1);
|
||||
assert.equal(catalog.selection.light?.ref.themeId, entry.ref.themeId);
|
||||
await act(async () => button("Restore default").click());
|
||||
assert.equal(writes, 2);
|
||||
assert.equal(restores, 1);
|
||||
assert.equal(catalog.selection.light, null);
|
||||
await act(async () => {
|
||||
registry.startPreview("old-owner", entry);
|
||||
registry.startPreview("new-owner", entry);
|
||||
registry.cancelPreview("old-owner");
|
||||
});
|
||||
assert.notEqual(
|
||||
document.documentElement.style.getPropertyValue("--primary"),
|
||||
original,
|
||||
);
|
||||
await act(async () =>
|
||||
registry.setCatalog({
|
||||
...catalog,
|
||||
selection: {
|
||||
...catalog.selection,
|
||||
revision: catalog.selection.revision + 1,
|
||||
},
|
||||
entries: [],
|
||||
}),
|
||||
);
|
||||
assert.equal(
|
||||
document.documentElement.style.getPropertyValue("--primary"),
|
||||
original,
|
||||
"package withdrawal cancels previews",
|
||||
);
|
||||
await act(async () =>
|
||||
registry.setCatalog({
|
||||
...catalog,
|
||||
selection: {
|
||||
...catalog.selection,
|
||||
revision: catalog.selection.revision + 2,
|
||||
light: { ref: entry.ref },
|
||||
},
|
||||
entries: [],
|
||||
}),
|
||||
);
|
||||
assert.equal(getUiThemeById("light", "snow").id, "host-plugin-fallback");
|
||||
await act(async () => registry.setHighContrast(true));
|
||||
assert.equal(getUiThemeById("light", "snow").id, "host-high-contrast");
|
||||
} finally {
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
registry.setHighContrast(false);
|
||||
registry.setCatalog({
|
||||
...catalog,
|
||||
selection: {
|
||||
formatVersion: 1,
|
||||
revision: registry.getCatalog().selection.revision + 1,
|
||||
light: null,
|
||||
dark: null,
|
||||
},
|
||||
entries: [],
|
||||
});
|
||||
});
|
||||
dom.window.close();
|
||||
for (const [key, descriptor] of previous) {
|
||||
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
|
||||
else Reflect.deleteProperty(globalThis, key);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,166 @@
|
||||
import { useEffect, useId, useState } from "react";
|
||||
import { useI18n } from "../../../application/i18n/I18nProvider";
|
||||
import {
|
||||
applyPluginThemeSelection,
|
||||
usePluginThemeRevision,
|
||||
} from "../../../application/state/usePluginThemeRuntime";
|
||||
import { pluginThemeRegistry } from "../../../infrastructure/config/pluginThemeRegistry";
|
||||
import { Button } from "../../ui/button";
|
||||
|
||||
export function PluginThemePicker({
|
||||
mode,
|
||||
onRestore,
|
||||
}: {
|
||||
mode: "light" | "dark";
|
||||
onRestore: () => void;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
usePluginThemeRevision();
|
||||
const owner = useId();
|
||||
const [selected, setSelected] = useState(""),
|
||||
[pending, setPending] = useState(false),
|
||||
[error, setError] = useState("");
|
||||
const catalog = pluginThemeRegistry.getCatalog();
|
||||
const entries = catalog.entries.filter((e) => e.palette.base === mode);
|
||||
const applied = catalog.selection[mode];
|
||||
const appliedAvailable =
|
||||
applied &&
|
||||
entries.some(
|
||||
(e) =>
|
||||
e.ref.pluginId === applied.ref.pluginId &&
|
||||
e.ref.themeId === applied.ref.themeId &&
|
||||
e.ref.version === applied.ref.version &&
|
||||
e.ref.digest === applied.ref.digest,
|
||||
);
|
||||
const entry = entries.find(
|
||||
(e) => `${e.ref.pluginId}/${e.ref.themeId}` === selected,
|
||||
);
|
||||
useEffect(
|
||||
() => () => pluginThemeRegistry.cancelPreview(owner),
|
||||
[owner, mode],
|
||||
);
|
||||
async function apply(restore = false) {
|
||||
if (pending || (!restore && !entry)) return;
|
||||
setPending(true);
|
||||
setError("");
|
||||
try {
|
||||
await applyPluginThemeSelection(
|
||||
mode,
|
||||
restore ? null : { ref: entry!.ref },
|
||||
);
|
||||
pluginThemeRegistry.cancelPreview(owner);
|
||||
if (restore) {
|
||||
setSelected("");
|
||||
onRestore();
|
||||
}
|
||||
} catch (e) {
|
||||
setError(
|
||||
e instanceof Error ? e.message : t("settings.plugins.themes.failed"),
|
||||
);
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
}
|
||||
return (
|
||||
<section
|
||||
className="space-y-3 py-3"
|
||||
aria-label={t("settings.plugins.themes.title")}
|
||||
>
|
||||
<div>
|
||||
<h4 className="text-sm font-medium">
|
||||
{t("settings.plugins.themes.title")}
|
||||
</h4>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("settings.plugins.themes.description")}
|
||||
</p>
|
||||
</div>
|
||||
{entries.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("settings.plugins.themes.empty")}
|
||||
</p>
|
||||
) : (
|
||||
<select
|
||||
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
|
||||
aria-label={t("settings.plugins.themes.choose")}
|
||||
value={entry ? selected : ""}
|
||||
disabled={pending}
|
||||
onChange={(e) => {
|
||||
pluginThemeRegistry.cancelPreview(owner);
|
||||
setSelected(e.target.value);
|
||||
setError("");
|
||||
}}
|
||||
>
|
||||
<option value="">{t("settings.plugins.themes.choose")}</option>
|
||||
{entries.map((e) => (
|
||||
<option
|
||||
key={`${e.ref.pluginId}/${e.ref.themeId}`}
|
||||
value={`${e.ref.pluginId}/${e.ref.themeId}`}
|
||||
>
|
||||
{e.label} — {e.pluginName} {e.ref.version}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
{catalog.selection[mode] && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t(
|
||||
appliedAvailable
|
||||
? "settings.plugins.themes.applied"
|
||||
: "settings.plugins.themes.unavailable",
|
||||
)}
|
||||
: {catalog.selection[mode]!.ref.themeId} ·{" "}
|
||||
{catalog.selection[mode]!.ref.version}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={!entry || pending}
|
||||
onClick={() => {
|
||||
if (entry) pluginThemeRegistry.startPreview(owner, entry);
|
||||
}}
|
||||
>
|
||||
{t("settings.plugins.themes.preview")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={!entry || pending}
|
||||
onClick={() => {
|
||||
void apply();
|
||||
}}
|
||||
>
|
||||
{t("settings.plugins.themes.apply")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
disabled={pending}
|
||||
onClick={() => pluginThemeRegistry.cancelPreview(owner)}
|
||||
>
|
||||
{t("settings.plugins.themes.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={pending}
|
||||
onClick={() => {
|
||||
void apply(true);
|
||||
}}
|
||||
>
|
||||
{t("settings.plugins.themes.restore")}
|
||||
</Button>
|
||||
</div>
|
||||
{catalog.diagnostics.length > 0 && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("settings.plugins.themes.invalid")}
|
||||
</p>
|
||||
)}
|
||||
{error && (
|
||||
<p role="alert" className="text-xs text-destructive">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,6 @@
|
||||
import { PluginThemePicker } from './PluginThemePicker';
|
||||
import { applyPluginThemeSelection, usePluginThemeRevision } from '../../../application/state/usePluginThemeRuntime';
|
||||
import { pluginThemeRegistry } from '../../../infrastructure/config/pluginThemeRegistry';
|
||||
import React, { memo, useCallback, useMemo, useState } from "react";
|
||||
import { applyCustomCssToDocument } from "../../../lib/customCss";
|
||||
import { DebouncedTextarea } from "../DebouncedTextarea";
|
||||
@@ -70,6 +73,8 @@ function SettingsAppearanceTab(props: {
|
||||
setAppIconVariant: (variant: AppIconVariant) => void;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
usePluginThemeRevision();
|
||||
const [pluginThemeError, setPluginThemeError] = useState('');
|
||||
const availableUIFonts = useAvailableUIFonts();
|
||||
// Note code fonts come from the monospace-only store (fontStore); the note
|
||||
// body font follows the UI font setting instead.
|
||||
@@ -231,8 +236,12 @@ function SettingsAppearanceTab(props: {
|
||||
);
|
||||
|
||||
const visibleUiThemes = resolvedTheme === "dark" ? DARK_UI_THEMES : LIGHT_UI_THEMES;
|
||||
const visibleUiThemeId = resolvedTheme === "dark" ? darkUiThemeId : lightUiThemeId;
|
||||
const setVisibleUiThemeId = resolvedTheme === "dark" ? setDarkUiThemeId : setLightUiThemeId;
|
||||
const visibleUiThemeId = pluginThemeRegistry.isActive(resolvedTheme) ? "" : resolvedTheme === "dark" ? darkUiThemeId : lightUiThemeId;
|
||||
const setVisibleUiThemeId = (id: string) => {
|
||||
void applyPluginThemeSelection(resolvedTheme, null).then(() => {
|
||||
(resolvedTheme === "dark" ? setDarkUiThemeId : setLightUiThemeId)(id);setPluginThemeError('');
|
||||
}).catch((error: unknown) => setPluginThemeError(error instanceof Error ? error.message : t('settings.plugins.themes.failed')));
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingsTabContent value="appearance">
|
||||
@@ -339,6 +348,8 @@ function SettingsAppearanceTab(props: {
|
||||
{renderThemeSwatches(visibleUiThemes, visibleUiThemeId, setVisibleUiThemeId)}
|
||||
</div>
|
||||
</SettingsAnchor>
|
||||
<PluginThemePicker mode={resolvedTheme} onRestore={() => (resolvedTheme === "dark" ? setDarkUiThemeId : setLightUiThemeId)(resolvedTheme === "dark" ? "midnight" : "snow")} />
|
||||
{pluginThemeError && <p role="alert" className="text-xs text-destructive">{pluginThemeError}</p>}
|
||||
<SettingRow
|
||||
anchorId="appearance-accent-mode"
|
||||
label={t("settings.appearance.accentColor.mode")}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { PluginMarketplace } from './PluginMarketplace';
|
||||
import { PluginPackageManager } from './PluginPackageManager';
|
||||
import { FolderOpen } from 'lucide-react';
|
||||
import { PluginStatus } from './PluginStatus';
|
||||
import { PluginLogSettings } from './PluginLogSettings';
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { normalizePluginKeyboardEvent } from '../../../application/state/pluginKeybindings';
|
||||
@@ -400,6 +401,7 @@ export default function SettingsPluginsTab() {
|
||||
<p className="mt-1 text-sm text-muted-foreground">{t('settings.plugins.description')}</p>
|
||||
</div>
|
||||
<PluginManagementDialog>{(handoff) => <PluginManagementContent handoff={handoff} />}</PluginManagementDialog>
|
||||
<PluginLogSettings />
|
||||
</SettingsAnchor>
|
||||
</SettingsTabContent>
|
||||
);
|
||||
|
||||
@@ -30,7 +30,7 @@ export const TerminalHostKeyVerification: React.FC<TerminalHostKeyVerificationPr
|
||||
const Icon = isChanged ? AlertTriangle : Fingerprint;
|
||||
|
||||
return (
|
||||
<div className="space-y-3 animate-in fade-in-0 slide-in-from-bottom-1 duration-200">
|
||||
<div data-host-security-surface="" className="space-y-3 animate-in fade-in-0 slide-in-from-bottom-1 duration-200">
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-xl border px-3 py-2.5",
|
||||
|
||||
@@ -44,6 +44,7 @@ import { collectOwnedPluginMenus, comparePluginMenus, usePluginContributions } f
|
||||
import { buildTerminalPluginContributionContext } from '../../application/state/pluginContributionContexts';
|
||||
import { PluginContributionIcon } from '../plugins/PluginContributionIcon';
|
||||
import { isPluginHostProtocol } from '../../domain/pluginConnection';
|
||||
import { PluginTerminalSidebarLauncher } from '../plugins/PluginTerminalSidebar';
|
||||
|
||||
export const TERMINAL_TOOLBAR_ITEM_IDS = [
|
||||
'highlight',
|
||||
@@ -1158,6 +1159,7 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
|
||||
)}
|
||||
</ToolbarCustomizeContextMenu>
|
||||
|
||||
{!compactToolbar && <PluginTerminalSidebarLauncher sessionId={sessionId} />}
|
||||
{pluginToolbarMenus.map((menu) => (
|
||||
<Tooltip key={menu.id}>
|
||||
<TooltipTrigger asChild>
|
||||
|
||||
@@ -9,7 +9,7 @@ import React, { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef,
|
||||
import { createPortal } from 'react-dom';
|
||||
import * as SelectPrimitive from '@radix-ui/react-select';
|
||||
|
||||
import { useActiveTabId } from '../../application/state/activeTabStore';
|
||||
import { activeTabStore, useActiveTabId } from '../../application/state/activeTabStore';
|
||||
import {
|
||||
getSidePanelLiveSnapshot,
|
||||
subscribeSidePanelLiveSnapshot,
|
||||
@@ -48,6 +48,7 @@ import {
|
||||
MAX_SIDE_PANEL_PANES,
|
||||
canSplitSidePanelPaneAtSize,
|
||||
collectSidePanelPanes,
|
||||
isPluginSidePanelTool,
|
||||
getFocusedSidePanelPane,
|
||||
getSidePanelNodeMinimumPixels,
|
||||
getSidePanelSplitResizeBounds,
|
||||
@@ -60,7 +61,10 @@ import {
|
||||
type SidePanelSplitNode,
|
||||
} from '../../domain/sidePanelLayout';
|
||||
import { terminalLayerSidePanelStableCtxEqual } from './terminalLayerViewMemo';
|
||||
import { SidePanelMountedContent } from './terminalLayerSidePanelSlots';
|
||||
import { PersistentSidePanelPortal, SidePanelMountedContent } from './terminalLayerSidePanelSlots';
|
||||
import { usePluginTerminalSidebar } from '../../application/state/usePluginTerminalSidebar';
|
||||
import { PluginTerminalSidebarPageHost } from '../plugins/PluginTerminalSidebar';
|
||||
import { PluginContributionIcon } from '../plugins/PluginContributionIcon';
|
||||
import { getPaneMagnificationShortcutLabel } from '../../domain/paneMagnification';
|
||||
|
||||
const MemoizedSidePanelMountedContent = memo(
|
||||
@@ -358,7 +362,7 @@ function SidePanelSplitView({
|
||||
);
|
||||
}
|
||||
|
||||
function SidePanelLayoutTree({
|
||||
export function SidePanelLayoutTree({
|
||||
node,
|
||||
layout,
|
||||
paneCount,
|
||||
@@ -568,6 +572,9 @@ function hasMountedSidePanelContent(ctx: SidePanelContext): boolean {
|
||||
&& Array.from((sidePanelOpenTabs as Map<string, SidePanelTab>).values()).includes('history');
|
||||
const anyNotesOpen = sidePanelOpenTabs instanceof Map
|
||||
&& Array.from((sidePanelOpenTabs as Map<string, SidePanelTab>).values()).includes('notes');
|
||||
const anyPluginOpen = ctx.sidePanelLayouts instanceof Map
|
||||
&& [...(ctx.sidePanelLayouts as Map<string, SidePanelLayout>).values()]
|
||||
.some((layout) => collectSidePanelPanes(layout.root).some((pane) => isPluginSidePanelTool(pane.tool)));
|
||||
|
||||
return !(
|
||||
browserMountedTabIds.length === 0
|
||||
@@ -579,10 +586,16 @@ function hasMountedSidePanelContent(ctx: SidePanelContext): boolean {
|
||||
&& themeMountedTabIds.length === 0
|
||||
&& !anyHistoryOpen
|
||||
&& !anyNotesOpen
|
||||
&& !anyPluginOpen
|
||||
);
|
||||
}
|
||||
|
||||
function TerminalLayerSidePanelSectionInner({ ctx }: { ctx: SidePanelContext }) {
|
||||
const pluginSidebar = usePluginTerminalSidebar({ layouts: ctx.sidePanelLayouts ?? new Map(),
|
||||
getSession: ctx.getActiveTerminalSessionId ?? (() => null),
|
||||
isTerminalTab: (tabId) => (ctx.sessions ?? []).some((session: { id: string }) => session.id === tabId) || !!ctx.workspaceById?.has(tabId),
|
||||
select: (tool) => ctx.handleSwitchSidePanelTab?.(tool),
|
||||
withdraw: (tabId, paneId, tool) => ctx.handleWithdrawPluginSidebarPane?.(tabId, paneId, tool) });
|
||||
if (!hasMountedSidePanelContent(ctx)) {
|
||||
// Keep AI scope maintenance alive even with zero mounted tool panels so
|
||||
// merge/dissolve handoff still runs when the user closed AI before the
|
||||
@@ -604,7 +617,7 @@ function TerminalLayerSidePanelSectionInner({ ctx }: { ctx: SidePanelContext })
|
||||
</AISidePanelStateRoot>
|
||||
);
|
||||
}
|
||||
return <TerminalLayerSidePanelInner ctx={ctx} />;
|
||||
return <TerminalLayerSidePanelInner ctx={ctx} pluginSidebar={pluginSidebar} />;
|
||||
}
|
||||
|
||||
/** Skip chrome rebuilds when only live/workspace-focus ticks change. */
|
||||
@@ -614,7 +627,7 @@ export const TerminalLayerSidePanelSection = memo(
|
||||
);
|
||||
TerminalLayerSidePanelSection.displayName = 'TerminalLayerSidePanelSection';
|
||||
|
||||
function TerminalLayerSidePanelInner({ ctx }: { ctx: SidePanelContext }) {
|
||||
function TerminalLayerSidePanelInner({ ctx, pluginSidebar }: { ctx: SidePanelContext; pluginSidebar: ReturnType<typeof usePluginTerminalSidebar> }) {
|
||||
const activeTabId = useActiveTabId();
|
||||
const sidePanelOpenTabs = ctx.sidePanelOpenTabs as Map<string, SidePanelTab>;
|
||||
const sidePanelLayouts = ctx.sidePanelLayouts as Map<string, SidePanelLayout>;
|
||||
@@ -630,6 +643,7 @@ function TerminalLayerSidePanelInner({ ctx }: { ctx: SidePanelContext }) {
|
||||
handleOpenAI,
|
||||
handleOpenHistory,
|
||||
handleOpenNotes,
|
||||
handleSwitchSidePanelTab,
|
||||
handleOpenBrowser,
|
||||
handleOpenScripts,
|
||||
handleOpenSystem,
|
||||
@@ -1046,6 +1060,9 @@ function TerminalLayerSidePanelInner({ ctx }: { ctx: SidePanelContext }) {
|
||||
{ id: 'system' as const, label: t('terminal.layer.system'), icon: <Activity size={15} />, onClick: handleOpenSystem },
|
||||
{ id: 'notes' as const, label: t('terminal.layer.notes'), icon: <NotebookText size={15} />, onClick: handleOpenNotes },
|
||||
{ id: 'ai' as const, label: t('terminal.layer.aiChat'), icon: <MessageSquare size={15} />, onClick: handleOpenAI },
|
||||
...pluginSidebar.pages.map((page) => ({ id: page.tool, label: `${page.title} · ${page.pluginName}`,
|
||||
icon: <PluginContributionIcon pluginId={page.pluginId} icon={page.icon} size={15} />,
|
||||
onClick: () => handleSwitchSidePanelTab?.(page.tool) })),
|
||||
], [
|
||||
handleOpenAI,
|
||||
handleOpenHistory,
|
||||
@@ -1056,6 +1073,8 @@ function TerminalLayerSidePanelInner({ ctx }: { ctx: SidePanelContext }) {
|
||||
handleOpenTheme,
|
||||
handleToggleSftpFromBar,
|
||||
t,
|
||||
handleSwitchSidePanelTab,
|
||||
pluginSidebar.pages,
|
||||
]);
|
||||
const sidePanelTabItemById = useMemo(
|
||||
() => new Map(sidePanelTabItems.map((item) => [item.id, item])),
|
||||
@@ -1491,6 +1510,19 @@ function TerminalLayerSidePanelInner({ ctx }: { ctx: SidePanelContext }) {
|
||||
paneHosts={panePortalHosts}
|
||||
parkingHost={parkingHost}
|
||||
/>
|
||||
{activeTabId && pluginSidebar.sessionId && activeSidePanelPanes.map((pane) => {
|
||||
const page = pluginSidebar.pages.find((item) => item.tool === pane.tool);
|
||||
const target = panePortalHosts.get(pane.tool) ?? parkingHost;
|
||||
if (!page || !target) return null;
|
||||
const sessionId = pluginSidebar.sessionId!;
|
||||
return <PersistentSidePanelPortal key={`${activeTabId}:${pane.id}:${sessionId}`} portalKey={`plugin-${pane.id}`} target={target}>
|
||||
<PluginTerminalSidebarPageHost page={page} tabId={activeTabId} sessionId={sessionId}
|
||||
focusOnReveal={activeSidePanelLayout?.focusedPaneId === pane.id}
|
||||
adapter={pluginSidebar.contributions} getSession={ctx.getActiveTerminalSessionId}
|
||||
unavailable={() => ctx.handleWithdrawPluginSidebarPane?.(activeTabId, pane.id, page.tool)}
|
||||
focused={() => { if (activeTabStore.getActiveTabId() === activeTabId) ctx.handleFocusSidePanelPane?.(pane.id); }} />
|
||||
</PersistentSidePanelPortal>;
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -32,6 +32,13 @@ test('terminal layer bridge passes vault open callbacks into the side panel cont
|
||||
assert.match(source, /onOpenVaultSectionFromChat: s\.onOpenVaultSectionFromChat/);
|
||||
});
|
||||
|
||||
test('production terminal layer context forwards extension sidebar owner and pane callbacks', () => {
|
||||
const contextProjection = source.slice(source.indexOf('const ctx = useMemo('));
|
||||
for (const callback of ['getActiveTerminalSessionId', 'handleSwitchSidePanelTab', 'handleWithdrawPluginSidebarPane']) {
|
||||
assert.match(contextProjection, new RegExp(`${callback}: s\\.${callback}\\b`));
|
||||
}
|
||||
});
|
||||
|
||||
test('terminal layer bridge updates side panel live state after render', () => {
|
||||
assert.match(source, /const sidePanelLiveSnapshot = useMemo<SidePanelLiveSnapshot>/);
|
||||
assert.match(
|
||||
|
||||
@@ -489,6 +489,9 @@ export function TerminalLayerTabBridge({ stableRef }: { stableRef: StableRef })
|
||||
handleFontWeightResetForFocusedSession: themeState.handleFontWeightResetForFocusedSession,
|
||||
handleOpenAI: s.handleOpenAI,
|
||||
handleOpenNotes: s.handleOpenNotes,
|
||||
getActiveTerminalSessionId: s.getActiveTerminalSessionId,
|
||||
handleSwitchSidePanelTab: s.handleSwitchSidePanelTab,
|
||||
handleWithdrawPluginSidebarPane: s.handleWithdrawPluginSidebarPane,
|
||||
handleOpenBrowser: s.handleOpenBrowser,
|
||||
handleOpenSystem: s.handleOpenSystem,
|
||||
handleOpenHistory: s.handleOpenHistory,
|
||||
|
||||
@@ -7,8 +7,9 @@ import { TerminalLayerWorkspaceSection } from './TerminalLayerWorkspaceSection';
|
||||
import { terminalLayerViewCtxEqual } from './terminalLayerViewMemo';
|
||||
import { useTerminalHostTreeLayoutWidth } from '../../application/state/terminalHostTreeStore';
|
||||
import { resolveTerminalLayerSurfaceStyle } from '../terminalPaneVisibility';
|
||||
import type { PluginSidebarHostActions } from '../../application/state/usePluginTerminalSidebar';
|
||||
|
||||
type TerminalLayerViewContext = Record<string, any>;
|
||||
type TerminalLayerViewContext = Record<string, any> & PluginSidebarHostActions;
|
||||
|
||||
function TerminalLayerViewInner({ ctx }: { ctx: TerminalLayerViewContext }) {
|
||||
const hostTreeLayoutWidth = useTerminalHostTreeLayoutWidth();
|
||||
|
||||
@@ -693,7 +693,7 @@ export function movePersistentPortalNode(
|
||||
}
|
||||
}
|
||||
|
||||
function PersistentSidePanelPortal({
|
||||
export function PersistentSidePanelPortal({
|
||||
portalKey,
|
||||
target,
|
||||
children,
|
||||
|
||||
@@ -316,6 +316,9 @@ const SIDE_PANEL_STABLE_CTX_KEYS = [
|
||||
'handleOpenTheme',
|
||||
'handleOpenAI',
|
||||
'handleOpenNotes',
|
||||
'getActiveTerminalSessionId',
|
||||
'handleSwitchSidePanelTab',
|
||||
'handleWithdrawPluginSidebarPane',
|
||||
'handleOpenBrowser',
|
||||
'handleBackFromNotes',
|
||||
'handleOpenHostFromNotes',
|
||||
|
||||
@@ -29,6 +29,7 @@ import { LazyLoadBoundary } from "../ui/lazy-load-boundary";
|
||||
import { toast } from "../ui/toast";
|
||||
import { AppWordmark } from "../AppWordmark";
|
||||
import { openHostBrowser } from "../../application/hostBrowser";
|
||||
import { PluginVaultNavigation, PluginVaultPage } from '../plugins/PluginVaultPages';
|
||||
|
||||
type VaultViewLayoutContext = Record<string, any>;
|
||||
|
||||
@@ -156,6 +157,7 @@ export function VaultViewLayout({ ctx }: { ctx: VaultViewLayoutContext }) {
|
||||
ContextMenuTrigger,
|
||||
Copy,
|
||||
currentSection,
|
||||
pluginVault,
|
||||
customGroups,
|
||||
deleteGroupPaths,
|
||||
deleteGroupWithHosts,
|
||||
@@ -595,12 +597,13 @@ export function VaultViewLayout({ ctx }: { ctx: VaultViewLayoutContext }) {
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={cn("space-y-1", sidebarCollapsed ? "px-1.5" : "px-2.5")}
|
||||
className={cn("min-h-0 flex-1 overflow-y-auto space-y-1", sidebarCollapsed ? "px-1.5" : "px-2.5")}
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<RippleButton
|
||||
variant={currentSection === "hosts" ? "secondary" : "ghost"}
|
||||
data-plugin-vault-fallback
|
||||
className={cn(
|
||||
"w-full h-10",
|
||||
sidebarCollapsed
|
||||
@@ -805,11 +808,12 @@ export function VaultViewLayout({ ctx }: { ctx: VaultViewLayoutContext }) {
|
||||
</TooltipContent>
|
||||
)}
|
||||
</Tooltip>
|
||||
{pluginVault && <PluginVaultNavigation model={pluginVault} collapsed={sidebarCollapsed} />}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"mt-auto pb-4 space-y-2",
|
||||
"mt-auto shrink-0 pb-4 space-y-2",
|
||||
sidebarCollapsed ? "px-1.5" : "px-2.5",
|
||||
)}
|
||||
>
|
||||
@@ -864,6 +868,7 @@ export function VaultViewLayout({ ctx }: { ctx: VaultViewLayoutContext }) {
|
||||
className="flex-1 min-w-0 flex flex-col min-h-0 relative"
|
||||
data-section="vault-main"
|
||||
>
|
||||
{pluginVault?.selected && <PluginVaultPage key={pluginVault.selected.key} model={pluginVault} />}
|
||||
<VaultPageHeader
|
||||
className={cn(!isHostsSectionActive && "hidden")}
|
||||
dataSection="vault-hosts-header"
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
# Sandbox capability coverage
|
||||
|
||||
This matrix tracks expansion from Draft #3951 at
|
||||
`e5bd294cddbf58dfe04c646b6291933d980498fd`. Each accepted item requires a
|
||||
canonical schema, generated SDK, broker, permission policy, quota and audit,
|
||||
host adapter, lifecycle cleanup, synthetic boundary tests, review, and exact-SHA
|
||||
CI. Enumeration in a manifest is not a working API. Status `planned` does not
|
||||
mean complete. Existing APIs remain available.
|
||||
|
||||
## Coverage matrix
|
||||
|
||||
| Subsystem | Working API at baseline | Enumeration or missing adapter | Accepted bounded entry | Data and action risk | Authorization resource | Revocation, audit, quota | Verification and disposition |
|
||||
| --- | --- | --- | --- | --- | --- | --- | --- |
|
||||
| Authorization management | Engine `listGrants`, `revokeAll`, database audit | Manager shows declarations only | Host-only grant list, bounded audit summary, disable and revoke | Paths, session IDs, grant metadata are sensitive | Exact plugin ID, trusted app sender | Serialized manager mutation, disable before revoke, runtime teardown, retained audit | Batch 1 limited acceptance complete on ff3734d: independent review fixes, host/manager/UI tests, exact test/packaging CI, parent native own-views Application grant/revoke/disable/runtime-stop check. Visible view was manually closed beforehand. No plugin RPC for managing grants |
|
||||
| Network and credential leases | `network.request`, `credentials.createLease`, single-use host redemption | SDK request omits lease and authorization fields | Canonical request types matching broker | Network mutation and credentials | Exact origin plus secret reference and operation | Existing timeout, redirects, byte quotas, runtime checks, lease TTL | Batch 1 contract/SDK consistency complete: canonical types, synthetic fixture/lease tests, feature discovery and exact CI. No live network or credential acceptance claimed. No plaintext secret API |
|
||||
| Commands, menus, toolbar, status bar, views | `commands.execute`, settings, context keys, view state and messages, UI contributions | No editor or SFTP menu slots | Existing contribution system | Plugin text and arguments may be sensitive | Namespaced contributions and permissions | Existing lifecycle, rate and message limits | Existing tests. New context menu slots planned with bounded context DTOs |
|
||||
| Terminal providers | Decorations, links, completion, snapshots through provider requests | Session change delivery follows prior provider request and non-once grant | Preserve providers. Standalone selected-session watch planned | Hostnames, session state, endpoint identity | Host-selected session, runtime identity | Session-close and revoke cleanup, bounded event rate | Existing provider tests. Ordinary views cannot independently subscribe today |
|
||||
| Current context and host picker | Host context keys are boolean state | `vault.metadata` enumeration is not generic SDK | Selected host/session DTO from an explicit whitelist | Labels and endpoint metadata are sensitive | Host-owned user-action context, selected host/session | TTL, runtime and session invalidation, redacted audit | Planned. Never reuse agent `sanitizeHostForAgent` as plugin DTO |
|
||||
| Terminal selection and input draft | Provider-supplied snapshots, completion suggestions | No explicit selection-share or draft API | User shares selection, plugin proposes a visible editable draft | Terminal plaintext, accidental execution | Host action token plus exact live session/endpoint | Single-use token, size cap, no plaintext audit, active identity before commit | Planned. Reject Enter/control characters for draft insertion. No full history or direct shell |
|
||||
| Native interactions and clipboard | Native permission dialogs; new browser SDK host choice/input and explicit copy/share workflow | Progress/toast remain unimplemented | Host-owned prompts, two-phase clipboard snapshot/share, exact copy preview | Clipboard and input may contain secrets; UI discloses granted network capability; prompt spam | `ui.interact`, clipboard direction, host-signed window/runtime/version/phase/TTL action | Admission before permission prompt, byte/rate/concurrency limits, lock/blur/close/revoke cleanup, active guard, content-free audit | [Host interaction slice](./host-interactions.md) implemented and synthetic-tested. 8e4dd0c exact test/packaging CI and independent delta review passed. Parent native compose and explicit Preview/Share/copy succeeded; a separately approved 0.1.1 compose ran about 86 seconds, beyond the old 30-second deadline and within the declared 180-second budget, with no fresh command-timeout error. All five permissions were Once; cleanup is parent-owned. New navigation slices retain separate acceptance gates. Progress/toast still planned. No background reads or trusted plugin `userGesture` boolean |
|
||||
| Local filesystem | Read file, stat, overwrite existing regular file | Directory listing lacks same-handle native adapter | Keep current boundary, evaluate file picker handles | Arbitrary local file content and writes | Canonical exact path or selected file handle | Inode checks, no symlink alias, bounded bytes, active runtime | Existing filesystem tests. Directory enumeration and new arbitrary-path creation deferred until portable safe native adapter exists |
|
||||
| SFTP and remote files | No generic plugin API | `sftp.read` and `sftp.write` enumerated only | Host-selected session/path/mode/TTL handles, read and explicit save/upload | Remote secrets and destructive overwrite | Live endpoint identity, exact path, read/write split | Cancel streams, byte quota, overwrite confirmation, close/revoke cleanup | Batch 2 planned. Never forward arbitrary MCP catalog or session ID alone |
|
||||
| Editor and Notes | No plugin document API | No versioned snapshot or edit preview | Explicit snapshot and versioned proposed edit | Document secrets, lost updates, executable files | Selected document plus revision and action token | Version conflict check, preview, bounded bytes, revocation | Planned evaluation after shared action context. No unattended edits |
|
||||
| Snippets and scripts | Provider import drafts only | No generic read/run API | Explicit selection and create draft | Script contents may contain secrets. Running executes commands | Selected item, read versus draft write | Preview, byte cap, no automatic execution | Planned evaluation. Script `run` and onConnect modification deferred |
|
||||
| Connections and authentication | Connection/authentication providers, opaque credentials, streams | Generic host open/connect absent | Existing providers remain | Connect can execute saved onConnect scripts | Provider operation plus live endpoint and credential lease | Existing provider lifecycle, streams and quotas | Existing tests. Generic connect is a write/execution capability, deferred pending explicit execution design |
|
||||
| Sync and storage | Local namespaced KV, encrypted-object sync providers, opt-in setting sidecars | No general KV cloud sync | Existing opt-in provider protocol | Conflicts, deletes, credential access | Provider/account/object, settings scope | Existing limits and lifecycle, non-cascade metadata | Existing sync tests. General KV sync deferred until conflict/delete/quota/migration policy is defined |
|
||||
| Vault and keychain | Credential references and import preview/commit | Metadata/write enums do not grant a generic Vault SDK | Only user-selected whitelist DTOs and drafts | Notes, environmentVariables, startupCommand, keys and credentials | Selected object plus field/direction | Explicit preview, identity checks, redacted audit | Full Vault export and secret reads deferred indefinitely. No agent field-exclusion DTO reuse |
|
||||
| System manager and Docker | No plugin system API | Agent/MCP adapters are separate authority | Typed read-only summaries excluding args/env/inspect | Process command lines and Docker inspect can expose secrets | Selected live session and summary kind | Bounded result, endpoint identity, cancellation | Planned evaluation. Arbitrary process/inspect forwarding deferred |
|
||||
| Port forwarding | No generic plugin API | Existing agent start is write-sensitive | Selected rule status and open host form | Rule endpoints and local exposure | Selected rule plus host action | Bounded DTO, session close and revoke | Planned evaluation. Direct start deferred until binding/listener approval design |
|
||||
| Themes, settings, appearance, i18n | Terminal theme/background Providers, isolated view CSS, scoped settings and environment updates | No host global theme contribution. Personal customCSS is not plugin authority | Accepted ThemePack stage: static palette registry, selector, local preview, revision-safe apply/cancel/reset, terminal linkage | Unreadable palettes and misleading security UI. Nineteen UiThemeTokens fields are not independent: accent derives primary/ring/foregrounds | User explicitly selects a theme; static contribution enablement separate from code permissions | Immutable version/hash, bounded JSON, contrast/security-surface gates, preview owner/revision and safe fallback | First host color layer implemented; [contract/lifecycle/evidence](./theme-packs.md). Exact-SHA CI, independent review and native acceptance pending. Static terminal linkage not yet accepted; no arbitrary DOM/CSS, remote assets, layout replacement or security UI customization |
|
||||
| Transfer center | Host transfer queue exists, no plugin entry | No ownership-bound task API | Observe and cancel only plugin-created tasks | Paths and host endpoints reveal user data. Cancel may leave partial files | Own transfer handle plus source/destination authority | Shared backpressure, cancel/close cleanup, honest partial status | Planned with batch 2. Do not broadcast or cancel other tasks |
|
||||
| AI, MCP, automation | Host agent catalog and harness exist separately | No generic plugin AI or tool forwarding | Selection-to-proposal workflow through the same controlled primitives | Content disclosure, account authority and model cost | Explicit content sharing plus service origin/budget | No inherited history, tools, account, or hidden cron | Controlled workflow planned. Generic host AI and visible schedules deferred to distinct product decision |
|
||||
| Known hosts, app lock and global security | Host security management UI | No plugin management API | Future allowlisted security-page navigation | Host-key trust, unlock, keys and security policy affect all sessions | Host navigation action only | No mutation or sensitive DTO | Security policy mutation, private-key reads, auto-trust and unlocking deferred indefinitely |
|
||||
| Plugin blob/cache | Local KV exists | No exclusive blob cache | Evaluate bounded plugin-owned cache when a real workflow needs it | Disk growth and lifecycle ambiguity | Plugin namespace, no arbitrary path | Size quota, eviction and uninstall policy required | Deferred until a concrete larger-than-KV workflow establishes value and retention policy |
|
||||
| Vault and terminal navigation | `tab` mounts a sandbox page through TopTabs/QuickSwitcher; aside/panel are overlays | No Vault subpage registry or terminal side-panel plugin tool. `settings` is a detail entry, not a new settings category | Accepted independent slice: host-owned namespaced Vault pages in an Extensions group, mounted in the Vault content area through existing ViewHost; then terminal pane/split pages for the selected session | Navigation cannot disclose session contents or replace built-in/security navigation | Declared page contribution plus host selection. Content reads have independent permissions | Host order/when/focus, cancellation of late open, view cleanup on hide/update/disable/uninstall, safe invalid-link fallback | [Vault page slice](./vault-pages.md) implemented and synthetic-tested: actual Vault content slot, private Scratchpad, catalog withdrawal, host occlusion, reserved-open cancellation, owner/crash/lock cleanup. Exact CI/independent/native acceptance pending. [Terminal sidebar slice](./terminal-sidebar.md) also implemented with actual pane/split mounting, owner focus and session-change cleanup; no automatic session data. Exact CI/independent/native acceptance pending for the new stack. No arbitrary router/DOM API |
|
||||
| Distribution and runtimes | Browser-only public catalog, immutable packages, advanced developer gate | Signing/trust and release remain separate phase | Retain existing boundary | Package execution and publisher trust | Pinned catalog selection, hash, engine/features | Existing install transaction, quarantine and cleanup | No merge/release. Do not extend to arbitrary Node, IPC, shell, or global capabilities |
|
||||
|
||||
## Delivery order and stopping rule
|
||||
|
||||
1. Batch 1: actual authorization management plus network contract consistency.
|
||||
Keep the existing manager layout. Add one independent permission panel.
|
||||
2. Batch 1b: the host text interaction slice, then selected context and terminal
|
||||
sharing/drafts. A plugin-supplied gesture is never authority.
|
||||
3. A separate navigation slice mounts actual Vault extension pages, then terminal
|
||||
sidebar panes, reusing the isolated ViewHost and keeping content permissions separate.
|
||||
4. Deliver the declarative ThemePack stage with security appearance and contrast gates.
|
||||
5. Batch 2: SFTP selected-file handles and explicit transfer destinations.
|
||||
6. Reassess editor, Notes, snippets, menus, system summaries, and port forwarding
|
||||
using the tested host-action and resource primitives.
|
||||
|
||||
Stop only when every row is implemented and tested, reasonably deferred with a
|
||||
specific boundary and rationale, or awaiting a concrete user decision. A
|
||||
planned row remains unfinished. Native screenshots and recordings belong to
|
||||
the parent thread. Real credentials, hosts, or transfers require approval for a
|
||||
specific target there. Synthetic checks do not establish native acceptance.
|
||||
|
||||
## Batch 1 evidence and next workflow
|
||||
|
||||
The independent permission panel adds only an import and a detail mount to
|
||||
`components/settings/tabs/PluginPackageManager.tsx`. It uses the manager hook's
|
||||
new `refresh()` after host revocation. Marketplace layout remains owned by the
|
||||
parallel UI layer. Five locale files contain the panel strings.
|
||||
|
||||
The next usable workflow is a browser plugin that asks a host quick-pick/input
|
||||
question, then explicitly copies its result or asks the user to share clipboard
|
||||
text. The host displays the plugin source and action before reading or writing.
|
||||
A canonical `HostInteractionRequest` and bounded result carry only text/choice
|
||||
IDs. The host owns request IDs, requester webContents, runtime/version, TTL,
|
||||
cancellation, and the consent result. The renderer cannot reply for another
|
||||
window. Plugin `userGesture` values are ignored. Runtime/revoke shutdown aborts
|
||||
all outstanding prompts and ignores late replies. A shared prompt primitive
|
||||
then supports selected host/session context, selection share, and input drafts.
|
||||
|
||||
Before proceeding to the sensitive context/terminal and SFTP entries, the
|
||||
native path must prove consent, cancellation, quota, and identity binding with
|
||||
synthetic data. UI screenshots and real-resource approval remain parent-owned.
|
||||
|
||||
Baseline code references: `electron/plugins/hostService.cjs`,
|
||||
`secureCapabilities.cjs`, `permissionEngine.cjs`, `terminalProviderService.cjs`,
|
||||
`filesystemBroker.cjs`, `extensionProviderService.cjs`,
|
||||
`packages/plugin-sdk/src/index.ts`, and
|
||||
`packages/plugin-contract/schema/plugin-contract.schema.json`. Host business
|
||||
implementations under `electron/capabilities/catalog/` are reference material,
|
||||
not plugin authority. `infrastructure/ai/vaultAgentBridgeClient.ts` is not a
|
||||
plugin DTO sanitizer.
|
||||
|
||||
## Navigation slice checkpoint
|
||||
|
||||
The Vault stage adds `views.vault-pages`, the `vault-page` location, and the
|
||||
existing `views` permission. It grants no Vault/session data. The ordinary
|
||||
browser Scratchpad example uses only its own bounded view-state API and
|
||||
explicit user input. It is not a Notes API or automatic sync feature.
|
||||
|
||||
The independent integration base includes #3956 UI polish and #3960 d4ca fixes;
|
||||
frozen evidence heads and prior PR bases are unchanged. Related UI changes in
|
||||
this stage are limited to VaultView/VaultViewLayout, new independent plugin
|
||||
components/hooks, and localized strings. PluginMarketplace/PluginPackageManager
|
||||
layouts are not rewritten. Native acceptance belongs to the parent with a new
|
||||
fixture-specific own-views approval. Sidebar, ThemePack, selected context and
|
||||
SFTP remain unfinished; this checkpoint is not overall completion.
|
||||
|
||||
## Terminal sidebar checkpoint
|
||||
|
||||
`views.terminal-sidebar` exposes one more existing sandbox view placement,
|
||||
using only the existing own-views permission. The host owns navigation,
|
||||
selection, split/close and focus. Its context is a surface string only; no
|
||||
terminal DTO or resource authority is implied. The Session Scratchpad fixture
|
||||
uses explicitly typed synthetic text and its own view state.
|
||||
|
||||
This stage builds on ordinary merges containing #3960 8e4dd0c and the #3971
|
||||
stale-layout callback fix. Prior QA heads and PR bases are not rewritten. The
|
||||
ThemePack implementation, selected-context/drafts and file handles remain
|
||||
unfinished; parent review/native gates still apply to each delivered head.
|
||||
|
||||
## Color-pack and remaining workflow checkpoint
|
||||
|
||||
The first [ThemePack color layer](./theme-packs.md) now has an implemented host
|
||||
catalog, data-only admission, Appearance selector, local preview, revision-safe
|
||||
application/reset, missing-version fallback and host security palette boundary.
|
||||
It is not native-accepted until the new exact head is independently reviewed,
|
||||
built by CI and exercised through normal Settings. Terminal palette linkage is
|
||||
not accepted by this contract yet, so a declared but ignored linkage cannot be
|
||||
counted as coverage. Font/density/icon/background packs remain separate product
|
||||
and accessibility decisions, rather than arbitrary CSS permissions.
|
||||
|
||||
Parent native evidence for sidebar `a8adeb86` now covers the original Local
|
||||
Terminal blocker, own-views Once, save, magnify/restore, a split with empty Notes,
|
||||
and side/bottom docking. `733f44c` adds only the production bridge projection
|
||||
regression. The older `0c097a1` CI run `40308057623` remains **failed and
|
||||
unattributed**: 9447 top-level TAP results are `ok`, the aggregate footer is
|
||||
missing and the process exits 1. A newer passing run does not change that record.
|
||||
The native Vault success retains its original `0c097a1` evidence label.
|
||||
|
||||
The next implementation stages are bounded by useful workflows:
|
||||
|
||||
1. **Selected terminal sharing and drafts (A).** Reuse host-signed action
|
||||
contexts for an explicit selection preview/share, then let the plugin submit
|
||||
a bounded input draft to the host compose surface. No Enter, raw PTY, history
|
||||
enumeration or background terminal listener. First add a host-selected
|
||||
session handle with a minimal from-scratch DTO and independent lifecycle
|
||||
watch; navigation alone grants no content. Bind handles to actual session and
|
||||
connection identity, runtime/version, owner, mode and TTL. Cover cancel,
|
||||
reconnect, close, revoke and late draft results with synthetic terminals.
|
||||
2. **Selected file conversion/transfer (A).** The host picker issues read or
|
||||
explicit-destination write handles. Start with one file, stat/read and saveAs
|
||||
or upload, not recursive traversal or arbitrary paths. Require destination
|
||||
preview/overwrite confirmation, byte/concurrency limits, cancellation,
|
||||
session/endpoint identity and final active checks. Observe/cancel only the
|
||||
plugin's own transfer. Real native SFTP targets require parent-coordinated
|
||||
concrete approval; local tests use isolated fixtures.
|
||||
3. **Versioned document proposals (selected B).** Reuse snapshot/handle and host
|
||||
review primitives for one explicitly shared editor document or Note. Apply
|
||||
only an expected-version patch to its buffer after a visible diff; saving a
|
||||
remote file remains a separate file operation. Reuse the draft path for a
|
||||
selected snippet or new snippet template; do not run scripts or add onConnect
|
||||
bindings. Cover edits after snapshot, detached ownership, closure and revoke.
|
||||
4. **Final reassessment.** Recheck the matrix by actual workflow and eight gates.
|
||||
Read-only system aggregates, selected port-rule status and bounded navigation
|
||||
may be added only where the shared primitives leave a concrete useful gap.
|
||||
Full process arguments/env/inspect, tunnel start, arbitrary AI/MCP tools,
|
||||
background jobs, general KV sync and raw high-privilege access remain deferred
|
||||
or retained boundaries. Do not invent new C work to prolong the task.
|
||||
|
||||
These stages remain unfinished work, not API-name coverage. Exact schemas and
|
||||
ordering will follow the existing brokers/adapters rather than a parallel RPC
|
||||
stack or one namespace per product module.
|
||||
@@ -630,3 +630,13 @@ negotiated API version and runtime initialization uses and verifies that exact
|
||||
version. Host version bounds remain independently enforced; unknown required
|
||||
features still fail closed. No old manifest is rewritten and no permission grant
|
||||
is migrated by this revision.
|
||||
|
||||
## Declarative host color contributions
|
||||
|
||||
`appearance.theme-packs` adds `contributes.themes` and generated palette/reference
|
||||
schemas. A pure package uses `main: { "data": "theme" }`, contributes only themes
|
||||
and omits permissions/activation/companions. It has no executable runtime. Normal
|
||||
admission accepts this data-only form alongside browser-only code packages; Node
|
||||
and arbitrary IPC remain advanced/unsupported public boundaries. Selection is a
|
||||
trusted host Appearance action, not an SDK setter or `appearance.write` grant.
|
||||
See [Theme packs](./theme-packs.md) for limits, preview, persistence and fallback.
|
||||
@@ -0,0 +1,130 @@
|
||||
# Host text interactions
|
||||
|
||||
Status: internal preview. This layer builds on the grant management in Draft
|
||||
#3955. It adds `host.interactions` feature discovery, a generated request/result
|
||||
contract, and an ordinary browser SDK workflow. Native acceptance remains a
|
||||
separate parent-owned gate.
|
||||
|
||||
## SDK and authorization
|
||||
|
||||
| SDK call | Permission | Result |
|
||||
| --- | --- | --- |
|
||||
| `ui.quickPick(title, items)` | `ui.interact` | Approved item ID or `undefined` on cancel |
|
||||
| `ui.input(title, initialValue?)` | `ui.interact` | Approved single-line text or `undefined` on cancel |
|
||||
| `clipboard.readText(title)` | `clipboard.read` | Explicitly shared snapshot or `undefined` on cancel |
|
||||
| `clipboard.writeText(text, title)` | `clipboard.write` | `true` after copy, `false` on cancel |
|
||||
|
||||
`ui.input` rejects CR and LF in initial values before any dialog is displayed,
|
||||
and rejects line breaks on submission. It does not silently remove hidden
|
||||
newlines. The displayed single-line value is the value submitted to the plugin.
|
||||
Clipboard text continues to support multiple lines.
|
||||
|
||||
All calls use the canonical `ui.interact` RPC. No host DOM, native object,
|
||||
router, general command ID, or arbitrary IPC channel enters the SDK. Existing
|
||||
API versions remain offered. A plugin requiring the new workflow declares
|
||||
`features.required: ["host.interactions"]`; old hosts reject unsupported
|
||||
required features. Old plugins keep their existing APIs and permissions.
|
||||
|
||||
Clipboard prompts restrict new permission decisions to once. Required or
|
||||
previously saved grants never skip the host action confirmation. Input and
|
||||
choice dialogs need their own declaration; a `views` or `menus` declaration is
|
||||
not permission to open them. Direction-specific clipboard permissions remain
|
||||
separate. The normal sample uses no network, host/session, filesystem, secret,
|
||||
Node, or shell capability.
|
||||
|
||||
## User action and text sharing
|
||||
|
||||
The host always displays plugin ID/version and a fixed source heading. Titles,
|
||||
choices, and text render as plain text. No plugin-supplied `userGesture` flag is
|
||||
accepted. The host creates an HMAC action token bound to request ID, runtime,
|
||||
plugin version, owner window, action kind, phase, nonce, and expiry. It sends
|
||||
that token only to the trusted host renderer. A response must come from the
|
||||
same WebContents and present the current token. Tokens are consumed before the
|
||||
first asynchronous action guard, so old-phase and duplicate responses cannot
|
||||
commit an action.
|
||||
|
||||
Clipboard reading has two steps. **Preview clipboard** authorizes a host read
|
||||
into a bounded in-memory snapshot. The plugin receives nothing yet. **Share
|
||||
with plugin** sends exactly that snapshot after another active-identity check.
|
||||
Changes to the system clipboard after preview do not replace it. Cancel, lock,
|
||||
window loss, or revoke discards the snapshot. Copy shows the exact replacement
|
||||
text and requires its own confirmation before a native write.
|
||||
|
||||
Shared text can be sensitive. The host explains that it becomes available to
|
||||
the plugin and its granted capabilities, including network access. Reading
|
||||
text does not approve a new network destination. This UI does not claim to
|
||||
prevent a plugin from transmitting derived text through an already granted
|
||||
capability. The example avoids network permission entirely.
|
||||
|
||||
## Availability, limits, and cleanup
|
||||
|
||||
The first slice supports the focused, visible, unlocked main window only.
|
||||
The host renderer registers readiness before any prompt can be presented.
|
||||
Other or unready windows return unavailable instead of inheriting an owner's
|
||||
authority. One interaction occupies an owner window at a time; there is no
|
||||
hidden prompt queue.
|
||||
|
||||
`HostInteractionLimits` is the shared canonical source for 4,096 UTF-8 text
|
||||
bytes, 100 choices, 64 KiB serialized requests, one pending request per runtime,
|
||||
eight total, ten attempts per minute per runtime, and a 60-second lifetime.
|
||||
The current main-window-only adapter is narrower than the total pending bound.
|
||||
Admission runs before the permission prompt. The generic capability quotas and
|
||||
byte windows still apply. Text exceeding its cap is rejected without silent
|
||||
truncation. RPC uses the same generated deadline.
|
||||
|
||||
After a native permission decision, an originally foreground request can wait
|
||||
at most one second for the same owner to regain focus. The host never focuses
|
||||
the window itself, presents content in the background, or postpones admission
|
||||
for an originally background request. Hide, lock, revoke, runtime stop, owner
|
||||
replacement and reload invalidate this admission, even during the permission
|
||||
dialog. Runtime activity and foreground state are checked again after waiting.
|
||||
|
||||
Input cancellation, timeout, disable/update/crash/revoke, owner blur/hide/close,
|
||||
main-document reload, renderer exit, and app lock invalidate pending tokens.
|
||||
Same-document or subframe navigation does not permanently unregister a live
|
||||
host UI. Runtime teardown clears rate accounting. The broker checks runtime
|
||||
activity, owner readiness/focus, and app lock again immediately before native
|
||||
read/write or disclosure. A revoked operation cannot resume from a late reply.
|
||||
|
||||
Commands normally retain the existing 30-second RPC deadline. A command that
|
||||
declares `interactive: true` must require `commands.interactive`; the host gives
|
||||
only that command a fixed 180-second total budget. This is not a global timeout
|
||||
increase or a UI/clipboard permission. Each individual host interaction still
|
||||
has its own 60-second deadline, including permission approval time.
|
||||
|
||||
When an outer command fails, times out or is cancelled, the host cancels pending
|
||||
interactions and pre-prompt admissions for its captured runtime. On successful
|
||||
completion it closes orphan interactions once the last concurrent command ends.
|
||||
This cleanup does not reset rate/byte quotas and cannot affect a replacement
|
||||
runtime. A failure conservatively closes that runtime's single shared pending
|
||||
dialog even if another command is active. Late action tokens cannot commit a
|
||||
clipboard effect. Cancellation does not forcibly terminate arbitrary plugin
|
||||
JavaScript; ignored errors remain subject to permission, focus, rate and runtime
|
||||
limits. Do not start independent interactive workflows concurrently.
|
||||
|
||||
Audit records contain only fixed event/action/kind fields. They contain no
|
||||
prompt, choices, user text, clipboard bytes, action tokens, or hashes derived
|
||||
from those values. A failed consent-audit write prevents the native action;
|
||||
no successful operation is reported for that failure.
|
||||
|
||||
## Verification and native acceptance
|
||||
|
||||
The [browser fixture](../../examples/plugins/host-interactions/README.md)
|
||||
exercises choice, input, copy, and clipboard transformation without a plugin
|
||||
view. Tests cover the actual runtime/SDK RPC path through permission middleware,
|
||||
actual host React dialog events against service-signed actions, denial, cancel,
|
||||
quota before prompt, overlong text, duplicate/forged actions, wrong owners,
|
||||
stale runtime, revoke during an asynchronous guard, audit failure, deadline,
|
||||
lock, blur, document navigation, and renderer teardown.
|
||||
|
||||
Parent native acceptance uses a separately approved temporary example-plugin
|
||||
session and synthetic clipboard text. It needs no real hosts, credentials,
|
||||
network destination, shell, or persistent grant. Check both complete workflows,
|
||||
cancel before reading, cancel after preview, cancel copy, and disable/revoke
|
||||
with a pending dialog. Record the exact SHA and restore the test clipboard.
|
||||
Unit tests are not evidence of native Electron acceptance.
|
||||
|
||||
Progress/toast, selected context and terminal sharing/drafts remain later slices.
|
||||
Navigation uses the existing sandbox ViewHost and adds no content permission.
|
||||
ThemePack and SFTP remain separate stages. This layer alone does not finish the
|
||||
capability coverage matrix.
|
||||
@@ -300,3 +300,8 @@ installs plugins but fails to start them outside the repository checkout.
|
||||
separate `npm run test:plugin-runtime:electron` smoke launches both a real
|
||||
sandboxed BrowserWindow plugin and a real utilityProcess plugin, verifies
|
||||
bidirectional storage RPC, and checks the recorded runtime ownership.
|
||||
|
||||
Static `appearance.theme-packs` packages are an explicit non-executable admission
|
||||
form: `main.data = "theme"`, theme-only contributions and no permissions,
|
||||
activation events or companions. The runtime supervisor never starts them. This
|
||||
exception does not admit Node code or arbitrary host access in normal Settings.
|
||||
@@ -0,0 +1,87 @@
|
||||
# Local logging validation
|
||||
|
||||
Base: PR #3978 at `91b8927c56314e68c06e2c008e84559ddfb96d78`.
|
||||
All data used in these checks is synthetic and each persistence test creates its
|
||||
own temporary profile. No real logs or credentials were opened.
|
||||
|
||||
| Check | Result |
|
||||
| --- | --- |
|
||||
| `node --test electron/plugins/*.test.cjs` | 561 passed, 2 expected skips (Electron live test and the #3981 database integration test on this older base) |
|
||||
| New logging settings, existing settings and permission-panel interaction tests | 4 passed |
|
||||
| `npm run build` | Passed; existing large-chunk warning |
|
||||
| `npm run lint` | No errors, 7 existing warnings; final changed-source lint also passed |
|
||||
| `npm run check:plugin-contract` | Passed, no generated drift |
|
||||
| `npx tsc --noEmit` against this change and the exact base | 773 diagnostics in each, zero added or removed after normalizing worktree paths |
|
||||
| `git diff --check` | Passed |
|
||||
|
||||
Coverage includes disabled and enabled persistence, opt-in plugin text, sensitive
|
||||
field exclusion, host source attribution, unknown-source fallback, URL rejection,
|
||||
spoofed metadata, cyclic/accessor/proxy input, noisy writers, shared queue and disk
|
||||
limits, rotation, expiration, drop/error counts, export, clear confirmation,
|
||||
disable/revoke, stale runtime callbacks, and manager shutdown. The filesystem
|
||||
regressions cover direct paths, symlink aliases, a symlinked profile directory,
|
||||
and revocation during the final protected-storage check.
|
||||
|
||||
## Remaining native checks
|
||||
|
||||
Electron launch failed before application/profile startup. The executable at
|
||||
`node_modules/electron/dist/electron --version` aborted with:
|
||||
|
||||
> The SUID sandbox helper binary was found, but is not configured correctly.
|
||||
|
||||
Its message requires `chrome-sandbox` to be root-owned with mode 4755. No sandbox
|
||||
switch, privilege escalation, permission repair, or native retry followed. Native
|
||||
smoke execution, screenshots, and a short recording remain pending. The acceptance
|
||||
flow is in [Local plugin logging](local-logging.md#native-qa-with-synthetic-data).
|
||||
|
||||
`npm run pack:dir` completed its production build, then failed during serialport
|
||||
native rebuilding: node-gyp returned ENOENT for mkdir
|
||||
`/home/agent/.electron-gyp`. No system-directory change or packaging retry followed.
|
||||
A native package was not produced.
|
||||
|
||||
## Parent integration
|
||||
|
||||
The only shared settings-page change is one import and one `<PluginLogSettings />`
|
||||
element. Preserve that element when integrating the marketplace task's page.
|
||||
There are no Installed, Official, Third-party, source-database, or unrelated host
|
||||
data changes.
|
||||
|
||||
The default host-owned resolver is wired through `createPluginHostService`. It
|
||||
consumes #3981's confirmed API without editing its database or sync implementation.
|
||||
On the older #3987 base, missing source APIs remain explicitly unknown. No shared
|
||||
transport-registration change is needed beyond the existing logging registration.
|
||||
|
||||
## Source and review follow-up
|
||||
|
||||
A separate worktree at `59721f97172acffffe960a6268a2916a90a6c554` received only
|
||||
the logging backend overlay. Its logging/source/host regression command passed
|
||||
33 tests with no skips, including the real database replacement, encoded local-owner roundtrip and
|
||||
shutdown-drain case. The product branch does not import marketplace changes or bump its database
|
||||
schema.
|
||||
|
||||
Independent review of the implementation follow-up found no remaining blocking
|
||||
findings. Independent runs passed 50 focused regressions with one expected source
|
||||
skip, 20 real-source integration regressions with no skips, and the logging UI
|
||||
interaction test. The reviewer made no edits and did not launch Electron. A further independent
|
||||
review of the newer encoded-local-owner contract found no issues; all six
|
||||
provenance tests passed on exact `59721f97172acffffe960a6268a2916a90a6c554`,
|
||||
with an additional probe confirming the local encoded owner.
|
||||
|
||||
Fixed review findings have deterministic regressions: 31 days of low-volume
|
||||
appends and export cannot extend segment retention via mtime; stale sources cannot
|
||||
rotate away existing evidence; settings-write failure shows actual host state;
|
||||
log draining still completes when another shutdown stage rejects; and activation
|
||||
permission denial produces a fixed host record before runtime creation.
|
||||
|
||||
Exact prior head `e3cc23bca81a4187ccc13b90d3882889d524ca3b` passed remote
|
||||
[test 40339839263](https://github.com/binaricat/Netcatty/actions/runs/40339839263)
|
||||
and [build-packages 40339839660](https://github.com/binaricat/Netcatty/actions/runs/40339839660).
|
||||
Those runs do not validate the follow-up head. Its exact-head CI is reported in
|
||||
the PR after pushing.
|
||||
|
||||
The native fixture build is reproducible: 3,270 bytes, SHA-256
|
||||
`c3770a55676ed708f01ec1e0e0b9968ffb5e03c02303ee7cb9f739bde27c9de0`.
|
||||
The concise flow and commands are in [Local plugin logging](local-logging.md#native-qa-with-synthetic-data).
|
||||
Native screenshots and recording remain assigned to the parent's working cloud
|
||||
GUI machine. No local sandbox bypass or packaging retry is authorized by this
|
||||
handoff.
|
||||
@@ -0,0 +1,178 @@
|
||||
# Local plugin logging
|
||||
|
||||
Local file persistence defaults to off. The existing permission panel keeps its
|
||||
bounded security history independently. Changing or clearing local log files does
|
||||
not clear that history, grant permissions, or change a plugin's source.
|
||||
|
||||
## Saved records
|
||||
|
||||
`PluginLogService` extends the existing `PluginLogger`. It owns one shared sink at
|
||||
`<userData>/plugins/logs/host-v1`, outside plugin storage. Files contain JSON Lines.
|
||||
They stay on the device. No cloud upload or sync path includes these files or the
|
||||
local preference at `<userData>/plugins/local-logging.json`.
|
||||
|
||||
Host audit records contain these facts:
|
||||
|
||||
- Host-assigned plugin ID, installed version, archive SHA-256, runtime ID and kind.
|
||||
- Source ID, source kind, and namespace from an injected host provenance resolver.
|
||||
Missing provenance is `null`. Manifest claims never imply an official source.
|
||||
- Registered host RPC operation, authorization outcome, result, numeric error code,
|
||||
elapsed milliseconds, and available input/output `data` field byte counts.
|
||||
- Target identifiers from host authorization descriptors, replaced with a keyed
|
||||
SHA-256 digest. The random key stays in memory, so target correlation lasts only
|
||||
for the current app process.
|
||||
- Cumulative dropped-record and write-error counts at enqueue time.
|
||||
|
||||
Runtime start, running, stop, failure, protocol rejection, and permission revocation
|
||||
produce separate host facts. Required and advanced activation permission failures
|
||||
produce a fixed `runtime.authorization` event even before a runtime ID exists. The registry records known RPC attempts even when
|
||||
validation or authorization fails. Authorization has separate `allowed`, `denied`,
|
||||
`not-required`, and `not-reached` states. A handler error after approval preserves
|
||||
`allowed` and records the failed result. Unknown values remain explicit.
|
||||
|
||||
The byte counts measure UTF-8 bytes in a string `data` field, or byte length for a
|
||||
binary `data` field. They are not total wire traffic or decoded base64 size.
|
||||
Missing measurements are `null`. Streaming traffic outside these calls does not
|
||||
become a complete per-byte trace. Host facts describe what the host observed;
|
||||
plugin-returned success cannot prove an external side effect occurred.
|
||||
|
||||
Host audit records never copy request bodies, response bodies, error messages,
|
||||
credentials, API keys, passwords, prompts, Notes, SQL, query results, or terminal
|
||||
content. Error codes come from the host RPC error set. No audit field accepts
|
||||
plugin-supplied source identity.
|
||||
|
||||
## Optional plugin text
|
||||
|
||||
Verbose diagnostics require a separate confirmation and turn off after app
|
||||
restart. Console messages, utility stdout/stderr, and `logger.write` text use
|
||||
`recordType: plugin-diagnostic` and `trust: plugin-authored-unverified`. The host
|
||||
adds identity outside the nested `diagnostic` object. Plugin fields named
|
||||
`pluginId`, `sourceId`, or `recordType` cannot replace reserved metadata.
|
||||
|
||||
Text and structured fields have traversal, length, depth and total-record limits.
|
||||
Control characters are removed and known sensitive field names are replaced.
|
||||
These bounds do **not** make arbitrary plugin text safe. A plugin can put a secret
|
||||
under an unexpected key or inside a sentence. Review files before sharing them.
|
||||
No plugin text is persisted when verbose diagnostics are off.
|
||||
|
||||
## Limits and lifecycle
|
||||
|
||||
The shared sink retains at most three files of 1 MiB each, for at most seven days.
|
||||
Expiry uses the first host timestamp in each segment, read within the existing
|
||||
16 KiB record bound. Appending cannot extend a segment's lifetime. Export uses
|
||||
the same cutoff. A segment is removed when its first record expires; capacity
|
||||
rotation or segment expiry can remove later records sooner. Retention runs on
|
||||
startup, writes and hourly while the app runs, including after saving is disabled. The app cannot delete expired files while it is closed.
|
||||
The asynchronous queue accepts at most 256 KiB and records drops when full.
|
||||
Plugin text can occupy only a quarter of that queue, leaving capacity for host
|
||||
facts. Diagnostics also reuse the existing per-runtime log token bucket (240
|
||||
records per minute, with a 240-record initial burst), including console output. Each
|
||||
record is limited to 16 KiB. Oversized records are dropped. Write failures increase
|
||||
both drop and error counts and never fail the plugin operation.
|
||||
|
||||
Turning saving off stops ingress, discards queued records that have not started,
|
||||
and waits for any current write. Runtime cleanup closes its diagnostic logger.
|
||||
App shutdown drains accepted writes through the existing manager shutdown path,
|
||||
including when another cleanup stage rejects. A failed settings write refreshes
|
||||
the checkbox from actual host state while retaining the original error.
|
||||
Clearing pauses ingress, discards queued records, waits for a current write and
|
||||
removes only the sink's known files. Cancellation leaves them intact. Export takes
|
||||
a bounded snapshot on the write queue and uses the native Save dialog.
|
||||
|
||||
Files use mode 0600 and the directory uses 0700 on systems that support them.
|
||||
The host filesystem broker rejects writes to the audit directory and preferences,
|
||||
including canonical paths reached through symlinks. Plugin views cannot invoke
|
||||
log management IPC. No plugin API exposes audit mutation. These local files are
|
||||
not a tamper-evident ledger against the OS account or the development-only utility
|
||||
runtime, which already has full Node and filesystem authority.
|
||||
|
||||
## Integration boundaries
|
||||
|
||||
`PluginLogSettings` is a standalone compact component, mounted by a single import
|
||||
and element in `SettingsPluginsTab`. It adds no marketplace tab and changes no
|
||||
Installed, Official, Third-party, or source-database implementation. When merging
|
||||
with the marketplace branch, preserve this element in the host settings surface.
|
||||
|
||||
`createPluginHostService` now injects the host-owned
|
||||
`createPluginLogProvenanceResolver(database)` by default. It consumes the source
|
||||
API confirmed on #3981 at `59721f97172acffffe960a6268a2916a90a6c554`:
|
||||
|
||||
- `getActivePlugin(pluginId)` supplies the installed version, hash and source.
|
||||
- `getRetainedPluginSource(pluginId)` must agree with the installed source.
|
||||
- `privateOwner(pluginId)` supplies the opaque namespace.
|
||||
- `publicPluginId(owner)` must round-trip to the host-assigned public ID.
|
||||
|
||||
The adapter receives host activation identity only, after the existing caller
|
||||
guards. It never accepts source or owner fields from plugin request parameters.
|
||||
Missing APIs, an absent installation, changed version/hash, a retained-source
|
||||
mismatch or an orphaned owner leave provenance explicitly unknown. There is no
|
||||
bare-plugin-ID fallback. The current host API encodes every owner, including
|
||||
local imports; a bare ID returned as an owner remains unknown.
|
||||
|
||||
Each logger snapshots the source binding and supplies a host-only `isCurrent`
|
||||
check. The sink checks it at enqueue, after asynchronous preflight, before
|
||||
rotation mutations and immediately before appending. Records from a replaced or
|
||||
uninstalled source are dropped and counted. They are never relabelled as the new
|
||||
source. Already saved records keep their original identity.
|
||||
|
||||
The optional `resolvePluginProvenance` override remains host-owned. Its result
|
||||
contains `sourceId`, `sourceKind`, `namespace`, and an optional synchronous
|
||||
`isCurrent` function. Source IDs and kinds accept 1-128 ASCII letters, digits,
|
||||
periods, underscores, colons and hyphens. Opaque namespaces accept the same
|
||||
characters up to 256 bytes. URLs, display names and plugin claims are not source
|
||||
identity. `official` describes the host-bound source, not official maintenance.
|
||||
|
||||
The source database, marketplace page and sync implementation are unchanged by
|
||||
this adapter. It also needs no new shared host-registration change for transport.
|
||||
|
||||
## Native QA with synthetic data
|
||||
|
||||
Use the parent's working cloud GUI machine, a fresh isolated profile, and the
|
||||
synthetic fixture below. Do not retry the blocked local Electron launch.
|
||||
Do not open real logs or credentials. Capture screenshots of the settings card,
|
||||
verbose disclosure, and native Clear confirmation. Record a short flow:
|
||||
|
||||
1. Open Plugins settings. Confirm both options are off and no host-v1 files exist.
|
||||
2. Enable local saving. Invoke a synthetic plugin host operation and deny another.
|
||||
3. Open the folder and export to a synthetic destination. Inspect parsed JSONL for
|
||||
host identity, outcomes, target digests, and absence of synthetic secret bodies.
|
||||
4. Open verbose disclosure and cancel. Confirm no plugin text is saved. Confirm
|
||||
the disclosure, write synthetic text, and verify the unverified record type.
|
||||
5. Disable/revoke the plugin. Confirm its later text cannot append. Restart the
|
||||
app and verify that local saving remains selected but verbose text is off.
|
||||
6. Cancel Clear, then confirm it. Confirm the files are removed and the permission
|
||||
panel's separate security history remains. Turn local saving off.
|
||||
|
||||
The fixture is `electron/plugins/fixtures/local-logging-plugin`. Build it with:
|
||||
|
||||
```sh
|
||||
node --input-type=module -e 'import { buildPluginPackage } from "@netcatty/plugin-cli"; await buildPluginPackage("electron/plugins/fixtures/local-logging-plugin", "/tmp/netcatty-local-logging-fixture.ncpkg");'
|
||||
```
|
||||
|
||||
Package: 3,270 bytes. SHA-256:
|
||||
`c3770a55676ed708f01ec1e0e0b9968ffb5e03c02303ee7cb9f739bde27c9de0`.
|
||||
It declares only `commands` and `menus`; its command-palette entries are under
|
||||
**Logging QA**. **Emit synthetic diagnostic** tests redaction and nested spoofed
|
||||
identity fields. **Emit synthetic diagnostic burst** emits 400 console messages
|
||||
to exercise drop accounting. **Test undeclared network denial** must return denial
|
||||
without a prompt or network I/O because the manifest declares no network access.
|
||||
No request body or credential is supplied. Do not grant additional permissions.
|
||||
|
||||
Capture the compact settings card, verbose disclosure and native Clear dialog,
|
||||
plus a short recording of the enable/emit/export/clear flow. On a #3981 integration
|
||||
build, a normal local import must log the source and opaque owner returned by its
|
||||
host database. On the older base, both stay explicitly unknown. Do not forge an
|
||||
Official source through fixture metadata. The real-source replacement case is
|
||||
covered by `pluginLogProvenance.integration.test.cjs` on the documented source
|
||||
integration worktree; it uses only temporary synthetic database state.
|
||||
|
||||
The execution environment for this change could not launch Electron: its SUID
|
||||
sandbox helper is not root-owned with mode 4755. Electron aborted before opening
|
||||
any profile. No sandbox-disable retry or system-permission change was attempted.
|
||||
`npm run pack:dir` also failed while rebuilding the serialport native dependency:
|
||||
node-gyp tried to create `/home/agent/.electron-gyp` and received ENOENT. No
|
||||
system-directory change or packaging retry was attempted.
|
||||
|
||||
Native screenshots and recording therefore remain pending in the parent desktop
|
||||
QA queue. Component interaction tests are separate evidence and do not replace
|
||||
that acceptance.
|
||||
@@ -1,84 +1,56 @@
|
||||
# Official static distribution client
|
||||
# Official registry 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.
|
||||
The Community page refreshes the fixed endpoint
|
||||
`https://raw.githubusercontent.com/binaricat/netcatty-plugins/main/catalog.json`
|
||||
in the main process. The renderer cannot choose a registry URL. This endpoint
|
||||
must be published separately; client implementation and synthetic tests do not
|
||||
prove the repository or an online artifact is available.
|
||||
|
||||
## Snapshot contract
|
||||
## Catalog contract
|
||||
|
||||
This client implements the version-1 contract from
|
||||
`binaricat/netcatty-plugins@7bfe23e5627e16ba63806de6dded5b2667c02984`.
|
||||
The source catalog/index remain unchanged; **no `readmeAsset` field is added**.
|
||||
`catalog.json` is an array of at most 1,000 current entries. Each entry supplies
|
||||
`id`, `version`, `name`, `description`, `author`, `repository` (`owner/repository`),
|
||||
`commit` (full 40-character SHA), `license`, `readme` (relative Markdown path),
|
||||
and `manifest`. Optional `artifact` contains `url` and SHA-256 `sha256`.
|
||||
Unknown catalog-entry and artifact fields are rejected. The whole JSON value,
|
||||
including manifest defaults, uses the existing 128-level / 100,000-value budget
|
||||
and finite-number validation. The existing manifest schema, browser-only distribution policy, identity/version
|
||||
and license checks apply. Source-only entries remain build-and-import examples.
|
||||
No download counts, ratings, or timestamps are invented. Brand previews remain
|
||||
bundled and reviewed; arbitrary author image URLs are never loaded.
|
||||
|
||||
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.
|
||||
Reads are anonymous HTTPS, redirect-free for the registry, bounded to 2 MiB,
|
||||
and time out after 30 seconds. All entries must validate, safely clone, and generate every selection token before
|
||||
atomically replacing the current snapshot. Invalid JSON/schema, network failure, and oversized responses
|
||||
leave the previous good snapshot intact. Errors and Retry remain visible. The Community header also has Refresh, including
|
||||
when the last refresh succeeded; it is disabled while a request is pending.
|
||||
The last good catalog is retained **in memory for this application session**;
|
||||
a fresh offline launch uses bundled examples. There is no new disk cache,
|
||||
database migration, cloud sync record, or credential requirement.
|
||||
|
||||
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.
|
||||
## Selection and installation
|
||||
|
||||
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.
|
||||
Each returned entry has a host-generated `selectionToken` covering its complete
|
||||
JSON contents. This is a concurrency check, **not a signature or trust proof**.
|
||||
README requests must still match that selection and fetch its pinned source
|
||||
commit. A changed entry requires refresh and renewed review.
|
||||
|
||||
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.
|
||||
A manual install must first refresh the official catalog successfully and match
|
||||
the selected token again. Failed refresh is browse-only, even if the cached
|
||||
entry has an artifact. Withdrawal, version changes, permission changes, README
|
||||
changes, or artifact hash changes cannot silently replace the selected package.
|
||||
Archive download then uses the existing approved-host, size, exact SHA-256,
|
||||
manifest and package validation pipeline. Installation leaves the plugin disabled;
|
||||
existing authorization gates still apply on activation/use.
|
||||
|
||||
## Bounds, cache and selection
|
||||
`index.json` release history and withdrawal records are not consumed by this
|
||||
first client. Removing an entry does **not** disable, uninstall, update, or delete
|
||||
data from an already installed plugin. There are no background code updates.
|
||||
|
||||
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.
|
||||
## Verification boundary
|
||||
|
||||
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.
|
||||
Service regressions use synthetic network responses and package bytes; the
|
||||
existing package manager tests cover archive admission. No third-party plugin,
|
||||
real credential, real sync account, or live registry write is used. Native QA
|
||||
must separately verify the visible offline/retry state and eventual real catalog
|
||||
availability once the repository is published.
|
||||
@@ -226,3 +226,33 @@ after durable user data can exist.
|
||||
- PR 9 supplies signed publisher principals and trust policy through placement;
|
||||
signed identity changes force a fresh grant instead of widening an unsigned
|
||||
grant silently.
|
||||
|
||||
## Inspect and revoke actual grants
|
||||
|
||||
The installed package detail separates declared permissions from **Saved
|
||||
grants**. The host-only panel lists resource, scope, session (when applicable),
|
||||
and grant time. Each row indicates whether its declaration hash and security
|
||||
principal match the host-owned current activation. Saved grants from an older
|
||||
package or a stopped/disabled activation remain visible as inactive. One-use decisions do not appear as retained grants. Refresh
|
||||
reads the engine again. Recent security events include at most 100 entries and
|
||||
project only event, time, permission, and scope. Arbitrary database details,
|
||||
operation IDs, terminal content, and credential material never cross this UI
|
||||
boundary.
|
||||
|
||||
**Disable and revoke all grants** runs through the manager mutation queue. It
|
||||
persists disabled state before revoking grants, invalidates contributions, and
|
||||
waits for runtime shutdown. Existing runtime teardown cancels RPC, streams,
|
||||
leases, providers, and companions. Every cleanup stage is attempted if grant deletion, audit persistence, or
|
||||
contribution invalidation fails. Multiple errors are aggregated and reported;
|
||||
the UI never claims successful revocation when persistence or cleanup failed. Explicit enable requests fresh access.
|
||||
This management bridge is available only to the trusted app renderer. It is
|
||||
not a plugin RPC method and grants no plugin authority over another package.
|
||||
|
||||
`PluginNetworkRequest` and `PluginNetworkResponse` now derive from the canonical
|
||||
schema. `credentialLease` and `authorization` describe the existing host lease
|
||||
injection path. Hosts advertise `network.credential-lease` for discovery without
|
||||
changing existing API versions or disabling legacy requests. Plugins may
|
||||
require that feature or handle its absence. Optional authorization defaults to
|
||||
Bearer. Basic authorization accepts a bounded username. Plaintext is redeemed
|
||||
only inside the host after origin permission and operation binding checks.
|
||||
See the [synthetic fixture](../../examples/plugins/lease-http/README.md).
|
||||
@@ -0,0 +1,74 @@
|
||||
# Terminal sidebar pages
|
||||
|
||||
The `terminal-sidebar` view location requires `views.terminal-sidebar` and the
|
||||
existing `views` permission. This adds navigation and plugin-owned content to
|
||||
the existing terminal pane/split system. It is not a session-data capability,
|
||||
Provider subscription, terminal history reader or command execution API.
|
||||
|
||||
## Host-owned navigation
|
||||
|
||||
An Extension pages menu in the normal terminal toolbar lists visible pages
|
||||
with their plugin source. Selecting one switches the existing focused pane;
|
||||
selecting an already-open tool focuses that pane. Existing split menus include
|
||||
the same contributions and enforce unique tools and the eight-pane limit.
|
||||
Host-generated `plugin-view:<namespaced-view-id>` keys cannot replace built-in
|
||||
tools. Declared order and ID determine listing order; existing host docking,
|
||||
split resizing, magnification and close controls remain authoritative.
|
||||
|
||||
The manager's internal Open event can select a page only when an open terminal
|
||||
is already selected. Otherwise the host asks the user to select a terminal; it
|
||||
does not connect or open a saved host. Launcher events target the session from
|
||||
the actual host terminal toolbar and are rechecked against current selection.
|
||||
These are internal host UI events, not public SDK router methods or trusted
|
||||
plugin `userGesture` flags.
|
||||
|
||||
## Session ownership without data sharing
|
||||
|
||||
The renderer binds each page to the actual terminal tab/workspace and current
|
||||
focused child session. It passes only `netcatty.surface: terminal-sidebar` as
|
||||
the view context. Internal ownership IDs are used for host state scoping, not
|
||||
returned to the page or treated as resource grants. A future selected-session
|
||||
data API must establish its own live endpoint authority and permissions.
|
||||
|
||||
Changing terminal tabs or workspace focus closes the old native view before
|
||||
the new one can receive focus. Session removal, pane close, withdrawal of a
|
||||
visible contribution, disable/update/uninstall and runtime loss remove the
|
||||
affected page. Unknown saved routes are pruned without opening them; restoring
|
||||
a layout never grants the page or any session-data permission. State buckets
|
||||
are for the plugin's own UI data, not an isolation boundary against that same
|
||||
plugin's already-authorized own-state API. Ordinary KV does not become synced.
|
||||
|
||||
PersistentSidePanelPortal moves a stable mount between the actual pane,
|
||||
magnification host and hidden parking host. There is no fallback native view
|
||||
over the entire terminal layer. Native geometry follows host DOM relocation,
|
||||
resize and scrolling; duplicate geometry updates are suppressed. Hidden/inert
|
||||
or parked mounts cannot reveal a native view.
|
||||
|
||||
## Focus and security surfaces
|
||||
|
||||
The existing sandbox ViewHost, immutable package/protocol checks, own-command
|
||||
and view-state boundaries are reused. Embedded pages start hidden. Host
|
||||
dialogs and overlapping portals hide them, and main-process App Lock closes
|
||||
them. Escape is host-owned and closes the plugin pane.
|
||||
|
||||
Native WebContents focus is reported only to its actual owner's trusted
|
||||
renderer using a canonical instance-ID event. It updates the selected pane;
|
||||
it does not expose an Electron object to the plugin. Revealing multiple panes
|
||||
does not focus all of them: only the host-selected pane can request focus.
|
||||
The main process verifies actual owner, visibility, foreground and unlocked
|
||||
state before honoring that request. A plugin has no SDK method to invoke the
|
||||
host focus bridge. No session or page text is added to focus/audit logs.
|
||||
|
||||
## Example and evidence
|
||||
|
||||
`com.netcatty.session-scratchpad` 0.1.0 asks only for its own `views`. It saves,
|
||||
reopens and clears explicitly typed synthetic text. Package instructions and
|
||||
minimal native actions are in its README. Repository tests use real split
|
||||
tree, layout state, persistent portal, launcher and embedded-view components;
|
||||
host tests cover owner-bound focus, hidden/locked refusal and withdrawal.
|
||||
Feature and permission admission, limits, old APIs and previous Vault/command
|
||||
fixes are checked on the same stack. Independent review, exact-SHA CI and
|
||||
parent native QA remain separate acceptance gates recorded on the Draft PR.
|
||||
|
||||
ThemePack, selected context/terminal drafts and SFTP handles remain subsequent
|
||||
bounded stages. Adding a sidebar page does not count as implementing them.
|
||||
@@ -0,0 +1,159 @@
|
||||
# Host color theme packs
|
||||
|
||||
Theme packs contribute validated light and dark host colors. They cannot inject
|
||||
CSS, access host DOM, change layout, load remote resources, replace security
|
||||
controls, or apply themselves. The feature is `appearance.theme-packs`.
|
||||
|
||||
## Install and select
|
||||
|
||||
Use normal Settings > Plugins to install and enable a package. In Appearance,
|
||||
choose a plugin theme for the current light or dark mode, then select Preview or
|
||||
Apply. Preview changes only the current window and writes no preference. Cancel,
|
||||
closing Appearance, changing mode, package withdrawal, or a newer committed
|
||||
revision ends that preview. Apply saves a host-owned, device-local choice and
|
||||
refreshes other application windows. Restore default clears the plugin choice
|
||||
for that mode and selects Snow or Midnight. Selecting a built-in swatch also
|
||||
clears that mode's plugin choice. Failures remain visible and are not reported as
|
||||
successful applications.
|
||||
|
||||
The current view environment is globally broadcast. To keep temporary previews
|
||||
local, it publishes **committed** theme tokens only. Plugin pages follow an
|
||||
applied theme through their existing environment subscription; they do not
|
||||
receive another window's temporary preview. This does not add an owner-local
|
||||
view environment API.
|
||||
|
||||
## Data-only package
|
||||
|
||||
A pure theme uses `main: { "data": "theme" }`, contributes only `themes`, and
|
||||
omits permissions, activation events and companions. Its code runtime is never
|
||||
started, including by the runtime supervisor's startup or explicit-start path.
|
||||
Browser code plugins may also declare themes; choosing a theme does not start a
|
||||
runtime or grant their functional permissions. Only enabled packages appear.
|
||||
|
||||
```json
|
||||
{
|
||||
"features": { "required": ["appearance.theme-packs"] },
|
||||
"main": { "data": "theme" },
|
||||
"contributes": {
|
||||
"themes": [{
|
||||
"id": "com.example.colors.forest",
|
||||
"label": "Forest",
|
||||
"variant": "light",
|
||||
"entry": "forest.json"
|
||||
}]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
This fragment belongs in an otherwise complete manifest. Contribution IDs use
|
||||
the package namespace. Palette paths are package-relative JSON files, included
|
||||
and checked by the normal CLI/package store. Hosts lacking the required feature
|
||||
reject compatibility deterministically.
|
||||
|
||||
```json
|
||||
{ "formatVersion": 1, "base": "light", "colors": { "accent": "#166534" } }
|
||||
```
|
||||
|
||||
Colors merge onto a fixed accessible light/dark host base. Only opaque `#RRGGBB`
|
||||
values and canonical semantic keys are accepted. The 15 editable keys cover
|
||||
background/foreground, card and popover pairs, secondary and muted pairs, accent,
|
||||
destructive and its foreground, border and input. Primary, ring and the accent
|
||||
foregrounds are derived together, matching the existing theme conversion.
|
||||
Nineteen output fields are not nineteen independent controls.
|
||||
|
||||
Each package has at most 32 themes; each palette is at most 64 KiB; the catalog
|
||||
and validated cache contain at most 256 entries. Schema and contrast checks run
|
||||
before catalog publication. Text pairs and state colors require at least 4.5:1;
|
||||
control borders and focus information require at least 3:1 against their
|
||||
surfaces. Invalid palettes produce bounded, content-free diagnostics and cannot
|
||||
be previewed or applied. Files are read from verified immutable package roots,
|
||||
with bounded reads and a no-follow final file open.
|
||||
|
||||
## Ownership, recovery and security appearance
|
||||
|
||||
The host stores selections separately from plugin KV, settings sidecars and
|
||||
cloud sync. A reference binds plugin ID, theme ID, exact version, archive digest
|
||||
and variant. Application uses a revision check plus an active-package check just
|
||||
before the atomic preference/audit transaction. An audit write failure rolls
|
||||
back the selection. Audit records contain revision/action metadata, not palette
|
||||
or user content.
|
||||
|
||||
Disable, uninstall, update, missing entries and integrity failures withdraw the
|
||||
palette. A saved missing reference remains visible as unavailable while the
|
||||
same-mode safe host palette is used; a changed package is not automatically
|
||||
applied. Preview owner and committed revision checks prevent a late cancellation
|
||||
from replacing another preview or a newer application.
|
||||
|
||||
Host dialogs, App Lock, grant/revoke, host details, keychain panels, host-key
|
||||
prompts and the credential picker use fixed host security variables while a
|
||||
plugin theme is active. The pack controls no font, icon, opacity, CSS selector,
|
||||
button text or ordering. System forced-colors/high-contrast preferences use a
|
||||
fixed host high-contrast fallback. Personal accent, fonts, terminal choices and
|
||||
accessibility preferences retain their authority. Existing personal custom CSS
|
||||
remains a user setting and is not exposed to plugins.
|
||||
|
||||
This first layer changes host semantic colors. Static terminal palette linkage,
|
||||
custom high-contrast variants, fonts, density, icons and backgrounds remain
|
||||
separate bounded follow-ups; their fields are not silently accepted here.
|
||||
|
||||
## Example and validation
|
||||
|
||||
`examples/plugins/forest-theme-pack` is a four-file data package with light and
|
||||
dark Forest accents. It requests no permissions and contains no JavaScript.
|
||||
The starter, Vault Scratchpad and Session Scratchpad pages now subscribe to the
|
||||
existing seven public HSL tokens through `theme.js`, dispose on page hide, and
|
||||
use responsive host-colored controls. No new styling RPC is needed.
|
||||
|
||||
Local evidence includes normal CLI validation/packing, schema/admission and
|
||||
no-runtime checks; host catalog/identity/size/contrast/CAS/migration/atomic-audit
|
||||
checks; picker preview/apply/cancel/reset and stale-owner/withdrawal/high-contrast
|
||||
checks; and view-template initial/live environment/disposal checks. Exact-SHA
|
||||
CI, independent review and parent native multi-window/security-surface acceptance
|
||||
remain separate delivery gates. Local production builds do not establish them.
|
||||
|
||||
### First review / CI delta
|
||||
|
||||
The first review found that muted text was validated only against its namesake
|
||||
muted surface, although host descriptions and statuses also use it on background,
|
||||
card and popover surfaces. The validator now checks generic and muted text on
|
||||
all neutral surfaces that reuse those tokens. The white-on-white counterexample
|
||||
is rejected both by the pure validator and the host catalog. Forest light/dark
|
||||
palettes remain valid.
|
||||
|
||||
The first exact-head test run `40317690002` failed with 15187 passing tests,
|
||||
one failure and 50 skips. Its single failure was the unchanged TERM-SSHD
|
||||
concurrent failure-release test expecting a particular invocation to reach OPEN
|
||||
first. Both calls perform asynchronous local-file preparation. The test now
|
||||
requires exactly one explicit Permission denied result, one successful result
|
||||
with matching bytes, and no rejected concurrent handle; it retains concurrency
|
||||
without assuming filesystem completion order. No transfer implementation changed.
|
||||
The test file's original SHA256 was identical in e5bd294c, 733f44c and fdeeaf74:
|
||||
`8b200d9f6a05ac5cd91fe4e0a95ea36a770d22267a6a0162b4c9fc927beb52a7`.
|
||||
This is source identity and a source-based ordering diagnosis, not a claim that a
|
||||
baseline runtime failure was reproduced locally. The updated test needs the new
|
||||
exact-head CI result. That same first run passed the added Vite browser-import
|
||||
smoke, resolving the earlier local temp-directory limitation for that head.
|
||||
|
||||
### Native security-color delta
|
||||
|
||||
Parent native acceptance at f868 verified zero-permission installation/enabling,
|
||||
light/dark Preview and Cancel, Apply across main/Settings windows, Restore
|
||||
default, restart persistence, and Disable fallback with the unavailable notice.
|
||||
The isolated schema-3 profile was not touched. These core results remain valid.
|
||||
|
||||
Native QA also found Forest-colored inner New Host cards/inputs/Save and some
|
||||
grant-panel text inside an otherwise fixed security container. The cause was
|
||||
Tailwind's derived `--color-*` aliases: changing only raw semantic tokens at the
|
||||
boundary does not recompute aliases already resolved on an ancestor. The same
|
||||
existing security rule now fixes both raw tokens and those color aliases. The
|
||||
selector and protected regions are unchanged; ordinary application cards remain
|
||||
theme-controlled. No host, credential or grant operation changed.
|
||||
|
||||
The regression renders the actual HostDetailsPanel (including General, Address,
|
||||
Credentials cards, real Inputs and Save), plus the real grant-panel root. It
|
||||
compiles the production Tailwind source, verifies which aliases those utilities
|
||||
consume, checks the fixed declarations, and verifies an ordinary card is outside
|
||||
the security selector. This is component/compiled-CSS coverage, not a claim of
|
||||
native computed-color acceptance. The parent's native color delta remains the
|
||||
final visual check. A local Chromium probe aborted because its SUID sandbox was
|
||||
not configured; sandboxing was not disabled to retry.
|
||||
@@ -0,0 +1,80 @@
|
||||
# Vault extension pages
|
||||
|
||||
`views.vault-pages` adds the `vault-page` view location. A package must require
|
||||
the feature and declare the existing `views` permission. Hosts without the
|
||||
feature reject the package deterministically. Existing view locations and API
|
||||
versions keep their behavior; there is no public plugin router or new Vault
|
||||
read/write permission.
|
||||
|
||||
## User workflow
|
||||
|
||||
Visible, enabled contributions appear in Vault's **Extensions** group. The
|
||||
host generates a namespaced route from the declared view ID, orders by declared
|
||||
order and ID, localizes the label, and resolves the declared icon through the
|
||||
existing safe icon pipeline. Built-in navigation and security entry points
|
||||
remain host-owned. Collapsed navigation retains source-labelled accessible
|
||||
buttons.
|
||||
|
||||
Selecting a page mounts its existing sandbox WebContentsView in the Vault main
|
||||
content area. It does not create a top tab, use an aside overlay, open a
|
||||
connection, or share host/session/Vault records. The trusted host context
|
||||
contains only `netcatty.surface: vault-page`. The manager's existing Open action
|
||||
can select the same page through an internal host event; this is not exposed
|
||||
as a generic SDK navigation capability.
|
||||
|
||||
The `vault-scratchpad` browser example lets the user type synthetic text, save
|
||||
it in the plugin's own view state, return to Hosts, reopen, and clear it. It
|
||||
does not create a Vault Note or enable automatic cloud sync. The fixture asks
|
||||
for only `views`; granting it does not approve another plugin or data access.
|
||||
|
||||
## Ownership and lifecycle
|
||||
|
||||
The host owns the route and validates the selected contribution against the
|
||||
current visible catalog. Missing IDs, disable/uninstall/update, a false `when`,
|
||||
failed activation or a host close return to Hosts. Focus restoration checks
|
||||
the current section and active tab so an old fallback cannot steal focus after
|
||||
a newer selection. Catalog listing does not activate a plugin.
|
||||
|
||||
The embedded host reuses PluginViewHost, its ephemeral session, CSP, no-Node
|
||||
preload, protocol validation, own-command checks, and bounded own view state.
|
||||
No DOM or Electron object enters the SDK. A window-route-and-slot scope separates
|
||||
this page's state from other view placements; actual BrowserWindow ownership is
|
||||
validated separately and does not rely on that state key. This initial embedded slice
|
||||
closes on navigation away even if `retainContextWhenHidden` is declared.
|
||||
|
||||
Each opening reserves an instance ID owned by its actual BrowserWindow before
|
||||
asynchronous activation. Close can cancel that reservation before native
|
||||
construction. Owner closure, shutdown and renderer cleanup prevent a late open
|
||||
from attaching; late responses are also closed by the renderer lifecycle
|
||||
controller. Current plugin version/runtime and `when` are checked before
|
||||
attachment and on subsequent contribution changes. Existing owner checks apply
|
||||
to bounds, visibility, close and view-state IPC.
|
||||
|
||||
Host reload/renderer exit and a view renderer crash close the associated native
|
||||
page. Plain Escape in a Vault page is handled by the host and returns to Hosts;
|
||||
it does not dispatch a plugin command or expose a general navigation API.
|
||||
|
||||
Native child views sit above React portals. A Vault page starts hidden; the
|
||||
trusted renderer reveals it only after checking the mount and host dialogs,
|
||||
menus, popovers, overlay panels and lock surfaces. Overlapping host surfaces
|
||||
hide the page. App Lock additionally closes all hosted plugin views and
|
||||
cancels pending opens in the main process; showing a view while locked fails.
|
||||
|
||||
The shared manifest bound remains 128 views per package. Only one Vault page is
|
||||
mounted per owner; listing and routing do not create a background view queue.
|
||||
View-state JSON size limits and existing runtime/permission quotas remain in
|
||||
force. No page text or state is added to audit logs by this adapter.
|
||||
|
||||
## Validation status
|
||||
|
||||
Source tests cover normal catalog selection, the actual embedded content slot,
|
||||
host-modal occlusion, synchronous tab-away cleanup, withdrawn and invalid
|
||||
routes, internal host selection, delayed open cancellation, feature/permission
|
||||
admission, host ownership, `when` withdrawal and App Lock. Production build,
|
||||
aggregate host tests and precise-SHA CI are reported on the accompanying Draft
|
||||
PR. Parent native acceptance must use the packed browser example through
|
||||
normal Settings and record its exact source SHA/package hash. Unit tests do
|
||||
not substitute for that native acceptance.
|
||||
|
||||
Terminal sidebar pages and selected-session DTOs remain separate stages. This
|
||||
page location does not make those capabilities available.
|
||||
@@ -9,6 +9,7 @@ const PERMISSION_DESCRIPTIONS = {
|
||||
"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.", "菜单入口", "将此插件的操作加入应用菜单。"],
|
||||
"ui.interact": [false, "Host prompts", "Ask the host to show bounded input and choice dialogs.", "宿主交互", "请求宿主显示有界的输入和选择对话框。"],
|
||||
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.", "写入剪贴板", "替换剪贴板内容,可能影响你随后粘贴的数据。"],
|
||||
|
||||
@@ -3,6 +3,14 @@
|
||||
// Shared by host placement, release admission, and the installed-package UI.
|
||||
// A Node entrypoint does not change default placement, but is not admitted to
|
||||
// the normal browser-only product path.
|
||||
function isStaticThemePack(manifest) {
|
||||
return manifest?.main?.data === "theme" && Object.keys(manifest.main).length === 1
|
||||
&& Array.isArray(manifest.contributes?.themes) && manifest.contributes.themes.length > 0
|
||||
&& Object.keys(manifest.contributes).every((key) => key === "themes")
|
||||
&& !Object.keys(manifest.permissions ?? {}).length && !manifest.activationEvents?.length
|
||||
&& !manifest.companionExecutables?.length
|
||||
&& manifest.features?.required?.includes("appearance.theme-packs");
|
||||
}
|
||||
function describePluginRuntime(manifest) {
|
||||
const providers = manifest?.contributes?.providers ?? [];
|
||||
const permissions = [...(manifest?.permissions?.required ?? []), ...(manifest?.permissions?.optional ?? [])];
|
||||
@@ -18,19 +26,20 @@ function describePluginRuntime(manifest) {
|
||||
const hasNode = Boolean(manifest?.main?.node);
|
||||
const defaultRuntimeKind = hasBrowser && !hasCompanions && !hasInterceptors ? "browser" : "utility";
|
||||
return {
|
||||
defaultRuntimeKind,
|
||||
defaultRuntimeKind: isStaticThemePack(manifest) ? "data" : defaultRuntimeKind,
|
||||
dataOnly: isStaticThemePack(manifest),
|
||||
hasNode,
|
||||
declaresAdvanced,
|
||||
browserOnly: hasBrowser && !hasNode && !hasCompanions && !hasInterceptors && !declaresAdvanced,
|
||||
browserOnly: !manifest?.main?.data && hasBrowser && !hasNode && !hasCompanions && !hasInterceptors && !declaresAdvanced,
|
||||
};
|
||||
}
|
||||
|
||||
function assertBrowserOnlyPlugin(manifest) {
|
||||
if (!describePluginRuntime(manifest).browserOnly) {
|
||||
if (!describePluginRuntime(manifest).browserOnly && !isStaticThemePack(manifest)) {
|
||||
const error = new Error("This package requires advanced developer mode; normal plugin management accepts browser-only packages");
|
||||
error.code = "PLUGIN_UNSUPPORTED_RUNTIME";
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { describePluginRuntime, assertBrowserOnlyPlugin };
|
||||
module.exports = { describePluginRuntime, assertBrowserOnlyPlugin, isStaticThemePack };
|
||||
@@ -19,3 +19,9 @@ test('normal release admission rejects advanced declarations and privileged prov
|
||||
assert.equal(describePluginRuntime(manifest).defaultRuntimeKind, 'utility');
|
||||
assert.throws(() => assertBrowserOnlyPlugin(manifest), /developer mode/);
|
||||
});
|
||||
|
||||
test('normal admission accepts only explicit non-executable theme-only data packages',()=>{
|
||||
const manifest={main:{data:'theme'},features:{required:['appearance.theme-packs']},contributes:{themes:[{id:'com.example.theme.light'}]}};
|
||||
assert.doesNotThrow(()=>assertBrowserOnlyPlugin(manifest));assert.equal(describePluginRuntime(manifest).dataOnly,true);assert.equal(describePluginRuntime(manifest).defaultRuntimeKind,'data');
|
||||
for(const change of [{main:{data:'theme',browser:'index.js'}},{permissions:{required:['network']}},{activationEvents:['onStartupFinished']},{contributes:{themes:[],commands:[]}}])assert.throws(()=>assertBrowserOnlyPlugin({...manifest,...change}),/developer mode/);
|
||||
});
|
||||
@@ -0,0 +1,145 @@
|
||||
"use strict";
|
||||
// Pure color projection. Package/wire schema validation belongs to the host.
|
||||
const LIGHT = Object.freeze({
|
||||
background: "#ffffff",
|
||||
foreground: "#161a1d",
|
||||
card: "#ffffff",
|
||||
cardForeground: "#161a1d",
|
||||
popover: "#ffffff",
|
||||
popoverForeground: "#161a1d",
|
||||
secondary: "#e5e7eb",
|
||||
secondaryForeground: "#161a1d",
|
||||
muted: "#f0f2f4",
|
||||
mutedForeground: "#374151",
|
||||
accent: "#1d4ed8",
|
||||
destructive: "#b91c1c",
|
||||
destructiveForeground: "#ffffff",
|
||||
border: "#6b7280",
|
||||
input: "#6b7280",
|
||||
});
|
||||
const DARK = Object.freeze({
|
||||
background: "#111827",
|
||||
foreground: "#f9fafb",
|
||||
card: "#1f2937",
|
||||
cardForeground: "#f9fafb",
|
||||
popover: "#1f2937",
|
||||
popoverForeground: "#f9fafb",
|
||||
secondary: "#374151",
|
||||
secondaryForeground: "#ffffff",
|
||||
muted: "#273244",
|
||||
mutedForeground: "#e5e7eb",
|
||||
accent: "#93c5fd",
|
||||
destructive: "#fca5a5",
|
||||
destructiveForeground: "#111827",
|
||||
border: "#9ca3af",
|
||||
input: "#9ca3af",
|
||||
});
|
||||
function rgb(hex) {
|
||||
return [1, 3, 5].map(
|
||||
(offset) => parseInt(hex.slice(offset, offset + 2), 16) / 255,
|
||||
);
|
||||
}
|
||||
function luminance(hex) {
|
||||
return rgb(hex)
|
||||
.map((v) => (v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4))
|
||||
.reduce((s, v, i) => s + v * [0.2126, 0.7152, 0.0722][i], 0);
|
||||
}
|
||||
function contrast(a, b) {
|
||||
const x = luminance(a),
|
||||
y = luminance(b);
|
||||
return (Math.max(x, y) + 0.05) / (Math.min(x, y) + 0.05);
|
||||
}
|
||||
function toHsl(hex) {
|
||||
const [r, g, b] = rgb(hex),
|
||||
max = Math.max(r, g, b),
|
||||
min = Math.min(r, g, b),
|
||||
delta = max - min,
|
||||
l = (max + min) / 2;
|
||||
let h = 0;
|
||||
if (delta)
|
||||
h =
|
||||
(max === r
|
||||
? ((g - b) / delta + 6) % 6
|
||||
: max === g
|
||||
? (b - r) / delta + 2
|
||||
: (r - g) / delta + 4) * 60;
|
||||
const s = delta ? delta / (1 - Math.abs(2 * l - 1)) : 0;
|
||||
return `${h.toFixed(3)} ${(s * 100).toFixed(3)}% ${(l * 100).toFixed(3)}%`;
|
||||
}
|
||||
// A small margin covers rounding when hex colors become HSL CSS components.
|
||||
function resolveThemePalette(palette) {
|
||||
const colors = {
|
||||
...(palette.base === "dark" ? DARK : LIGHT),
|
||||
...palette.colors,
|
||||
};
|
||||
for (const value of Object.values(colors))
|
||||
if (!/^#[0-9a-f]{6}$/i.test(value)) throw new Error("Invalid theme color");
|
||||
const pairs = [
|
||||
["foreground", "background"],
|
||||
["cardForeground", "card"],
|
||||
["popoverForeground", "popover"],
|
||||
["secondaryForeground", "secondary"],
|
||||
["mutedForeground", "muted"],
|
||||
["destructiveForeground", "destructive"],
|
||||
];
|
||||
for (const [a, b] of pairs)
|
||||
if (contrast(colors[a], colors[b]) < 4.51)
|
||||
throw new Error("Theme text contrast is below 4.5:1");
|
||||
// Host labels and descriptions reuse these text tokens across neutral
|
||||
// surfaces; they do not always sit on their namesake background token.
|
||||
for (const surface of ["background", "card", "popover", "secondary", "muted"])
|
||||
for (const key of ["foreground", "mutedForeground"])
|
||||
if (contrast(colors[key], colors[surface]) < 4.51)
|
||||
throw new Error("Theme neutral-surface text contrast is below 4.5:1");
|
||||
for (const surface of ["background", "card", "popover"]) {
|
||||
for (const key of ["accent", "destructive"])
|
||||
if (contrast(colors[key], colors[surface]) < 4.51)
|
||||
throw new Error("Theme state contrast is below 4.5:1");
|
||||
for (const key of ["border", "input"])
|
||||
if (contrast(colors[key], colors[surface]) < 3.01)
|
||||
throw new Error("Theme control contrast is below 3:1");
|
||||
}
|
||||
const foreground =
|
||||
contrast(colors.accent, "#000000") >= contrast(colors.accent, "#ffffff")
|
||||
? "#000000"
|
||||
: "#ffffff";
|
||||
const tokens = Object.fromEntries(
|
||||
Object.entries(colors).map(([k, v]) => [k, toHsl(v)]),
|
||||
);
|
||||
return Object.freeze({
|
||||
...tokens,
|
||||
primary: tokens.accent,
|
||||
primaryForeground: toHsl(foreground),
|
||||
accentForeground: toHsl(foreground),
|
||||
ring: tokens.accent,
|
||||
});
|
||||
}
|
||||
function securityThemeTokens(mode, highContrast = false) {
|
||||
const colors = highContrast
|
||||
? mode === "dark"
|
||||
? {
|
||||
background: "#000000",
|
||||
card: "#000000",
|
||||
popover: "#000000",
|
||||
foreground: "#ffffff",
|
||||
cardForeground: "#ffffff",
|
||||
popoverForeground: "#ffffff",
|
||||
accent: "#ffff00",
|
||||
border: "#ffffff",
|
||||
input: "#ffffff",
|
||||
}
|
||||
: {
|
||||
background: "#ffffff",
|
||||
card: "#ffffff",
|
||||
popover: "#ffffff",
|
||||
foreground: "#000000",
|
||||
cardForeground: "#000000",
|
||||
popoverForeground: "#000000",
|
||||
accent: "#000080",
|
||||
border: "#000000",
|
||||
input: "#000000",
|
||||
}
|
||||
: {};
|
||||
return resolveThemePalette({ base: mode, colors });
|
||||
}
|
||||
module.exports = { resolveThemePalette, securityThemeTokens, contrast, toHsl };
|
||||
@@ -0,0 +1,33 @@
|
||||
const assert = require("node:assert/strict");
|
||||
const test = require("node:test");
|
||||
const {
|
||||
resolveThemePalette,
|
||||
securityThemeTokens,
|
||||
contrast,
|
||||
} = require("./pluginThemePack.cjs");
|
||||
test("opaque accessible palettes derive coupled accent tokens and fixed high contrast fallback", () => {
|
||||
for (const mode of ["light", "dark"]) {
|
||||
const tokens = resolveThemePalette({ base: mode, colors: {} });
|
||||
assert.equal(tokens.primary, tokens.accent);
|
||||
assert.equal(tokens.ring, tokens.accent);
|
||||
assert.equal(tokens.primaryForeground, tokens.accentForeground);
|
||||
assert.ok(securityThemeTokens(mode, true));
|
||||
}
|
||||
assert.ok(contrast("#000000", "#ffffff") > 20);
|
||||
assert.throws(
|
||||
() =>
|
||||
resolveThemePalette({ base: "light", colors: { foreground: "#ffffff" } }),
|
||||
/contrast/,
|
||||
);
|
||||
});
|
||||
|
||||
test("generic and muted text stay readable on the neutral surfaces that use them", () => {
|
||||
for (const colors of [
|
||||
{ muted: "#000000", mutedForeground: "#ffffff" },
|
||||
{ card: "#000000", cardForeground: "#ffffff" },
|
||||
{ popover: "#000000", popoverForeground: "#ffffff" },
|
||||
{ secondary: "#000000", secondaryForeground: "#ffffff" },
|
||||
]) {
|
||||
assert.throws(() => resolveThemePalette({ base: "light", colors }), /contrast/);
|
||||
}
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
export type SidePanelTool =
|
||||
export type BuiltinSidePanelTool =
|
||||
| 'sftp'
|
||||
| 'browser'
|
||||
| 'scripts'
|
||||
@@ -8,6 +8,12 @@ export type SidePanelTool =
|
||||
| 'system'
|
||||
| 'notes';
|
||||
|
||||
export type SidePanelTool = BuiltinSidePanelTool | `plugin-view:${string}`;
|
||||
|
||||
export function isPluginSidePanelTool(value: unknown): value is `plugin-view:${string}` {
|
||||
return typeof value === 'string' && value.length <= 256 && /^plugin-view:[A-Za-z0-9_.-]+$/.test(value);
|
||||
}
|
||||
|
||||
export type SidePanelSplitDirection = 'horizontal' | 'vertical';
|
||||
|
||||
export type SidePanelPaneNode = {
|
||||
|
||||
@@ -90,6 +90,21 @@ test('sanitize accepts a valid saved preset and rejects malformed data', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('plugin panes keep split uniqueness and limits; saved routes grant no availability and can be pruned', () => {
|
||||
const tool = 'plugin-view:com.example.plugin.page' as const;
|
||||
let layout = createSidePanelLayout('notes', 'notes');
|
||||
layout = splitSidePanelPane(layout, 'notes', tool, 'vertical', { paneId: 'plugin', splitId: 'root' }, 600);
|
||||
const unique = splitSidePanelPane(layout, 'notes', tool, 'vertical', { paneId: 'duplicate', splitId: 'unused' }, 600);
|
||||
assert.equal(collectSidePanelPanes(unique.root).length, 2);
|
||||
assert.equal(getFocusedSidePanelPane(unique).tool, tool);
|
||||
assert.ok(createWorkspaceLayoutPreset(layout));
|
||||
assert.equal(withoutUnavailableSidePanelTools(layout, (item) => item === 'notes')?.root.type, 'pane');
|
||||
for (let i = 0; i < 10; i++) layout = splitSidePanelPane(layout, getFocusedSidePanelPane(layout).id,
|
||||
`plugin-view:com.example.plugin.page${i}`, 'horizontal', { paneId: `p${i}`, splitId: `s${i}` }, 600);
|
||||
assert.equal(collectSidePanelPanes(layout.root).length, 8);
|
||||
assert.equal(sanitizeWorkspaceLayoutPreset({ version: 1, layout: { root: { id: 'invalid', type: 'pane', tool: 'plugin-view:../outside' }, focusedPaneId: 'invalid' } }), null);
|
||||
});
|
||||
|
||||
test('sanitize rejects duplicate node ids', () => {
|
||||
assert.equal(
|
||||
sanitizeWorkspaceLayoutPreset({
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
closeSidePanelPane,
|
||||
collectSidePanelPanes,
|
||||
getFocusedSidePanelPane,
|
||||
isPluginSidePanelTool,
|
||||
type SidePanelLayout,
|
||||
type SidePanelLayoutNode,
|
||||
type SidePanelTool,
|
||||
@@ -32,7 +33,7 @@ const SIDE_PANEL_TOOLS: ReadonlySet<SidePanelTool> = new Set([
|
||||
]);
|
||||
|
||||
function isSidePanelTool(value: unknown): value is SidePanelTool {
|
||||
return typeof value === 'string' && SIDE_PANEL_TOOLS.has(value as SidePanelTool);
|
||||
return typeof value === 'string' && (SIDE_PANEL_TOOLS.has(value as SidePanelTool) || isPluginSidePanelTool(value));
|
||||
}
|
||||
|
||||
function sanitizeSidePanelLayoutNode(
|
||||
|
||||
@@ -70,6 +70,7 @@ module.exports = {
|
||||
'domain/terminalPromptSecurity.shared.cjs',
|
||||
'domain/pluginRuntimePolicy.cjs',
|
||||
'domain/pluginPermissionDescriptions.cjs',
|
||||
'domain/pluginThemePack.cjs',
|
||||
// Main-process SSH, SFTP, and port-forward bridges share this matcher.
|
||||
// TypeScript under domain/ is excluded below, so the .cjs must be listed.
|
||||
'domain/systemManager/containerRuntime.shared.cjs',
|
||||
|
||||
@@ -311,7 +311,7 @@ test("TERM-SSHD queued cancellation releases its turn without closing the owner
|
||||
assert.equal(transferBridge._getSessionFastDownloadWaiterCountForTests(fixture.sftp), 0);
|
||||
});
|
||||
|
||||
test("TERM-SSHD failed transfer releases the physical channel for the next alias", async (t) => {
|
||||
test("TERM-SSHD failed transfer releases the physical channel for the next alias", { timeout: 5_000 }, async (t) => {
|
||||
const fixture = await createSequentialTransferFixture(t);
|
||||
const originalOpen = fixture.sftp.open;
|
||||
let rejectFirst = true;
|
||||
@@ -321,10 +321,16 @@ test("TERM-SSHD failed transfer releases the physical channel for the next alias
|
||||
args.at(-1)(Object.assign(new Error("Permission denied"), { code: 3 }));
|
||||
} else originalOpen(...args);
|
||||
};
|
||||
const [first, next] = await Promise.all([fixture.start("download"), fixture.start("download", "alias")]);
|
||||
assert.match(first.result.error || "", /Permission denied/);
|
||||
assert.equal(next.result.error, undefined);
|
||||
assert.deepEqual(await fs.readFile(next.targetPath), fixture.payload);
|
||||
const transfers = await Promise.all([fixture.start("download"), fixture.start("download", "alias")]);
|
||||
// Both calls await local file preparation before acquiring the shared channel.
|
||||
// Either invocation may reach the injected first OPEN failure; retain the
|
||||
// concurrent release check without assuming filesystem completion order.
|
||||
const failed = transfers.filter((transfer) => transfer.result.error !== undefined);
|
||||
const succeeded = transfers.filter((transfer) => transfer.result.error === undefined);
|
||||
assert.equal(failed.length, 1);
|
||||
assert.equal(succeeded.length, 1);
|
||||
assert.match(failed[0].result.error, /Permission denied/);
|
||||
assert.deepEqual(await fs.readFile(succeeded[0].targetPath), fixture.payload);
|
||||
assert.equal(fixture.rejectedOpens(), 0);
|
||||
});
|
||||
|
||||
|
||||
@@ -165,6 +165,9 @@ function createBridgeRegistrar(context) {
|
||||
app,
|
||||
electron: electronModule,
|
||||
safeStorage,
|
||||
getInteractionWindow: () => win,
|
||||
isAppLocked: () => getAppLockController?.()?.getRuntimeState?.()?.locked !== false,
|
||||
subscribeAppLock: (listener) => getAppLockController?.()?.subscribe?.(listener),
|
||||
requestPermissionDecision: createNativePermissionDecisionProvider({
|
||||
getLocale: () => getWindowManager().getCurrentLanguage?.() ?? "en",
|
||||
dialog: electronModule.dialog,
|
||||
@@ -217,6 +220,10 @@ function createBridgeRegistrar(context) {
|
||||
});
|
||||
registerPluginBridge(ipcMain, {
|
||||
manager: pluginHostService?.manager,
|
||||
interactionService: pluginHostService?.interactionService,
|
||||
permissionEngine: pluginHostService?.permissionEngine,
|
||||
runtimeSupervisor: pluginHostService?.runtimeSupervisor,
|
||||
database: pluginHostService?.database,
|
||||
catalogService: pluginHostService?.catalogService,
|
||||
contributionService: pluginHostService?.contributionService,
|
||||
terminalProviderService: pluginHostService?.terminalProviderService,
|
||||
@@ -237,6 +244,8 @@ function createBridgeRegistrar(context) {
|
||||
},
|
||||
resolveBrandIcon: (pluginId) => pluginHostService?.contributionIconService?.resolveBrand(pluginId),
|
||||
resolveContributionIcon: (payload) => pluginHostService?.contributionIconService?.resolve(payload),
|
||||
themeService: pluginHostService?.themeService,
|
||||
logService: pluginHostService?.logService,
|
||||
viewHost: pluginHostService?.viewHost,
|
||||
env: process.env,
|
||||
isTrustedSender: createTrustedPluginBridgeSender({ devServerUrl: effectiveDevServerUrl }),
|
||||
|
||||
@@ -12,26 +12,23 @@ 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";
|
||||
const DOWNLOAD_HOSTS = new Set(["github.com", "raw.githubusercontent.com", "release-assets.githubusercontent.com", "objects.githubusercontent.com"]);
|
||||
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");
|
||||
if (url.protocol !== "https:" || url.username || url.password || url.port || !DOWNLOAD_HOSTS.has(url.hostname)) {
|
||||
throw new Error("Catalog download URL is not an approved HTTPS GitHub source");
|
||||
}
|
||||
return url.href;
|
||||
}
|
||||
function assertReadmeSource(entry) {
|
||||
function pinnedReadmeUrl(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");
|
||||
}
|
||||
const prefix = `/${entry.repository}/${entry.commit}/`;
|
||||
const url = new URL(`https://raw.githubusercontent.com${prefix}${entry.readme.split("/").map(encodeURIComponent).join("/")}`);
|
||||
if (!url.pathname.startsWith(prefix)) throw new Error("README URL escapes the pinned commit");
|
||||
return url.href;
|
||||
}
|
||||
function validateCatalog(entries) {
|
||||
assertPluginJsonValue(entries, { maxBytes: MAX_CATALOG_BYTES });
|
||||
@@ -46,49 +43,51 @@ function validateCatalog(entries) {
|
||||
|| entry.readme.split("/").some((p) => !p || p === "." || p === "..") || /[\\%?#\0]/u.test(entry.readme)) {
|
||||
throw new Error("Invalid or unpinned plugin catalog entry");
|
||||
}
|
||||
assertReadmeSource(entry);
|
||||
pinnedReadmeUrl(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`);
|
||||
if (Object.hasOwn(entry, "artifact")) {
|
||||
if (!entry.artifact || typeof entry.artifact !== "object" || Array.isArray(entry.artifact)
|
||||
|| Object.keys(entry.artifact).some((key) => key !== "url" && key !== "sha256")
|
||||
|| typeof entry.artifact.url !== "string" || typeof entry.artifact.sha256 !== "string") {
|
||||
throw new Error("Invalid catalog artifact metadata");
|
||||
}
|
||||
assertDownloadUrl(asset.url);
|
||||
if (!/^[a-f0-9]{64}$/u.test(asset.sha256)) throw new Error(`Catalog ${field} requires SHA-256`);
|
||||
assertDownloadUrl(entry.artifact.url);
|
||||
if (!/^[a-f0-9]{64}$/u.test(entry.artifact.sha256)) throw new Error("Catalog artifact requires SHA-256");
|
||||
}
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
async function fetchBounded(url, limit, fetchImpl = fetch) {
|
||||
async function fetchBounded(url, limit, fetchImpl = fetch, maxRedirects = 4) {
|
||||
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));
|
||||
for (let redirects = 0; redirects <= maxRedirects; redirects++) {
|
||||
const response = await fetchImpl(assertDownloadUrl(url), { redirect: "manual", signal, credentials: "omit", referrerPolicy: "no-referrer" });
|
||||
if ([301, 302, 303, 307, 308].includes(response.status)) {
|
||||
await response.body?.cancel();
|
||||
url = new URL(response.headers.get("location"), url).href;
|
||||
continue;
|
||||
}
|
||||
return Buffer.concat(chunks, size);
|
||||
} finally { await reader.cancel(); }
|
||||
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(); }
|
||||
}
|
||||
throw new Error("Too many catalog redirects");
|
||||
}
|
||||
class PluginCatalogService {
|
||||
constructor(options) {
|
||||
@@ -104,6 +103,11 @@ class PluginCatalogService {
|
||||
if (!entry) throw new Error("Plugin is not in the curated catalog");
|
||||
return entry;
|
||||
}
|
||||
async readme(id) {
|
||||
const entry = this.get(id);
|
||||
const url = pinnedReadmeUrl(entry);
|
||||
return (await fetchBounded(url, 256 * 1024, this.fetch, 0)).toString("utf8");
|
||||
}
|
||||
async install(id) {
|
||||
const entry = this.get(id);
|
||||
if (!entry.artifact) throw new Error("This source example has no published package; build and import locally");
|
||||
@@ -123,4 +127,4 @@ class PluginCatalogService {
|
||||
}
|
||||
}
|
||||
}
|
||||
module.exports = { PluginCatalogService, assertDownloadUrl, fetchBounded, validateCatalog, assertReadmeSource, MAX_CATALOG_BYTES, DISTRIBUTION_ORIGIN };
|
||||
module.exports = { PluginCatalogService, assertDownloadUrl, fetchBounded, validateCatalog, pinnedReadmeUrl, MAX_CATALOG_BYTES };
|
||||
@@ -24,17 +24,25 @@ test("curated catalog uses pinned public first-party source and rejects advanced
|
||||
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/);
|
||||
test("README retrieval is bounded and pinned, never follows a redirect to latest", async () => {
|
||||
const requested = [];
|
||||
const service = new PluginCatalogService({ entries, fetch: async (url) => {
|
||||
requested.push(url); return new Response("# First-party example");
|
||||
} });
|
||||
assert.equal(await service.readme(entries[0].id), "# First-party example");
|
||||
assert.equal(requested[0], `https://raw.githubusercontent.com/${entries[0].repository}/${entries[0].commit}/${entries[0].readme}`);
|
||||
service.fetch = async () => new Response(null, { status: 302, headers: { location: "https://raw.githubusercontent.com/a/b/main/README.md" } });
|
||||
await assert.rejects(service.readme(entries[0].id), /redirects/);
|
||||
await assert.rejects(fetchBounded("https://raw.githubusercontent.com/a/b/c", 3, async () => new Response("1234")), /size limit/);
|
||||
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/);
|
||||
await assert.rejects(fetchBounded("https://github.com/a", 10, async () => new Response(null, { status: 302, headers: { location: "http://localhost/secret" } })), /approved HTTPS/);
|
||||
});
|
||||
|
||||
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") } };
|
||||
const entry = { ...structuredClone(entries[0]), artifact: { url: "https://github.com/example/repo/releases/download/v1/plugin.ncpkg", sha256: createHash("sha256").update(bytes).digest("hex") } };
|
||||
let installs = 0;
|
||||
const service = new PluginCatalogService({ entries: [entry], getTempFilePath: (name) => {
|
||||
assert.equal(name, "plugin-catalog-");
|
||||
|
||||
@@ -6,6 +6,7 @@ const { assertPluginContextKey, evaluateContextKeyExpression } = require("./cont
|
||||
const { assertPluginJsonValue } = require("./jsonBoundary.cjs");
|
||||
const { compileRestrictedJsonSchema } = require("./restrictedJsonSchema.cjs");
|
||||
const { PluginRpcError, RPC_ERRORS } = require("./rpcRouter.cjs");
|
||||
const { InteractiveCommandFeature, InteractiveCommandLimits } = require("./generated/plugin-contract.schema.json").$defs;
|
||||
|
||||
const MAX_SETTING_BYTES = 128 * 1024;
|
||||
const MAX_PATTERN_LENGTH = 512;
|
||||
@@ -149,6 +150,8 @@ class PluginContributionService {
|
||||
constructor(options) {
|
||||
this.database = options.database;
|
||||
this.runtimeSupervisor = options.runtimeSupervisor;
|
||||
this.onCommandFinished = options.onCommandFinished;
|
||||
this.activeCommandCalls = new Map();
|
||||
this.secretStore = options.secretStore;
|
||||
this.getLocale = options.getLocale ?? (() => "en");
|
||||
this.contextKeys = new Map();
|
||||
@@ -336,12 +339,31 @@ class PluginContributionService {
|
||||
throw new PluginRpcError(RPC_ERRORS.failedPrecondition, `Plugin command is disabled: ${commandId}`);
|
||||
}
|
||||
assertJsonValue(args ?? null, "command arguments");
|
||||
await this.#ensureActivated(plugin, `onCommand:${commandId}`);
|
||||
return this.runtimeSupervisor.request(plugin.id, "plugin.command.execute", {
|
||||
command: commandId,
|
||||
...(args === undefined ? {} : { args }),
|
||||
invocation: freezeJson({ source: invocation.source ?? "host", context: invocation.context ?? {} }),
|
||||
});
|
||||
if (contribution.interactive && !plugin.manifest.features?.required?.includes(InteractiveCommandFeature.const)) {
|
||||
throw new PluginRpcError(RPC_ERRORS.failedPrecondition, "Interactive command feature is required");
|
||||
}
|
||||
const identity = await this.#ensureActivated(plugin, `onCommand:${commandId}`);
|
||||
const runtimeId = identity?.runtimeId;
|
||||
if (runtimeId) this.activeCommandCalls.set(runtimeId, (this.activeCommandCalls.get(runtimeId) ?? 0) + 1);
|
||||
let failed = true;
|
||||
try {
|
||||
const result = await this.runtimeSupervisor.request(plugin.id, "plugin.command.execute", {
|
||||
command: commandId,
|
||||
...(args === undefined ? {} : { args }),
|
||||
invocation: freezeJson({ source: invocation.source ?? "host", context: invocation.context ?? {} }),
|
||||
}, { expectedIdentity: identity, ...(contribution.interactive ? { timeoutMs: InteractiveCommandLimits.const.timeoutMs } : {}) });
|
||||
failed = false;
|
||||
return result;
|
||||
} finally {
|
||||
if (runtimeId) {
|
||||
const remaining = Math.max(0, (this.activeCommandCalls.get(runtimeId) ?? 1) - 1);
|
||||
if (remaining) this.activeCommandCalls.set(runtimeId, remaining);
|
||||
else this.activeCommandCalls.delete(runtimeId);
|
||||
// A failed command must not leave a live native consent prompt. On
|
||||
// success wait for other concurrent commands before closing orphans.
|
||||
if (failed || remaining === 0) this.onCommandFinished?.(runtimeId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async activateView(viewId, params = {}) {
|
||||
|
||||
@@ -87,6 +87,7 @@ function setup(context, pluginManifest, options = {}) {
|
||||
runtimeSupervisor,
|
||||
secretStore,
|
||||
getLocale: () => "zh-CN",
|
||||
onCommandFinished: options.onCommandFinished,
|
||||
});
|
||||
return { calls, database, secretStore, service, secrets };
|
||||
}
|
||||
@@ -100,6 +101,48 @@ test("plugin contribution values enforce the exact JSON value boundary", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("only explicitly declared interactive commands receive the fixed budget and command completion is runtime-bound", async (t) => {
|
||||
const pluginManifest = manifest();
|
||||
delete pluginManifest.contributes.commands[0].enablement;
|
||||
pluginManifest.features = { required: ["commands.interactive"] };
|
||||
pluginManifest.contributes.commands.push({ id: `${pluginManifest.id}.interactive`, title: "Interactive", interactive: true });
|
||||
const optionsSeen = [], finished = [];
|
||||
const runtimeId = "runtime-budget";
|
||||
const { service } = setup(t, pluginManifest, {
|
||||
onCommandFinished: (id) => finished.push(id),
|
||||
runtimeSupervisor: {
|
||||
async start() { return { runtimeId, pluginVersion: pluginManifest.version }; },
|
||||
async request(_id, _method, _params, options) { optionsSeen.push(options); return null; },
|
||||
},
|
||||
});
|
||||
await service.executeCommand(`${pluginManifest.id}.hello`);
|
||||
await service.executeCommand(`${pluginManifest.id}.interactive`);
|
||||
assert.equal(Object.hasOwn(optionsSeen[0], "timeoutMs"), false, "ordinary commands retain the existing router deadline");
|
||||
assert.equal(optionsSeen[0].expectedIdentity.runtimeId, runtimeId);
|
||||
assert.equal(optionsSeen[1].timeoutMs, 180_000);
|
||||
assert.equal(optionsSeen[1].expectedIdentity.runtimeId, runtimeId);
|
||||
assert.deepEqual(finished, [runtimeId, runtimeId]);
|
||||
assert.equal(service.activeCommandCalls.size, 0);
|
||||
});
|
||||
|
||||
test("concurrent successful commands retain the runtime interaction until the last ends, while a failed command cancels it", async (t) => {
|
||||
const pluginManifest = manifest(); delete pluginManifest.contributes.commands[0].enablement;
|
||||
const pending = [], finished = [];
|
||||
const { service } = setup(t, pluginManifest, { onCommandFinished: (id) => finished.push(id),
|
||||
runtimeSupervisor: { async start() { return { runtimeId: "runtime-concurrent" }; },
|
||||
request() { return new Promise((resolve, reject) => pending.push({ resolve, reject })); } },
|
||||
});
|
||||
const first = service.executeCommand(`${pluginManifest.id}.hello`);
|
||||
const second = service.executeCommand(`${pluginManifest.id}.hello`);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
pending[0].resolve(null); await first;
|
||||
assert.deepEqual(finished, []);
|
||||
pending[1].reject(new Error("synthetic timeout"));
|
||||
await assert.rejects(second, /synthetic timeout/);
|
||||
assert.deepEqual(finished, ["runtime-concurrent"]);
|
||||
assert.equal(service.activeCommandCalls.size, 0);
|
||||
});
|
||||
|
||||
test("only onStartupFinished plugins activate during contribution initialization", async (context) => {
|
||||
const pluginManifest = manifest("com.example.startup", ["onStartupFinished"]);
|
||||
const { calls, service } = setup(context, pluginManifest);
|
||||
|
||||
@@ -5,10 +5,11 @@ const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const { DatabaseSync } = require("node:sqlite");
|
||||
|
||||
const SCHEMA_VERSION = 3;
|
||||
const SCHEMA_VERSION = 4;
|
||||
const MAX_SECURITY_AUDIT_DETAILS_BYTES = 16 * 1024;
|
||||
const MAX_SECURITY_AUDIT_RECORDS_PER_PLUGIN = 1_000;
|
||||
const REQUIRED_SCHEMA_COLUMNS = Object.freeze({
|
||||
plugin_host_preferences: ["key", "value_json", "updated_at"],
|
||||
plugins: ["id", "enabled", "active_version", "installed_at", "updated_at"],
|
||||
plugin_versions: ["plugin_id", "version", "manifest_json", "archive_sha256", "package_relative_path", "installed_at"],
|
||||
plugin_runtime_state: ["plugin_id", "plugin_version", "status", "runtime_kind", "last_error", "quarantined_at", "updated_at"],
|
||||
@@ -212,6 +213,10 @@ class PluginDatabase {
|
||||
`);
|
||||
});
|
||||
}
|
||||
if (version <= 3) this.transaction(() => this.db.exec(`
|
||||
CREATE TABLE plugin_host_preferences (key TEXT PRIMARY KEY, value_json TEXT NOT NULL, updated_at INTEGER NOT NULL);
|
||||
PRAGMA user_version = 4;
|
||||
`));
|
||||
this.#assertSchemaLayout();
|
||||
// Legacy-map backfill is deferred until PackageStore.recover() has finished
|
||||
// (hostService seeds after package initialize). Running here would promote
|
||||
@@ -1030,18 +1035,34 @@ class PluginDatabase {
|
||||
sha256: createHash("sha256").update(detailsJson).digest("hex"),
|
||||
});
|
||||
}
|
||||
this.transaction(() => this.#appendSecurityAudit(pluginId, event, detailsJson));
|
||||
}
|
||||
|
||||
#appendSecurityAudit(pluginId, event, detailsJson) {
|
||||
this.db.prepare(`
|
||||
INSERT INTO plugin_security_audit(plugin_id, event, details_json, created_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
`).run(pluginId, event, detailsJson, this.clock());
|
||||
this.db.prepare(`
|
||||
DELETE FROM plugin_security_audit
|
||||
WHERE plugin_id = ? AND id NOT IN (
|
||||
SELECT id FROM plugin_security_audit
|
||||
WHERE plugin_id = ? ORDER BY id DESC LIMIT ${MAX_SECURITY_AUDIT_RECORDS_PER_PLUGIN}
|
||||
)
|
||||
`).run(pluginId, pluginId);
|
||||
}
|
||||
|
||||
getHostThemeSelection() {
|
||||
const row = this.db.prepare("SELECT value_json FROM plugin_host_preferences WHERE key = 'theme'").get();
|
||||
return row ? parseJson(row.value_json, "host theme selection") : {formatVersion: 1, revision: 0, light: null, dark: null};
|
||||
}
|
||||
|
||||
commitHostThemeSelection(expectedRevision, selection, pluginId) {
|
||||
this.transaction(() => {
|
||||
this.db.prepare(`
|
||||
INSERT INTO plugin_security_audit(plugin_id, event, details_json, created_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
`).run(pluginId, event, detailsJson, this.clock());
|
||||
this.db.prepare(`
|
||||
DELETE FROM plugin_security_audit
|
||||
WHERE plugin_id = ? AND id NOT IN (
|
||||
SELECT id FROM plugin_security_audit
|
||||
WHERE plugin_id = ? ORDER BY id DESC LIMIT ${MAX_SECURITY_AUDIT_RECORDS_PER_PLUGIN}
|
||||
)
|
||||
`).run(pluginId, pluginId);
|
||||
if (this.getHostThemeSelection().revision !== expectedRevision) throw new Error("Theme selection changed; refresh before applying");
|
||||
this.db.prepare("INSERT INTO plugin_host_preferences(key,value_json,updated_at) VALUES('theme',?,?) ON CONFLICT(key) DO UPDATE SET value_json=excluded.value_json,updated_at=excluded.updated_at")
|
||||
.run(JSON.stringify(selection), this.clock());
|
||||
this.#appendSecurityAudit(pluginId ?? "$host.appearance", "theme-selection", JSON.stringify({revision:selection.revision, action:pluginId ? "apply" : "restore"}));
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,166 +0,0 @@
|
||||
{
|
||||
"$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}$"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,194 +0,0 @@
|
||||
{
|
||||
"$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/"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
"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 };
|
||||
@@ -187,6 +187,8 @@ function assertSameOpenedDirectory(expectedStats, pathStats) {
|
||||
class PluginFilesystemBroker {
|
||||
constructor(options = {}) {
|
||||
this.quotaManager = options.quotaManager ?? null;
|
||||
this.protectedDirectories = options.protectedDirectories ?? [];
|
||||
this.protectedFiles = options.protectedFiles ?? [];
|
||||
this.fileSystem = options.fileSystem ?? fsp;
|
||||
this.openDirectoryHandle = options.openDirectoryHandle ?? null;
|
||||
if (this.openDirectoryHandle != null && typeof this.openDirectoryHandle !== "function") {
|
||||
@@ -261,10 +263,33 @@ class PluginFilesystemBroker {
|
||||
}
|
||||
}
|
||||
|
||||
async #assertWritable(canonical) {
|
||||
const variants = async (entry) => {
|
||||
const lexical = path.resolve(entry);
|
||||
try { return [lexical, await this.fileSystem.realpath(lexical)]; }
|
||||
catch (error) { if (error?.code !== "ENOENT") throw error; return [lexical]; }
|
||||
};
|
||||
for (const file of this.protectedFiles) {
|
||||
if ((await variants(file)).includes(canonical)) {
|
||||
throw new PluginRpcError(RPC_ERRORS.permissionDenied, "Host audit storage is protected");
|
||||
}
|
||||
}
|
||||
for (const directory of this.protectedDirectories) {
|
||||
for (const parent of await variants(directory)) {
|
||||
const relative = path.relative(parent, canonical);
|
||||
if (relative === "" || (!path.isAbsolute(relative) && relative !== ".." && !relative.startsWith(`..${path.sep}`))) {
|
||||
throw new PluginRpcError(RPC_ERRORS.permissionDenied, "Host audit storage is protected");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async writeFile(params, context) {
|
||||
const value = assertSecureWriteMode(assertWriteParams(params));
|
||||
await this.#assertWritable(path.resolve(value.path));
|
||||
assertAuthorizedPath(context, "filesystem.write", value.path);
|
||||
const { canonical, stats } = await resolveWritePath(value.path, this.fileSystem);
|
||||
await this.#assertWritable(canonical);
|
||||
assertAuthorizedPath(context, "filesystem.write", canonical);
|
||||
this.quotaManager?.chargeBytes(context.runtimeId, "filesystem", value.bytes.byteLength);
|
||||
await context.assertActive();
|
||||
@@ -278,6 +303,7 @@ class PluginFilesystemBroker {
|
||||
]);
|
||||
assertSameOpenedFile(openedStats, stats);
|
||||
assertSameOpenedFile(openedStats, pathStats);
|
||||
await this.#assertWritable(canonical);
|
||||
await context.assertActive();
|
||||
if (value.overwrite) await handle.truncate(0);
|
||||
await handle.writeFile(value.bytes);
|
||||
|
||||
@@ -390,3 +390,61 @@ test("filesystem broker rejects oversized and non-canonical payloads", async (co
|
||||
(error) => error.code === RPC_ERRORS.resourceExhausted,
|
||||
);
|
||||
});
|
||||
|
||||
test("plugin writes cannot modify host audit files or preferences, including symlink aliases", async (t) => {
|
||||
const root = createRoot(t);
|
||||
const directory = path.join(root, "host-logs");
|
||||
await fsp.mkdir(directory);
|
||||
const audit = path.join(directory, "runtime.jsonl");
|
||||
const preferences = path.join(root, "local-logging.json");
|
||||
await fsp.writeFile(audit, "host audit");
|
||||
await fsp.writeFile(preferences, "host preferences");
|
||||
const alias = path.join(root, "alias");
|
||||
await fsp.symlink(audit, alias);
|
||||
const broker = new PluginFilesystemBroker({ protectedDirectories: [directory], protectedFiles: [preferences] });
|
||||
for (const target of [audit, preferences, alias]) {
|
||||
const authorization = broker.describeWriteAuthorization({ path: target, data: "overwrite", overwrite: true });
|
||||
await assert.rejects(broker.writeFile({ path: target, data: "overwrite", overwrite: true }, runtimeContext(authorization)), { code: RPC_ERRORS.permissionDenied });
|
||||
}
|
||||
assert.equal(await fsp.readFile(audit, "utf8"), "host audit");
|
||||
assert.equal(await fsp.readFile(preferences, "utf8"), "host preferences");
|
||||
});
|
||||
|
||||
test("host audit protection resolves a symlinked userData directory", async (t) => {
|
||||
const root = createRoot(t);
|
||||
const actual = path.join(root, "actual-profile");
|
||||
await fsp.mkdir(path.join(actual, "logs"), { recursive: true });
|
||||
const profileAlias = path.join(root, "profile-alias");
|
||||
await fsp.symlink(actual, profileAlias);
|
||||
const target = path.join(actual, "logs", "runtime.jsonl");
|
||||
await fsp.writeFile(target, "host audit");
|
||||
const broker = new PluginFilesystemBroker({ protectedDirectories: [path.join(profileAlias, "logs")] });
|
||||
const params = { path: target, data: "overwrite", overwrite: true };
|
||||
const authorization = broker.describeWriteAuthorization(params);
|
||||
await assert.rejects(broker.writeFile(params, runtimeContext(authorization)), { code: RPC_ERRORS.permissionDenied });
|
||||
assert.equal(await fsp.readFile(target, "utf8"), "host audit");
|
||||
});
|
||||
|
||||
test("revocation during the protected-storage check cannot write an unrelated granted file", async (t) => {
|
||||
const root = createRoot(t);
|
||||
const logs = path.join(root, "host-logs");
|
||||
const target = path.join(root, "synthetic.txt");
|
||||
await fsp.mkdir(logs);
|
||||
await fsp.writeFile(target, "prior");
|
||||
let checks = 0;
|
||||
let active = true;
|
||||
const broker = new PluginFilesystemBroker({ protectedDirectories: [logs], fileSystem: {
|
||||
...fsp, async realpath(requested) {
|
||||
const canonical = await fsp.realpath(requested);
|
||||
if (requested === logs && ++checks === 3) active = false;
|
||||
return canonical;
|
||||
},
|
||||
} });
|
||||
const params = { path: target, data: "overwrite", overwrite: true };
|
||||
const authorization = broker.describeWriteAuthorization(params);
|
||||
const context = { ...runtimeContext(authorization), async assertActive() {
|
||||
if (!active) throw Object.assign(new Error("synthetic revocation"), { code: RPC_ERRORS.permissionDenied });
|
||||
} };
|
||||
await assert.rejects(broker.writeFile(params, context), { code: RPC_ERRORS.permissionDenied });
|
||||
assert.equal(await fsp.readFile(target, "utf8"), "prior");
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import { definePlugin } from "@netcatty/plugin-sdk";
|
||||
|
||||
export default definePlugin({
|
||||
activate(context) {
|
||||
const register = (suffix, handler) => context.subscriptions.add(context.commands.registerCommand(
|
||||
`com.example.local-logging-fixture.${suffix}`, handler,
|
||||
));
|
||||
register("emit", () => {
|
||||
context.logger.info("Synthetic diagnostic for local logging QA", {
|
||||
apiKey: "SYNTHETIC-API-KEY-NOT-A-CREDENTIAL",
|
||||
prompt: "SYNTHETIC-PROMPT-BODY",
|
||||
Notes: "SYNTHETIC-NOTE-BODY",
|
||||
SQL: "SYNTHETIC-SQL-BODY",
|
||||
results: "SYNTHETIC-RESULT-BODY",
|
||||
terminalContent: "SYNTHETIC-TERMINAL-BODY",
|
||||
pluginId: "synthetic-spoofed-plugin",
|
||||
sourceId: "official",
|
||||
runtimeId: "synthetic-spoofed-runtime",
|
||||
});
|
||||
return { ok: true, synthetic: true };
|
||||
});
|
||||
register("noise", () => {
|
||||
for (let index = 0; index < 400; index++) console.info(`Synthetic diagnostic burst ${index}`);
|
||||
return { ok: true, synthetic: true, emitted: 400 };
|
||||
});
|
||||
register("denied", async () => {
|
||||
// No network permission is declared. The host must reject before any
|
||||
// network I/O or approval prompt; no body or credential is ever supplied.
|
||||
try {
|
||||
await context.network.request({ method: "GET", url: "https://logging-fixture.example.com/should-never-be-sent" });
|
||||
throw new Error("Synthetic undeclared request unexpectedly succeeded");
|
||||
} catch (error) {
|
||||
if (error?.code !== "permission_denied" && error?.code !== -32007) throw error;
|
||||
return { denied: true, synthetic: true };
|
||||
}
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"manifestVersion": 1,
|
||||
"id": "com.example.local-logging-fixture",
|
||||
"name": "local-logging-fixture",
|
||||
"displayName": "Synthetic local logging fixture",
|
||||
"description": "Synthetic diagnostics and denied-operation checks. No real data or credentials.",
|
||||
"version": "0.1.0",
|
||||
"publisher": "example",
|
||||
"engines": { "netcatty": ">=0.0.0", "api": ">=0.1.0-internal <0.2.0" },
|
||||
"main": { "browser": "index.js" },
|
||||
"activationEvents": [
|
||||
"onCommand:com.example.local-logging-fixture.emit",
|
||||
"onCommand:com.example.local-logging-fixture.noise",
|
||||
"onCommand:com.example.local-logging-fixture.denied"
|
||||
],
|
||||
"permissions": { "required": ["commands", "menus"], "optional": [] },
|
||||
"contributes": {
|
||||
"commands": [
|
||||
{ "id": "com.example.local-logging-fixture.emit", "title": "Emit synthetic diagnostic", "category": "Logging QA" },
|
||||
{ "id": "com.example.local-logging-fixture.noise", "title": "Emit synthetic diagnostic burst", "category": "Logging QA" },
|
||||
{ "id": "com.example.local-logging-fixture.denied", "title": "Test undeclared network denial", "category": "Logging QA" }
|
||||
],
|
||||
"menus": [
|
||||
{ "command": "com.example.local-logging-fixture.emit", "location": "commandPalette" },
|
||||
{ "command": "com.example.local-logging-fixture.noise", "location": "commandPalette" },
|
||||
{ "command": "com.example.local-logging-fixture.denied", "location": "commandPalette" }
|
||||
]
|
||||
}
|
||||
}
|
||||
-53
@@ -1,53 +0,0 @@
|
||||
# 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.
|
Before Width: | Height: | Size: 873 B |
-37
@@ -1,37 +0,0 @@
|
||||
# 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.
|
Before Width: | Height: | Size: 873 B |
@@ -1,195 +0,0 @@
|
||||
[
|
||||
{
|
||||
"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"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -1,48 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
@@ -1,218 +0,0 @@
|
||||
{
|
||||
"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
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
"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 };
|
||||
@@ -231,6 +231,440 @@
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"HostInteractionLimits": {
|
||||
"const": {
|
||||
"textBytes": 4096,
|
||||
"requestBytes": 65536,
|
||||
"items": 100,
|
||||
"pendingPerRuntime": 1,
|
||||
"pendingTotal": 8,
|
||||
"requestsPerMinute": 10,
|
||||
"timeoutMs": 60000
|
||||
}
|
||||
},
|
||||
"HostInteractionFeature": {
|
||||
"const": "host.interactions"
|
||||
},
|
||||
"InteractiveCommandFeature": {
|
||||
"const": "commands.interactive"
|
||||
},
|
||||
"InteractiveCommandLimits": {
|
||||
"const": {
|
||||
"timeoutMs": 180000
|
||||
}
|
||||
},
|
||||
"HostQuickPickItem": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"id",
|
||||
"label"
|
||||
],
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 128
|
||||
},
|
||||
"label": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 512
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"maxLength": 512
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"HostQuickPickRequest": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"kind",
|
||||
"title",
|
||||
"items"
|
||||
],
|
||||
"properties": {
|
||||
"kind": {
|
||||
"const": "quickPick"
|
||||
},
|
||||
"title": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 128,
|
||||
"pattern": "^[^\\u0000-\\u001f\\u007f]*$"
|
||||
},
|
||||
"items": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"maxItems": 100,
|
||||
"items": {
|
||||
"$ref": "#/$defs/HostQuickPickItem"
|
||||
}
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"HostSingleLineInputText": {
|
||||
"description": "Single-line host input. CR and LF are rejected before display and on submission; clipboard text remains multiline.",
|
||||
"type": "string",
|
||||
"maxLength": 4096,
|
||||
"pattern": "^[^\\u0000\\r\\n]*$"
|
||||
},
|
||||
"HostInputRequest": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"kind",
|
||||
"title"
|
||||
],
|
||||
"properties": {
|
||||
"kind": {
|
||||
"const": "input"
|
||||
},
|
||||
"title": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 128,
|
||||
"pattern": "^[^\\u0000-\\u001f\\u007f]*$"
|
||||
},
|
||||
"initialValue": {
|
||||
"$ref": "#/$defs/HostSingleLineInputText"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"HostClipboardReadRequest": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"kind",
|
||||
"title"
|
||||
],
|
||||
"properties": {
|
||||
"kind": {
|
||||
"const": "clipboard.read"
|
||||
},
|
||||
"title": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 128,
|
||||
"pattern": "^[^\\u0000-\\u001f\\u007f]*$"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"HostClipboardWriteRequest": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"kind",
|
||||
"title",
|
||||
"text"
|
||||
],
|
||||
"properties": {
|
||||
"kind": {
|
||||
"const": "clipboard.write"
|
||||
},
|
||||
"title": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 128,
|
||||
"pattern": "^[^\\u0000-\\u001f\\u007f]*$"
|
||||
},
|
||||
"text": {
|
||||
"type": "string",
|
||||
"maxLength": 4096,
|
||||
"pattern": "^[^\\u0000]*$"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"HostInteractionRequest": {
|
||||
"oneOf": [
|
||||
{
|
||||
"$ref": "#/$defs/HostQuickPickRequest"
|
||||
},
|
||||
{
|
||||
"$ref": "#/$defs/HostInputRequest"
|
||||
},
|
||||
{
|
||||
"$ref": "#/$defs/HostClipboardReadRequest"
|
||||
},
|
||||
{
|
||||
"$ref": "#/$defs/HostClipboardWriteRequest"
|
||||
}
|
||||
]
|
||||
},
|
||||
"HostInteractionResult": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"required": [
|
||||
"cancelled"
|
||||
],
|
||||
"properties": {
|
||||
"cancelled": {
|
||||
"const": true
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"required": [
|
||||
"cancelled",
|
||||
"value"
|
||||
],
|
||||
"properties": {
|
||||
"cancelled": {
|
||||
"const": false
|
||||
},
|
||||
"value": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "string",
|
||||
"maxLength": 4096,
|
||||
"pattern": "^[^\\u0000]*$"
|
||||
},
|
||||
{
|
||||
"const": true
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
]
|
||||
},
|
||||
"HostInteractionResponse": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"requestId",
|
||||
"actionToken",
|
||||
"action"
|
||||
],
|
||||
"properties": {
|
||||
"requestId": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 128
|
||||
},
|
||||
"actionToken": {
|
||||
"type": "string",
|
||||
"minLength": 32,
|
||||
"maxLength": 256
|
||||
},
|
||||
"action": {
|
||||
"enum": [
|
||||
"cancel",
|
||||
"preview",
|
||||
"submit"
|
||||
]
|
||||
},
|
||||
"value": {
|
||||
"type": "string",
|
||||
"maxLength": 4096,
|
||||
"pattern": "^[^\\u0000]*$"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"HostInteractionClosed": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"requestId",
|
||||
"closed"
|
||||
],
|
||||
"properties": {
|
||||
"requestId": {
|
||||
"type": "string",
|
||||
"maxLength": 128
|
||||
},
|
||||
"closed": {
|
||||
"const": true
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"HostInteractionEvent": {
|
||||
"oneOf": [
|
||||
{
|
||||
"$ref": "#/$defs/HostInteractionPrompt"
|
||||
},
|
||||
{
|
||||
"$ref": "#/$defs/HostInteractionClosed"
|
||||
}
|
||||
]
|
||||
},
|
||||
"HostInteractionPrompt": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"requestId",
|
||||
"actionToken",
|
||||
"pluginId",
|
||||
"pluginVersion",
|
||||
"expiresAt",
|
||||
"phase",
|
||||
"request"
|
||||
],
|
||||
"properties": {
|
||||
"requestId": {
|
||||
"type": "string",
|
||||
"maxLength": 128
|
||||
},
|
||||
"actionToken": {
|
||||
"type": "string",
|
||||
"maxLength": 256
|
||||
},
|
||||
"pluginId": {
|
||||
"$ref": "#/$defs/PluginId"
|
||||
},
|
||||
"pluginVersion": {
|
||||
"$ref": "#/$defs/SemanticVersion"
|
||||
},
|
||||
"expiresAt": {
|
||||
"$ref": "#/$defs/SafePositiveInteger"
|
||||
},
|
||||
"phase": {
|
||||
"enum": [
|
||||
"request",
|
||||
"preview"
|
||||
]
|
||||
},
|
||||
"request": {
|
||||
"$ref": "#/$defs/HostInteractionRequest"
|
||||
},
|
||||
"previewText": {
|
||||
"type": "string",
|
||||
"maxLength": 4096,
|
||||
"pattern": "^[^\\u0000]*$"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"NetworkCredentialLeaseFeature": {
|
||||
"const": "network.credential-lease"
|
||||
},
|
||||
"PluginNetworkAuthorization": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"scheme": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"Basic",
|
||||
"Bearer"
|
||||
]
|
||||
},
|
||||
"username": {
|
||||
"type": "string",
|
||||
"maxLength": 8192,
|
||||
"pattern": "^[^\\u0000-\\u001f\\u007f]*$"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"PluginNetworkRequest": {
|
||||
"description": "Origin-authorized HTTP request. The host consumes a one-use credentialLease for network:<canonical origin>; plaintext credentials never return to the plugin.",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"url"
|
||||
],
|
||||
"properties": {
|
||||
"url": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 8192
|
||||
},
|
||||
"method": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"GET",
|
||||
"POST",
|
||||
"PUT",
|
||||
"PATCH",
|
||||
"DELETE",
|
||||
"HEAD"
|
||||
]
|
||||
},
|
||||
"headers": {
|
||||
"type": "object",
|
||||
"maxProperties": 64,
|
||||
"additionalProperties": {
|
||||
"type": "string",
|
||||
"maxLength": 8192
|
||||
}
|
||||
},
|
||||
"body": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"encoding",
|
||||
"data"
|
||||
],
|
||||
"properties": {
|
||||
"encoding": {
|
||||
"enum": [
|
||||
"utf8",
|
||||
"base64"
|
||||
]
|
||||
},
|
||||
"data": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"timeoutMs": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 300000
|
||||
},
|
||||
"credentialLease": {
|
||||
"$ref": "#/$defs/SecretLeaseRef"
|
||||
},
|
||||
"authorization": {
|
||||
"$ref": "#/$defs/PluginNetworkAuthorization"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"PluginNetworkResponse": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"url",
|
||||
"status",
|
||||
"headers",
|
||||
"body"
|
||||
],
|
||||
"properties": {
|
||||
"url": {
|
||||
"type": "string"
|
||||
},
|
||||
"status": {
|
||||
"type": "integer"
|
||||
},
|
||||
"headers": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"body": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"encoding",
|
||||
"data"
|
||||
],
|
||||
"properties": {
|
||||
"encoding": {
|
||||
"const": "base64"
|
||||
},
|
||||
"data": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"SemanticVersion": {
|
||||
"type": "string",
|
||||
"pattern": "^(0|[1-9]\\d*)\\.(0|[1-9]\\d*)\\.(0|[1-9]\\d*)(?:-(?:0|[1-9]\\d*|\\d*[A-Za-z-][0-9A-Za-z-]*)(?:\\.(?:0|[1-9]\\d*|\\d*[A-Za-z-][0-9A-Za-z-]*))*)?(?:\\+[0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*)?$"
|
||||
@@ -273,6 +707,7 @@
|
||||
"commands",
|
||||
"menus",
|
||||
"views",
|
||||
"ui.interact",
|
||||
"clipboard.read",
|
||||
"clipboard.write",
|
||||
"terminal.metadata",
|
||||
@@ -310,6 +745,7 @@
|
||||
"commands",
|
||||
"menus",
|
||||
"views",
|
||||
"ui.interact",
|
||||
"clipboard.read",
|
||||
"clipboard.write",
|
||||
"terminal.metadata",
|
||||
@@ -466,10 +902,27 @@
|
||||
},
|
||||
"node": {
|
||||
"$ref": "#/$defs/RelativePackagePath"
|
||||
},
|
||||
"data": {
|
||||
"const": "theme"
|
||||
}
|
||||
},
|
||||
"minProperties": 1,
|
||||
"additionalProperties": false
|
||||
"additionalProperties": false,
|
||||
"if": {
|
||||
"required": [
|
||||
"data"
|
||||
],
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"data": {
|
||||
"const": "theme"
|
||||
}
|
||||
}
|
||||
},
|
||||
"then": {
|
||||
"maxProperties": 1
|
||||
}
|
||||
},
|
||||
"PluginEngines": {
|
||||
"type": "object",
|
||||
@@ -746,6 +1199,11 @@
|
||||
},
|
||||
"enablement": {
|
||||
"$ref": "#/$defs/ContextKeyExpression"
|
||||
},
|
||||
"interactive": {
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"description": "Require commands.interactive for this command's fixed bounded host interaction budget. Does not grant UI or clipboard permissions."
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
@@ -852,9 +1310,34 @@
|
||||
"panel",
|
||||
"tab",
|
||||
"modal",
|
||||
"settings"
|
||||
"settings",
|
||||
"vault-page",
|
||||
"terminal-sidebar"
|
||||
]
|
||||
},
|
||||
"VaultPagesFeature": {
|
||||
"const": "views.vault-pages"
|
||||
},
|
||||
"TerminalSidebarFeature": {
|
||||
"const": "views.terminal-sidebar"
|
||||
},
|
||||
"PluginViewFocusEvent": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"instanceId"
|
||||
],
|
||||
"properties": {
|
||||
"instanceId": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 128
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"PluginViewFocusRequest": {
|
||||
"$ref": "#/$defs/PluginViewFocusEvent"
|
||||
},
|
||||
"ViewContribution": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
@@ -2829,6 +3312,14 @@
|
||||
"PluginContributions": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"themes": {
|
||||
"type": "array",
|
||||
"maxItems": 32,
|
||||
"items": {
|
||||
"$ref": "#/$defs/ThemePackContribution"
|
||||
},
|
||||
"minItems": 1
|
||||
},
|
||||
"settings": {
|
||||
"type": "array",
|
||||
"maxItems": 512,
|
||||
@@ -2991,7 +3482,51 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
"additionalProperties": false,
|
||||
"if": {
|
||||
"properties": {
|
||||
"main": {
|
||||
"required": [
|
||||
"data"
|
||||
],
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"data": {
|
||||
"const": "theme"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"then": {
|
||||
"properties": {
|
||||
"contributes": {
|
||||
"required": [
|
||||
"themes"
|
||||
],
|
||||
"maxProperties": 1,
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"themes": true
|
||||
}
|
||||
},
|
||||
"permissions": {
|
||||
"maxProperties": 0,
|
||||
"type": "object"
|
||||
},
|
||||
"activationEvents": {
|
||||
"maxItems": 0,
|
||||
"type": "array"
|
||||
},
|
||||
"companionExecutables": {
|
||||
"maxItems": 0,
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"contributes"
|
||||
]
|
||||
}
|
||||
},
|
||||
"RpcId": {
|
||||
"oneOf": [
|
||||
@@ -4306,6 +4841,328 @@
|
||||
"additionalProperties": false
|
||||
}
|
||||
]
|
||||
},
|
||||
"ThemePackFeature": {
|
||||
"const": "appearance.theme-packs"
|
||||
},
|
||||
"ThemePackLimits": {
|
||||
"const": {
|
||||
"jsonBytes": 65536,
|
||||
"catalogEntries": 256,
|
||||
"themesPerPackage": 32
|
||||
}
|
||||
},
|
||||
"ThemePackVariant": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"light",
|
||||
"dark"
|
||||
]
|
||||
},
|
||||
"ThemePackContribution": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"$ref": "#/$defs/ContributionId"
|
||||
},
|
||||
"label": {
|
||||
"$ref": "#/$defs/LocalizedText"
|
||||
},
|
||||
"variant": {
|
||||
"$ref": "#/$defs/ThemePackVariant"
|
||||
},
|
||||
"entry": {
|
||||
"$ref": "#/$defs/RelativePackagePath"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"id",
|
||||
"label",
|
||||
"variant",
|
||||
"entry"
|
||||
]
|
||||
},
|
||||
"ThemePackColors": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"background": {
|
||||
"type": "string",
|
||||
"pattern": "^#[0-9A-Fa-f]{6}$"
|
||||
},
|
||||
"foreground": {
|
||||
"type": "string",
|
||||
"pattern": "^#[0-9A-Fa-f]{6}$"
|
||||
},
|
||||
"card": {
|
||||
"type": "string",
|
||||
"pattern": "^#[0-9A-Fa-f]{6}$"
|
||||
},
|
||||
"cardForeground": {
|
||||
"type": "string",
|
||||
"pattern": "^#[0-9A-Fa-f]{6}$"
|
||||
},
|
||||
"popover": {
|
||||
"type": "string",
|
||||
"pattern": "^#[0-9A-Fa-f]{6}$"
|
||||
},
|
||||
"popoverForeground": {
|
||||
"type": "string",
|
||||
"pattern": "^#[0-9A-Fa-f]{6}$"
|
||||
},
|
||||
"secondary": {
|
||||
"type": "string",
|
||||
"pattern": "^#[0-9A-Fa-f]{6}$"
|
||||
},
|
||||
"secondaryForeground": {
|
||||
"type": "string",
|
||||
"pattern": "^#[0-9A-Fa-f]{6}$"
|
||||
},
|
||||
"muted": {
|
||||
"type": "string",
|
||||
"pattern": "^#[0-9A-Fa-f]{6}$"
|
||||
},
|
||||
"mutedForeground": {
|
||||
"type": "string",
|
||||
"pattern": "^#[0-9A-Fa-f]{6}$"
|
||||
},
|
||||
"accent": {
|
||||
"type": "string",
|
||||
"pattern": "^#[0-9A-Fa-f]{6}$"
|
||||
},
|
||||
"destructive": {
|
||||
"type": "string",
|
||||
"pattern": "^#[0-9A-Fa-f]{6}$"
|
||||
},
|
||||
"destructiveForeground": {
|
||||
"type": "string",
|
||||
"pattern": "^#[0-9A-Fa-f]{6}$"
|
||||
},
|
||||
"border": {
|
||||
"type": "string",
|
||||
"pattern": "^#[0-9A-Fa-f]{6}$"
|
||||
},
|
||||
"input": {
|
||||
"type": "string",
|
||||
"pattern": "^#[0-9A-Fa-f]{6}$"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"minProperties": 1
|
||||
},
|
||||
"ThemePackPalette": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"formatVersion": {
|
||||
"const": 1
|
||||
},
|
||||
"base": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"light",
|
||||
"dark"
|
||||
]
|
||||
},
|
||||
"colors": {
|
||||
"$ref": "#/$defs/ThemePackColors"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"formatVersion",
|
||||
"base",
|
||||
"colors"
|
||||
]
|
||||
},
|
||||
"ThemePackReference": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"pluginId": {
|
||||
"$ref": "#/$defs/PluginId"
|
||||
},
|
||||
"themeId": {
|
||||
"$ref": "#/$defs/ContributionId"
|
||||
},
|
||||
"version": {
|
||||
"$ref": "#/$defs/SemanticVersion"
|
||||
},
|
||||
"digest": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-f0-9]{64}$"
|
||||
},
|
||||
"variant": {
|
||||
"$ref": "#/$defs/ThemePackVariant"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"pluginId",
|
||||
"themeId",
|
||||
"version",
|
||||
"digest",
|
||||
"variant"
|
||||
]
|
||||
},
|
||||
"ThemePackChoice": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"ref": {
|
||||
"$ref": "#/$defs/ThemePackReference"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"ref"
|
||||
]
|
||||
},
|
||||
"ThemePackSelection": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"formatVersion": {
|
||||
"const": 1
|
||||
},
|
||||
"revision": {
|
||||
"$ref": "#/$defs/SafeUnsignedInteger"
|
||||
},
|
||||
"light": {
|
||||
"oneOf": [
|
||||
{
|
||||
"$ref": "#/$defs/ThemePackChoice"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"dark": {
|
||||
"oneOf": [
|
||||
{
|
||||
"$ref": "#/$defs/ThemePackChoice"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"formatVersion",
|
||||
"revision",
|
||||
"light",
|
||||
"dark"
|
||||
]
|
||||
},
|
||||
"ThemePackApplyRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"expectedRevision": {
|
||||
"$ref": "#/$defs/SafeUnsignedInteger"
|
||||
},
|
||||
"mode": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"light",
|
||||
"dark"
|
||||
]
|
||||
},
|
||||
"choice": {
|
||||
"oneOf": [
|
||||
{
|
||||
"$ref": "#/$defs/ThemePackChoice"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"expectedRevision",
|
||||
"mode",
|
||||
"choice"
|
||||
]
|
||||
},
|
||||
"ThemePackEntry": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"ref": {
|
||||
"$ref": "#/$defs/ThemePackReference"
|
||||
},
|
||||
"label": {
|
||||
"type": "string",
|
||||
"maxLength": 512
|
||||
},
|
||||
"pluginName": {
|
||||
"type": "string",
|
||||
"maxLength": 512
|
||||
},
|
||||
"dataOnly": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"palette": {
|
||||
"$ref": "#/$defs/ThemePackPalette"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"ref",
|
||||
"label",
|
||||
"pluginName",
|
||||
"dataOnly",
|
||||
"palette"
|
||||
]
|
||||
},
|
||||
"ThemePackCatalog": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"selection": {
|
||||
"$ref": "#/$defs/ThemePackSelection"
|
||||
},
|
||||
"entries": {
|
||||
"type": "array",
|
||||
"maxItems": 256,
|
||||
"items": {
|
||||
"$ref": "#/$defs/ThemePackEntry"
|
||||
}
|
||||
},
|
||||
"diagnostics": {
|
||||
"type": "array",
|
||||
"maxItems": 256,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"pluginId": {
|
||||
"$ref": "#/$defs/PluginId"
|
||||
},
|
||||
"themeId": {
|
||||
"type": "string",
|
||||
"maxLength": 192
|
||||
},
|
||||
"code": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"invalid",
|
||||
"unavailable",
|
||||
"capacity"
|
||||
]
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"pluginId",
|
||||
"code"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"selection",
|
||||
"entries",
|
||||
"diagnostics"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
"use strict";
|
||||
const assert = require("node:assert/strict");
|
||||
const { readFile } = require("node:fs/promises");
|
||||
const { EventEmitter } = require("node:events");
|
||||
const { pathToFileURL } = require("node:url");
|
||||
const test = require("node:test");
|
||||
const { MessageChannel } = require("node:worker_threads");
|
||||
const { PluginRpcRouter } = require("./rpcRouter.cjs");
|
||||
const { PluginHostRpcRegistry } = require("./hostRpcRegistry.cjs");
|
||||
const { PluginPermissionEngine } = require("./permissionEngine.cjs");
|
||||
const { PluginHostInteractionService } = require("./hostInteractionService.cjs");
|
||||
const { PluginContributionService } = require("./contributionService.cjs");
|
||||
|
||||
for (const source of ["compose", "share"]) test(`normal browser example completes the ${source} text workflow through permission middleware`, async () => {
|
||||
const { startPluginRuntime } = await import("./runtime/runtimePeer.mjs");
|
||||
const plugin = await import("../../examples/plugins/host-interactions/browser.js");
|
||||
const manifest = JSON.parse(await readFile(new URL("../../examples/plugins/host-interactions/netcatty.plugin.json", pathToFileURL(__filename)), "utf8"));
|
||||
const audit = [], copied = [], requests = [];
|
||||
let reads = 0, active = true;
|
||||
const database = { listPermissionGrants: () => [], recordSecurityAudit(...args) { audit.push(args); } };
|
||||
const permissionEngine = new PluginPermissionEngine({ database,
|
||||
requestDecision: async (request) => { requests.push(request.permission); return { requestId: request.requestId, decision: "allow", scope: "once" }; },
|
||||
});
|
||||
let service;
|
||||
const sender = Object.assign(new EventEmitter(), { id: 7, isDestroyed: () => false,
|
||||
send(_channel, message) {
|
||||
if (message.closed) return;
|
||||
queueMicrotask(() => {
|
||||
const action = message.request.kind === "clipboard.read" && message.phase === "request" ? "preview" : "submit";
|
||||
const value = message.request.kind === "quickPick" ? source : message.request.kind === "input" ? "synthetic output" : undefined;
|
||||
void service.respond(sender, { requestId: message.requestId, actionToken: message.actionToken, action, ...(value === undefined ? {} : { value }) }).catch(() => {});
|
||||
});
|
||||
},
|
||||
});
|
||||
const window = Object.assign(new EventEmitter(), { webContents: sender, isFocused: () => true, isVisible: () => true, isDestroyed: () => false });
|
||||
service = new PluginHostInteractionService({ database, permissionEngine, getWindow: () => window, isLocked: () => false,
|
||||
quotaManager: { chargeBytes() {} },
|
||||
clipboard: { readText() { reads++; return "synthetic private text"; }, writeText(text) { copied.push(text); } },
|
||||
});
|
||||
service.setReady(sender, true);
|
||||
const registry = new PluginHostRpcRegistry();
|
||||
registry.use(permissionEngine.createMiddleware()); service.registerRpcCapabilities(registry);
|
||||
const routes = registry.createRoutes({ pluginId: manifest.id, pluginVersion: manifest.version, runtimeId: "runtime-synthetic", runtimeKind: "browser", manifest,
|
||||
assertCurrent() { if (!active) throw new Error("stale runtime"); },
|
||||
});
|
||||
const { port1, port2 } = new MessageChannel();
|
||||
const host = new PluginRpcRouter({ pluginId: manifest.id, send: (message) => port1.postMessage(message), handlers: routes.requestHandlers });
|
||||
port1.on("message", (message) => { void 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.1", enabledFeatures: ["host.interactions"] },
|
||||
loadPlugin: async () => plugin,
|
||||
});
|
||||
try {
|
||||
await host.request("plugin.initialize", { netcattyVersion: "0.0.0", apiVersion: "0.1.0-internal.1", supportedFeatures: ["host.interactions"] });
|
||||
await host.request("plugin.activate", {});
|
||||
const result = await host.request("plugin.command.execute", { command: "com.netcatty.host-interactions.text", invocation: { source: "palette" } });
|
||||
const output = source === "compose" ? "synthetic output" : "SYNTHETIC PRIVATE TEXT";
|
||||
assert.deepEqual(result, { copied: true, characters: output.length });
|
||||
assert.deepEqual(copied, [output]); assert.equal(reads, source === "share" ? 1 : 0);
|
||||
assert.ok(requests.includes("ui.interact")); assert.ok(requests.includes("clipboard.write"));
|
||||
assert.equal(requests.includes("clipboard.read"), source === "share");
|
||||
assert.equal(JSON.stringify(audit).includes("synthetic private text"), false);
|
||||
assert.equal(JSON.stringify(audit).includes("synthetic output"), false);
|
||||
assert.equal(service.pending.size, 0);
|
||||
} finally { active = false; await peer.dispose(); host.close(); port1.close(); service.shutdown(); permissionEngine.shutdown(); }
|
||||
});
|
||||
|
||||
test("outer command deadline closes its real browser clipboard confirmation and rejects late commit", async () => {
|
||||
const { startPluginRuntime } = await import("./runtime/runtimePeer.mjs");
|
||||
const plugin = await import("../../examples/plugins/host-interactions/browser.js");
|
||||
const manifest = JSON.parse(await readFile(new URL("../../examples/plugins/host-interactions/netcatty.plugin.json", pathToFileURL(__filename)), "utf8"));
|
||||
const identity = { pluginId: manifest.id, pluginVersion: manifest.version, runtimeId: "runtime-deadline", runtimeKind: "browser" };
|
||||
const writes = [], messages = [];
|
||||
const database = { listPermissionGrants: () => [], recordSecurityAudit() {},
|
||||
listPlugins: () => [{ id: manifest.id, activeVersion: manifest.version, manifest, enabled: true, runtime: { status: "running" } }],
|
||||
};
|
||||
const engine = new PluginPermissionEngine({ database, requestDecision: async (request) => ({ requestId: request.requestId, decision: "allow", scope: "once" }) });
|
||||
let service, confirm;
|
||||
const copyPrompt = new Promise((resolve) => { confirm = resolve; });
|
||||
const sender = Object.assign(new EventEmitter(), { id: 8, isDestroyed: () => false, send(_channel, message) {
|
||||
messages.push(message); if (message.closed) return;
|
||||
if (message.request.kind === "clipboard.write") { confirm(message); return; }
|
||||
const value = message.request.kind === "quickPick" ? "compose" : "synthetic pending copy";
|
||||
queueMicrotask(() => { void service.respond(sender, { requestId: message.requestId, actionToken: message.actionToken, action: "submit", value }).catch(() => {}); });
|
||||
} });
|
||||
const owner = Object.assign(new EventEmitter(), { webContents: sender, isFocused: () => true, isVisible: () => true, isDestroyed: () => false });
|
||||
service = new PluginHostInteractionService({ database, permissionEngine: engine, getWindow: () => owner, isLocked: () => false,
|
||||
quotaManager: { chargeBytes() {} }, clipboard: { writeText(text) { writes.push(text); } } });
|
||||
service.setReady(sender, true);
|
||||
const registry = new PluginHostRpcRegistry(); registry.use(engine.createMiddleware()); service.registerRpcCapabilities(registry);
|
||||
const routes = registry.createRoutes({ ...identity, manifest, assertCurrent() {} });
|
||||
const { port1, port2 } = new MessageChannel();
|
||||
const host = new PluginRpcRouter({ pluginId: manifest.id, send: (message) => port1.postMessage(message), handlers: routes.requestHandlers });
|
||||
port1.on("message", (message) => { void host.accept(message); });
|
||||
const features = manifest.features.required;
|
||||
const peer = await startPluginRuntime({ port: port2, config: { ...identity, netcattyVersion: "0.0.0", apiVersion: "0.1.0-internal.1", enabledFeatures: features }, loadPlugin: async () => plugin });
|
||||
try {
|
||||
await host.request("plugin.initialize", { netcattyVersion: "0.0.0", apiVersion: "0.1.0-internal.1", supportedFeatures: features });
|
||||
await host.request("plugin.activate", {});
|
||||
const contributions = new PluginContributionService({ database, onCommandFinished: (runtimeId) => service.cancelRuntimeRequests(runtimeId),
|
||||
runtimeSupervisor: { async start() { return identity; }, request(_pluginId, method, params, options) {
|
||||
assert.equal(options.timeoutMs, 180_000, "host chooses the fixed declared budget");
|
||||
return host.request(method, params, { timeoutMs: 500 }); // bounded synthetic timer, no production timeout change
|
||||
} },
|
||||
});
|
||||
const command = contributions.executeCommand("com.netcatty.host-interactions.text");
|
||||
const prompt = await Promise.race([copyPrompt, command.then(() => { throw new Error("command finished before confirmation"); })]);
|
||||
const attempts = service.rates.get(identity.runtimeId).count;
|
||||
await assert.rejects(command, /timed out/);
|
||||
assert.equal(service.pending.size, 0);
|
||||
assert.equal(service.rates.get(identity.runtimeId).count, attempts);
|
||||
assert.ok(messages.some((message) => message.closed && message.requestId === prompt.requestId));
|
||||
assert.equal(await service.respond(sender, { requestId: prompt.requestId, actionToken: prompt.actionToken, action: "submit" }), false);
|
||||
assert.deepEqual(writes, []);
|
||||
assert.equal(contributions.activeCommandCalls.size, 0);
|
||||
} finally { await peer.dispose(); host.close(); port1.close(); service.shutdown(); engine.shutdown(); }
|
||||
});
|
||||
Loaded 100 of 186 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user