mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-10-31 10:52:38 +00:00
Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
91b8927c56 | ||
|
|
f868221b5f | ||
|
|
fd45463d98 | ||
|
|
fdeeaf74bb | ||
|
|
ffabb9e9c7 | ||
|
|
733f44cfdc | ||
|
|
a8adeb86fe | ||
|
|
0c097a1260 | ||
|
|
5c9286775e |
No files matched your search
@@ -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]);
|
||||
}
|
||||
@@ -1246,6 +1246,9 @@ Highlight the focused split pane:
|
||||
"settings.plugins.manager.remove": "Uninstall",
|
||||
"settings.plugins.manager.removeNote": "Uninstall removes package code and local KV data. User-owned settings, secrets and sync sidecars are retained.",
|
||||
"settings.plugins.permissions.statusUnknown": "Status unknown",
|
||||
"plugins.sidebar.extensions": "Extension pages",
|
||||
"plugins.sidebar.selectTerminal": "Select an open terminal first.",
|
||||
"plugins.sidebar.loading": "Loading extension page…",
|
||||
"plugins.vault.loading": "Loading plugin page…",
|
||||
"plugins.vault.extensions": "Extensions",
|
||||
"plugins.vault.back": "Back to hosts",
|
||||
@@ -1277,6 +1280,19 @@ Highlight the focused split pane:
|
||||
"settings.plugins.manager.noPermissions": "No privileged permissions declared.",
|
||||
"settings.plugins.manager.required": "Required",
|
||||
"settings.plugins.manager.optional": "Optional",
|
||||
"settings.plugins.themes.title": "Plugin themes",
|
||||
"settings.plugins.themes.description": "Preview changes only this window. Apply saves this device’s selection. Personal accent, fonts and terminal overrides stay in control.",
|
||||
"settings.plugins.themes.empty": "No enabled plugin themes for this mode.",
|
||||
"settings.plugins.themes.choose": "Choose a theme",
|
||||
"settings.plugins.themes.unavailable": "Saved theme unavailable; using the host default",
|
||||
"settings.plugins.themes.applied": "Applied",
|
||||
"settings.plugins.themes.preview": "Preview",
|
||||
"settings.plugins.themes.apply": "Apply",
|
||||
"settings.plugins.themes.cancel": "Cancel preview",
|
||||
"settings.plugins.themes.restore": "Restore default",
|
||||
"settings.plugins.themes.invalid": "Some theme palettes were rejected or unavailable.",
|
||||
"settings.plugins.themes.failed": "Could not apply theme.",
|
||||
"settings.plugins.manager.dataTheme": "Theme pack: validated colors, no executable runtime.",
|
||||
"settings.plugins.manager.browser": "Browser runtime: privileged access goes through host permission brokers.",
|
||||
"settings.plugins.manager.advanced": "Advanced runtime: full Node filesystem and network authority. Broker permissions cannot restrict this access. Development preview only.",
|
||||
"settings.plugins.manager.data": "Local KV stays on this device. Only non-secret settings explicitly marked for sync participate in encrypted cloud sidecars. Packages and permission grants do not sync.",
|
||||
|
||||
@@ -1246,6 +1246,9 @@ Resalta el panel dividido enfocado:
|
||||
"settings.plugins.manager.remove": "Desinstalar",
|
||||
"settings.plugins.manager.removeNote": "La desinstalación elimina el código y los datos KV locales. Se conservan los ajustes del usuario, los secretos y los datos auxiliares de sincronización.",
|
||||
"settings.plugins.permissions.statusUnknown": "Estado desconocido",
|
||||
"plugins.sidebar.extensions": "Páginas de extensiones",
|
||||
"plugins.sidebar.selectTerminal": "Selecciona primero un terminal abierto.",
|
||||
"plugins.sidebar.loading": "Cargando página de extensión…",
|
||||
"plugins.vault.loading": "Cargando página del plugin…",
|
||||
"plugins.vault.extensions": "Extensiones",
|
||||
"plugins.vault.back": "Volver a hosts",
|
||||
@@ -1277,6 +1280,19 @@ Resalta el panel dividido enfocado:
|
||||
"settings.plugins.manager.noPermissions": "No se han declarado permisos privilegiados.",
|
||||
"settings.plugins.manager.required": "Obligatorio",
|
||||
"settings.plugins.manager.optional": "Opcional",
|
||||
"settings.plugins.themes.title": "Temas de plugins",
|
||||
"settings.plugins.themes.description": "La vista previa solo cambia esta ventana. Aplicar guarda la selección en este dispositivo. Se conservan los ajustes personales.",
|
||||
"settings.plugins.themes.empty": "No hay temas habilitados para este modo.",
|
||||
"settings.plugins.themes.choose": "Elegir tema",
|
||||
"settings.plugins.themes.unavailable": "Tema guardado no disponible; se usa el predeterminado",
|
||||
"settings.plugins.themes.applied": "Aplicado",
|
||||
"settings.plugins.themes.preview": "Vista previa",
|
||||
"settings.plugins.themes.apply": "Aplicar",
|
||||
"settings.plugins.themes.cancel": "Cancelar vista previa",
|
||||
"settings.plugins.themes.restore": "Restaurar predeterminado",
|
||||
"settings.plugins.themes.invalid": "Algunas paletas no están disponibles o no son válidas.",
|
||||
"settings.plugins.themes.failed": "No se pudo aplicar el tema.",
|
||||
"settings.plugins.manager.dataTheme": "Paquete de temas: colores validados, sin ejecución de código.",
|
||||
"settings.plugins.manager.browser": "Entorno de navegador: el acceso privilegiado pasa por los controles de permisos del anfitrión.",
|
||||
"settings.plugins.manager.advanced": "Entorno avanzado: acceso completo de Node al sistema de archivos y a la red. Los permisos del intermediario no restringen este acceso. Solo para desarrollo.",
|
||||
"settings.plugins.manager.data": "Los datos KV permanecen en este dispositivo. Solo los ajustes no secretos marcados para sincronización participan en los datos auxiliares cifrados. Los paquetes y las autorizaciones no se sincronizan.",
|
||||
|
||||
@@ -1141,6 +1141,9 @@ export const ruCoreMessages: Messages = {
|
||||
"settings.plugins.manager.remove": "Удалить",
|
||||
"settings.plugins.manager.removeNote": "Удаление очищает код пакета и локальные данные KV. Настройки пользователя, секреты и данные синхронизации сохраняются.",
|
||||
"settings.plugins.permissions.statusUnknown": "Состояние неизвестно",
|
||||
"plugins.sidebar.extensions": "Страницы расширений",
|
||||
"plugins.sidebar.selectTerminal": "Сначала выберите открытый терминал.",
|
||||
"plugins.sidebar.loading": "Загрузка страницы расширения…",
|
||||
"plugins.vault.loading": "Загрузка страницы плагина…",
|
||||
"plugins.vault.extensions": "Расширения",
|
||||
"plugins.vault.back": "К хостам",
|
||||
@@ -1172,6 +1175,19 @@ export const ruCoreMessages: Messages = {
|
||||
"settings.plugins.manager.noPermissions": "Привилегированные разрешения не заявлены.",
|
||||
"settings.plugins.manager.required": "Обязательно",
|
||||
"settings.plugins.manager.optional": "Необязательно",
|
||||
"settings.plugins.themes.title": "Темы плагинов",
|
||||
"settings.plugins.themes.description": "Предпросмотр меняет только это окно. Применение сохраняет выбор на этом устройстве. Личные настройки сохраняются.",
|
||||
"settings.plugins.themes.empty": "Нет включённых тем для этого режима.",
|
||||
"settings.plugins.themes.choose": "Выберите тему",
|
||||
"settings.plugins.themes.unavailable": "Сохранённая тема недоступна; используется стандартная палитра",
|
||||
"settings.plugins.themes.applied": "Применено",
|
||||
"settings.plugins.themes.preview": "Предпросмотр",
|
||||
"settings.plugins.themes.apply": "Применить",
|
||||
"settings.plugins.themes.cancel": "Отменить предпросмотр",
|
||||
"settings.plugins.themes.restore": "Сбросить тему",
|
||||
"settings.plugins.themes.invalid": "Некоторые палитры недоступны или отклонены.",
|
||||
"settings.plugins.themes.failed": "Не удалось применить тему.",
|
||||
"settings.plugins.manager.dataTheme": "Пакет тем: проверенные цвета без выполнения кода.",
|
||||
"settings.plugins.manager.browser": "Браузерная среда: привилегированный доступ проходит через проверку разрешений приложения.",
|
||||
"settings.plugins.manager.advanced": "Расширенная среда: полный доступ Node к файловой системе и сети. Разрешения посредника не ограничивают этот доступ. Только для разработки.",
|
||||
"settings.plugins.manager.data": "Данные KV остаются на устройстве. Синхронизируются только явно отмеченные несекретные настройки в зашифрованных данных. Пакеты и разрешения не синхронизируются.",
|
||||
|
||||
@@ -1161,6 +1161,9 @@ Vaults 标签保持扁平,不使用这块背景。
|
||||
"settings.plugins.manager.remove": "卸载",
|
||||
"settings.plugins.manager.removeNote": "卸载会移除插件代码和本地 KV 数据,保留用户设置、秘密存储和同步 sidecar。",
|
||||
"settings.plugins.permissions.statusUnknown": "状态未知",
|
||||
"plugins.sidebar.extensions": "扩展页面",
|
||||
"plugins.sidebar.selectTerminal": "请先选择已打开的终端。",
|
||||
"plugins.sidebar.loading": "正在加载扩展页面…",
|
||||
"plugins.vault.loading": "正在加载插件页面…",
|
||||
"plugins.vault.extensions": "扩展",
|
||||
"plugins.vault.back": "返回主机",
|
||||
@@ -1192,6 +1195,19 @@ Vaults 标签保持扁平,不使用这块背景。
|
||||
"settings.plugins.manager.noPermissions": "未声明特权权限。",
|
||||
"settings.plugins.manager.required": "必需",
|
||||
"settings.plugins.manager.optional": "可选",
|
||||
"settings.plugins.themes.title": "插件主题",
|
||||
"settings.plugins.themes.description": "预览仅影响当前窗口;应用后保存在本设备。个人强调色、字体和终端覆盖设置仍然优先。",
|
||||
"settings.plugins.themes.empty": "当前模式没有已启用的插件主题。",
|
||||
"settings.plugins.themes.choose": "选择主题",
|
||||
"settings.plugins.themes.unavailable": "已保存的主题不可用,正在使用宿主默认配色",
|
||||
"settings.plugins.themes.applied": "已应用",
|
||||
"settings.plugins.themes.preview": "预览",
|
||||
"settings.plugins.themes.apply": "应用",
|
||||
"settings.plugins.themes.cancel": "取消预览",
|
||||
"settings.plugins.themes.restore": "恢复默认",
|
||||
"settings.plugins.themes.invalid": "部分主题配色未通过验证或不可用。",
|
||||
"settings.plugins.themes.failed": "无法应用主题。",
|
||||
"settings.plugins.manager.dataTheme": "主题包:已验证配色,不启动代码运行时。",
|
||||
"settings.plugins.manager.browser": "浏览器运行时:特权访问通过宿主权限代理。",
|
||||
"settings.plugins.manager.advanced": "高级运行时:拥有完整 Node 文件系统和网络能力,代理权限无法限制这些访问。仅供开发预览。",
|
||||
"settings.plugins.manager.data": "本地 KV 仅保留在此设备。只有明确标记为同步的非秘密设置进入加密云 sidecar。插件包和权限授权不参与同步。",
|
||||
|
||||
@@ -1164,6 +1164,9 @@ Vaults 分頁維持扁平,不使用這塊背景。
|
||||
"settings.plugins.manager.remove": "卸載",
|
||||
"settings.plugins.manager.removeNote": "卸載會移除插件代碼和本地 KV 數據,保留用戶設置、秘密存儲和同步 sidecar。",
|
||||
"settings.plugins.permissions.statusUnknown": "狀態不明",
|
||||
"plugins.sidebar.extensions": "擴充功能頁面",
|
||||
"plugins.sidebar.selectTerminal": "請先選擇已開啟的終端。",
|
||||
"plugins.sidebar.loading": "正在載入擴充功能頁面…",
|
||||
"plugins.vault.loading": "正在載入擴充功能頁面…",
|
||||
"plugins.vault.extensions": "擴充功能",
|
||||
"plugins.vault.back": "返回主機",
|
||||
@@ -1195,6 +1198,19 @@ Vaults 分頁維持扁平,不使用這塊背景。
|
||||
"settings.plugins.manager.noPermissions": "未聲明特權權限。",
|
||||
"settings.plugins.manager.required": "必需",
|
||||
"settings.plugins.manager.optional": "可選",
|
||||
"settings.plugins.themes.title": "外掛主題",
|
||||
"settings.plugins.themes.description": "預覽僅影響目前視窗;套用後儲存於本機。個人強調色、字型和終端覆寫設定仍然優先。",
|
||||
"settings.plugins.themes.empty": "目前模式沒有已啟用的外掛主題。",
|
||||
"settings.plugins.themes.choose": "選擇主題",
|
||||
"settings.plugins.themes.unavailable": "已儲存的主題無法使用,正在使用宿主預設配色",
|
||||
"settings.plugins.themes.applied": "已套用",
|
||||
"settings.plugins.themes.preview": "預覽",
|
||||
"settings.plugins.themes.apply": "套用",
|
||||
"settings.plugins.themes.cancel": "取消預覽",
|
||||
"settings.plugins.themes.restore": "恢復預設",
|
||||
"settings.plugins.themes.invalid": "部分主題配色未通過驗證或無法使用。",
|
||||
"settings.plugins.themes.failed": "無法套用主題。",
|
||||
"settings.plugins.manager.dataTheme": "主題套件:已驗證配色,不啟動程式執行環境。",
|
||||
"settings.plugins.manager.browser": "瀏覽器運行時:特權訪問通過宿主權限代理。",
|
||||
"settings.plugins.manager.advanced": "高級運行時:擁有完整 Node 文件系統和網絡能力,代理權限無法限制這些訪問。僅供開發預覽。",
|
||||
"settings.plugins.manager.data": "本地 KV 僅保留在此設備。只有明確標記為同步的非秘密設置進入加密雲 sidecar。插件包和權限授權不參與同步。",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/** 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]')) return true;
|
||||
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]',
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -4,10 +4,10 @@ import { resolvePluginViewWindowScope } from './pluginViewScopes';
|
||||
import { isPluginViewOccluded } from './pluginViewOcclusion';
|
||||
import type { UsePluginContributionsResult } from './usePluginContributions';
|
||||
|
||||
type ViewAdapter = Pick<UsePluginContributionsResult, 'openView' | 'closeView' | 'setViewBounds' | 'setViewVisibility' | 'onViewClosed'>;
|
||||
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 }: {
|
||||
export function useEmbeddedPluginView({ viewId, version, slot, active, context, adapter, subscribeHidden, unavailable, focused, focusOnReveal = false }: {
|
||||
viewId: string;
|
||||
version: string;
|
||||
slot: string;
|
||||
@@ -16,11 +16,18 @@ export function useEmbeddedPluginView({ viewId, version, slot, active, context,
|
||||
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(() => {
|
||||
@@ -31,7 +38,7 @@ export function useEmbeddedPluginView({ viewId, version, slot, active, context,
|
||||
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, setViewBounds, setViewVisibility, onViewClosed } = adapterRef.current;
|
||||
const { openView, closeView, focusView, setViewBounds, setViewVisibility, onViewClosed, onViewFocused } = adapterRef.current;
|
||||
const close = () => {
|
||||
if (cancelled) return;
|
||||
cancelled = true;
|
||||
@@ -51,29 +58,41 @@ export function useEmbeddedPluginView({ viewId, version, slot, active, context,
|
||||
}
|
||||
});
|
||||
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).catch(failCurrent);
|
||||
void setViewVisibility(instanceId, next).then(() => { if (next) focus(); }).catch(failCurrent);
|
||||
};
|
||||
const resize = () => {
|
||||
if (opened && !cancelled) void setViewBounds(instanceId, bounds()).catch(failCurrent);
|
||||
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(updateVisibility);
|
||||
const mutations = new MutationObserver(resize);
|
||||
mutations.observe(document.body, { childList: true, subtree: true, attributes: true,
|
||||
attributeFilter: ['class', 'style', 'hidden', 'inert', 'role', 'aria-modal', 'data-state', 'data-app-lock-overlay', 'data-native-view-occluder'] });
|
||||
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);
|
||||
@@ -91,9 +110,10 @@ export function useEmbeddedPluginView({ viewId, version, slot, active, context,
|
||||
if (!cancelled) { close(); unavailableRef.current(); }
|
||||
}).finally(() => lifecycle.finishOpen({ token: opening, viewKey: viewId }));
|
||||
return () => {
|
||||
close(); unsubscribe(); stopOnHide(); observer?.disconnect(); mutations.disconnect();
|
||||
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,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.
|
||||
}
|
||||
@@ -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 && view.location !== 'vault-page') ?? 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,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); }
|
||||
}
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -28,7 +28,7 @@ export function PluginPackageDetails({ plugin }: { plugin: NetcattyInstalledPlug
|
||||
<dt className="text-muted-foreground">API</dt><dd>{manifest.engines.api}</dd>
|
||||
<dt className="text-muted-foreground">SHA-256</dt><dd className="break-all font-mono">{plugin.archiveSha256 ?? '-'}</dd>
|
||||
</dl>
|
||||
<p className={!policy.browserOnly ? 'text-destructive' : 'text-muted-foreground'}>{t(`settings.plugins.manager.${runtimeLabel}`)}</p>
|
||||
<p className={!policy.browserOnly && !policy.dataOnly ? 'text-destructive' : 'text-muted-foreground'}>{policy.dataOnly ? t('settings.plugins.manager.dataTheme') : t(`settings.plugins.manager.${runtimeLabel}`)}</p>
|
||||
<div className="space-y-2">
|
||||
<h4 className="font-medium">{t('settings.plugins.manager.permissions')}</h4>
|
||||
<p className="text-xs text-muted-foreground">{t('settings.plugins.manager.permissionNote')}</p>
|
||||
|
||||
@@ -56,7 +56,7 @@ export function PluginPermissionPanel({ pluginId, onChanged }: {
|
||||
pending.current = false; setBusy(false);
|
||||
}
|
||||
};
|
||||
return <section className="space-y-3 border-t border-border/60 pt-4 text-sm" aria-label={t('settings.plugins.permissions.title')}>
|
||||
return <section data-host-security-surface="" className="space-y-3 border-t border-border/60 pt-4 text-sm" aria-label={t('settings.plugins.permissions.title')}>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<h4 className="font-medium">{t('settings.plugins.permissions.title')}</h4>
|
||||
<Button variant="ghost" size="sm" disabled={busy} onClick={() => void refresh()}>{t('settings.plugins.permissions.refresh')}</Button>
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -17,7 +17,7 @@ mean complete. Existing APIs remain available.
|
||||
| Terminal providers | Decorations, links, completion, snapshots through provider requests | Session change delivery follows prior provider request and non-once grant | Preserve providers. Standalone selected-session watch planned | Hostnames, session state, endpoint identity | Host-selected session, runtime identity | Session-close and revoke cleanup, bounded event rate | Existing provider tests. Ordinary views cannot independently subscribe today |
|
||||
| Current context and host picker | Host context keys are boolean state | `vault.metadata` enumeration is not generic SDK | Selected host/session DTO from an explicit whitelist | Labels and endpoint metadata are sensitive | Host-owned user-action context, selected host/session | TTL, runtime and session invalidation, redacted audit | Planned. Never reuse agent `sanitizeHostForAgent` as plugin DTO |
|
||||
| Terminal selection and input draft | Provider-supplied snapshots, completion suggestions | No explicit selection-share or draft API | User shares selection, plugin proposes a visible editable draft | Terminal plaintext, accidental execution | Host action token plus exact live session/endpoint | Single-use token, size cap, no plaintext audit, active identity before commit | Planned. Reject Enter/control characters for draft insertion. No full history or direct shell |
|
||||
| Native interactions and clipboard | Native permission dialogs; new browser SDK host choice/input and explicit copy/share workflow | Progress/toast remain unimplemented | Host-owned prompts, two-phase clipboard snapshot/share, exact copy preview | Clipboard and input may contain secrets; UI discloses granted network capability; prompt spam | `ui.interact`, clipboard direction, host-signed window/runtime/version/phase/TTL action | Admission before permission prompt, byte/rate/concurrency limits, lock/blur/close/revoke cleanup, active guard, content-free audit | [Host interaction slice](./host-interactions.md) implemented and synthetic-tested. d4ca123 exact test/packaging CI and parent independent delta review passed; parent native rerun pending after the permission-modal focus fix. Progress/toast still planned. No background reads or trusted plugin `userGesture` boolean |
|
||||
| Native interactions and clipboard | Native permission dialogs; new browser SDK host choice/input and explicit copy/share workflow | Progress/toast remain unimplemented | Host-owned prompts, two-phase clipboard snapshot/share, exact copy preview | Clipboard and input may contain secrets; UI discloses granted network capability; prompt spam | `ui.interact`, clipboard direction, host-signed window/runtime/version/phase/TTL action | Admission before permission prompt, byte/rate/concurrency limits, lock/blur/close/revoke cleanup, active guard, content-free audit | [Host interaction slice](./host-interactions.md) implemented and synthetic-tested. 8e4dd0c exact test/packaging CI and independent delta review passed. Parent native compose and explicit Preview/Share/copy succeeded; a separately approved 0.1.1 compose ran about 86 seconds, beyond the old 30-second deadline and within the declared 180-second budget, with no fresh command-timeout error. All five permissions were Once; cleanup is parent-owned. New navigation slices retain separate acceptance gates. Progress/toast still planned. No background reads or trusted plugin `userGesture` boolean |
|
||||
| Local filesystem | Read file, stat, overwrite existing regular file | Directory listing lacks same-handle native adapter | Keep current boundary, evaluate file picker handles | Arbitrary local file content and writes | Canonical exact path or selected file handle | Inode checks, no symlink alias, bounded bytes, active runtime | Existing filesystem tests. Directory enumeration and new arbitrary-path creation deferred until portable safe native adapter exists |
|
||||
| SFTP and remote files | No generic plugin API | `sftp.read` and `sftp.write` enumerated only | Host-selected session/path/mode/TTL handles, read and explicit save/upload | Remote secrets and destructive overwrite | Live endpoint identity, exact path, read/write split | Cancel streams, byte quota, overwrite confirmation, close/revoke cleanup | Batch 2 planned. Never forward arbitrary MCP catalog or session ID alone |
|
||||
| Editor and Notes | No plugin document API | No versioned snapshot or edit preview | Explicit snapshot and versioned proposed edit | Document secrets, lost updates, executable files | Selected document plus revision and action token | Version conflict check, preview, bounded bytes, revocation | Planned evaluation after shared action context. No unattended edits |
|
||||
@@ -27,12 +27,12 @@ mean complete. Existing APIs remain available.
|
||||
| Vault and keychain | Credential references and import preview/commit | Metadata/write enums do not grant a generic Vault SDK | Only user-selected whitelist DTOs and drafts | Notes, environmentVariables, startupCommand, keys and credentials | Selected object plus field/direction | Explicit preview, identity checks, redacted audit | Full Vault export and secret reads deferred indefinitely. No agent field-exclusion DTO reuse |
|
||||
| System manager and Docker | No plugin system API | Agent/MCP adapters are separate authority | Typed read-only summaries excluding args/env/inspect | Process command lines and Docker inspect can expose secrets | Selected live session and summary kind | Bounded result, endpoint identity, cancellation | Planned evaluation. Arbitrary process/inspect forwarding deferred |
|
||||
| Port forwarding | No generic plugin API | Existing agent start is write-sensitive | Selected rule status and open host form | Rule endpoints and local exposure | Selected rule plus host action | Bounded DTO, session close and revoke | Planned evaluation. Direct start deferred until binding/listener approval design |
|
||||
| Themes, settings, appearance, i18n | Terminal theme/background Providers, isolated view CSS, scoped settings and environment updates | No host global theme contribution. Personal customCSS is not plugin authority | Accepted ThemePack stage: static palette registry, selector, local preview, revision-safe apply/cancel/reset, terminal linkage | Unreadable palettes and misleading security UI. Nineteen UiThemeTokens fields are not independent: accent derives primary/ring/foregrounds | User explicitly selects a theme; static contribution enablement separate from code permissions | Immutable version/hash, bounded JSON, contrast/security-surface gates, preview owner/revision and safe fallback | Planned independent implementation after current controlled workflow/navigation. No arbitrary DOM/CSS, remote assets, layout replacement or security UI customization |
|
||||
| Themes, settings, appearance, i18n | Terminal theme/background Providers, isolated view CSS, scoped settings and environment updates | No host global theme contribution. Personal customCSS is not plugin authority | Accepted ThemePack stage: static palette registry, selector, local preview, revision-safe apply/cancel/reset, terminal linkage | Unreadable palettes and misleading security UI. Nineteen UiThemeTokens fields are not independent: accent derives primary/ring/foregrounds | User explicitly selects a theme; static contribution enablement separate from code permissions | Immutable version/hash, bounded JSON, contrast/security-surface gates, preview owner/revision and safe fallback | First host color layer implemented; [contract/lifecycle/evidence](./theme-packs.md). Exact-SHA CI, independent review and native acceptance pending. Static terminal linkage not yet accepted; no arbitrary DOM/CSS, remote assets, layout replacement or security UI customization |
|
||||
| Transfer center | Host transfer queue exists, no plugin entry | No ownership-bound task API | Observe and cancel only plugin-created tasks | Paths and host endpoints reveal user data. Cancel may leave partial files | Own transfer handle plus source/destination authority | Shared backpressure, cancel/close cleanup, honest partial status | Planned with batch 2. Do not broadcast or cancel other tasks |
|
||||
| AI, MCP, automation | Host agent catalog and harness exist separately | No generic plugin AI or tool forwarding | Selection-to-proposal workflow through the same controlled primitives | Content disclosure, account authority and model cost | Explicit content sharing plus service origin/budget | No inherited history, tools, account, or hidden cron | Controlled workflow planned. Generic host AI and visible schedules deferred to distinct product decision |
|
||||
| Known hosts, app lock and global security | Host security management UI | No plugin management API | Future allowlisted security-page navigation | Host-key trust, unlock, keys and security policy affect all sessions | Host navigation action only | No mutation or sensitive DTO | Security policy mutation, private-key reads, auto-trust and unlocking deferred indefinitely |
|
||||
| Plugin blob/cache | Local KV exists | No exclusive blob cache | Evaluate bounded plugin-owned cache when a real workflow needs it | Disk growth and lifecycle ambiguity | Plugin namespace, no arbitrary path | Size quota, eviction and uninstall policy required | Deferred until a concrete larger-than-KV workflow establishes value and retention policy |
|
||||
| Vault and terminal navigation | `tab` mounts a sandbox page through TopTabs/QuickSwitcher; aside/panel are overlays | No Vault subpage registry or terminal side-panel plugin tool. `settings` is a detail entry, not a new settings category | Accepted independent slice: host-owned namespaced Vault pages in an Extensions group, mounted in the Vault content area through existing ViewHost; then terminal pane/split pages for the selected session | Navigation cannot disclose session contents or replace built-in/security navigation | Declared page contribution plus host selection. Content reads have independent permissions | Host order/when/focus, cancellation of late open, view cleanup on hide/update/disable/uninstall, safe invalid-link fallback | [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 pane/split pages remain the next separate stage. No arbitrary router/DOM API |
|
||||
| Vault and terminal navigation | `tab` mounts a sandbox page through TopTabs/QuickSwitcher; aside/panel are overlays | No Vault subpage registry or terminal side-panel plugin tool. `settings` is a detail entry, not a new settings category | Accepted independent slice: host-owned namespaced Vault pages in an Extensions group, mounted in the Vault content area through existing ViewHost; then terminal pane/split pages for the selected session | Navigation cannot disclose session contents or replace built-in/security navigation | Declared page contribution plus host selection. Content reads have independent permissions | Host order/when/focus, cancellation of late open, view cleanup on hide/update/disable/uninstall, safe invalid-link fallback | [Vault page slice](./vault-pages.md) implemented and synthetic-tested: actual Vault content slot, private Scratchpad, catalog withdrawal, host occlusion, reserved-open cancellation, owner/crash/lock cleanup. Exact CI/independent/native acceptance pending. [Terminal sidebar slice](./terminal-sidebar.md) also implemented with actual pane/split mounting, owner focus and session-change cleanup; no automatic session data. Exact CI/independent/native acceptance pending for the new stack. No arbitrary router/DOM API |
|
||||
| Distribution and runtimes | Browser-only public catalog, immutable packages, advanced developer gate | Signing/trust and release remain separate phase | Retain existing boundary | Package execution and publisher trust | Pinned catalog selection, hash, engine/features | Existing install transaction, quarantine and cleanup | No merge/release. Do not extend to arbitrary Node, IPC, shell, or global capabilities |
|
||||
|
||||
## Delivery order and stopping rule
|
||||
@@ -98,3 +98,69 @@ 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.
|
||||
@@ -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,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.
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -244,6 +244,7 @@ function createBridgeRegistrar(context) {
|
||||
},
|
||||
resolveBrandIcon: (pluginId) => pluginHostService?.contributionIconService?.resolveBrand(pluginId),
|
||||
resolveContributionIcon: (payload) => pluginHostService?.contributionIconService?.resolve(payload),
|
||||
themeService: pluginHostService?.themeService,
|
||||
viewHost: pluginHostService?.viewHost,
|
||||
env: process.env,
|
||||
isTrustedSender: createTrustedPluginBridgeSender({ devServerUrl: effectiveDevServerUrl }),
|
||||
|
||||
@@ -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"}));
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -902,10 +902,27 @@
|
||||
},
|
||||
"node": {
|
||||
"$ref": "#/$defs/RelativePackagePath"
|
||||
},
|
||||
"data": {
|
||||
"const": "theme"
|
||||
}
|
||||
},
|
||||
"minProperties": 1,
|
||||
"additionalProperties": false
|
||||
"additionalProperties": false,
|
||||
"if": {
|
||||
"required": [
|
||||
"data"
|
||||
],
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"data": {
|
||||
"const": "theme"
|
||||
}
|
||||
}
|
||||
},
|
||||
"then": {
|
||||
"maxProperties": 1
|
||||
}
|
||||
},
|
||||
"PluginEngines": {
|
||||
"type": "object",
|
||||
@@ -1294,12 +1311,33 @@
|
||||
"tab",
|
||||
"modal",
|
||||
"settings",
|
||||
"vault-page"
|
||||
"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": [
|
||||
@@ -3274,6 +3312,14 @@
|
||||
"PluginContributions": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"themes": {
|
||||
"type": "array",
|
||||
"maxItems": 32,
|
||||
"items": {
|
||||
"$ref": "#/$defs/ThemePackContribution"
|
||||
},
|
||||
"minItems": 1
|
||||
},
|
||||
"settings": {
|
||||
"type": "array",
|
||||
"maxItems": 512,
|
||||
@@ -3436,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": [
|
||||
@@ -4751,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"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
const path = require("node:path");
|
||||
const NETWORK_CREDENTIAL_LEASE_FEATURE = require("./generated/plugin-contract.schema.json").$defs.NetworkCredentialLeaseFeature.const;
|
||||
const VAULT_PAGES_FEATURE = require("./generated/plugin-contract.schema.json").$defs.VaultPagesFeature.const;
|
||||
const TERMINAL_SIDEBAR_FEATURE = require("./generated/plugin-contract.schema.json").$defs.TerminalSidebarFeature.const;
|
||||
const INTERACTIVE_COMMAND_FEATURE = require("./generated/plugin-contract.schema.json").$defs.InteractiveCommandFeature.const;
|
||||
|
||||
const { assertBrowserOnlyPlugin } = require("../../domain/pluginRuntimePolicy.cjs");
|
||||
@@ -21,6 +22,7 @@ const {
|
||||
normalizePluginModuleResources,
|
||||
} = require("./moduleResources.cjs");
|
||||
const { RegistryCatalogService } = require("./registryCatalogService.cjs");
|
||||
const { PluginThemeService } = require("./themeService.cjs");
|
||||
const { PackageStore } = require("./packageStore.cjs");
|
||||
const { createPluginPaths } = require("./paths.cjs");
|
||||
const { PluginManager } = require("./pluginManager.cjs");
|
||||
@@ -97,7 +99,7 @@ function createPluginHostService(options) {
|
||||
database,
|
||||
netcattyVersion: options.app.getVersion(),
|
||||
apiVersion: PLUGIN_API_VERSION,
|
||||
supportedFeatures: [...new Set(["metadata.brand-icon", NETWORK_CREDENTIAL_LEASE_FEATURE, HOST_INTERACTION_FEATURE, VAULT_PAGES_FEATURE, INTERACTIVE_COMMAND_FEATURE, ...(options.supportedFeatures ?? [])])],
|
||||
supportedFeatures: [...new Set(["appearance.theme-packs", "metadata.brand-icon", NETWORK_CREDENTIAL_LEASE_FEATURE, HOST_INTERACTION_FEATURE, VAULT_PAGES_FEATURE, TERMINAL_SIDEBAR_FEATURE, INTERACTIVE_COMMAND_FEATURE, ...(options.supportedFeatures ?? [])])],
|
||||
});
|
||||
const contributionIconService = new PluginContributionIconService({
|
||||
database,
|
||||
@@ -237,7 +239,7 @@ function createPluginHostService(options) {
|
||||
paths,
|
||||
netcattyVersion: options.app.getVersion(),
|
||||
apiVersion: PLUGIN_API_VERSION,
|
||||
supportedFeatures: [...new Set(["metadata.brand-icon", NETWORK_CREDENTIAL_LEASE_FEATURE, HOST_INTERACTION_FEATURE, VAULT_PAGES_FEATURE, INTERACTIVE_COMMAND_FEATURE, ...(options.supportedFeatures ?? [])])],
|
||||
supportedFeatures: [...new Set(["appearance.theme-packs", "metadata.brand-icon", NETWORK_CREDENTIAL_LEASE_FEATURE, HOST_INTERACTION_FEATURE, VAULT_PAGES_FEATURE, TERMINAL_SIDEBAR_FEATURE, INTERACTIVE_COMMAND_FEATURE, ...(options.supportedFeatures ?? [])])],
|
||||
runtimeDirectory,
|
||||
appRoot,
|
||||
rpcRegistry,
|
||||
@@ -369,6 +371,7 @@ function createPluginHostService(options) {
|
||||
subscribeLock: options.subscribeAppLock,
|
||||
})
|
||||
: null;
|
||||
const themeService = new PluginThemeService({database,packageStore,contributionService});
|
||||
const manager = new PluginManager({
|
||||
permissionEngine,
|
||||
database,
|
||||
@@ -376,6 +379,7 @@ function createPluginHostService(options) {
|
||||
runtimeSupervisor,
|
||||
contributionService,
|
||||
beforeClose: async () => {
|
||||
themeService.shutdown();
|
||||
interactionService.shutdown();
|
||||
terminalDataPipelineService?.shutdown();
|
||||
extensionProviderService.shutdown();
|
||||
@@ -388,6 +392,7 @@ function createPluginHostService(options) {
|
||||
},
|
||||
});
|
||||
return {
|
||||
themeService,
|
||||
interactionService,
|
||||
catalogService: new RegistryCatalogService({ manager, fetch: options.electron.net?.fetch?.bind(options.electron.net) }),
|
||||
companionSupervisor,
|
||||
|
||||
@@ -23,6 +23,7 @@ test("packaged application declares every plugin host runtime dependency and res
|
||||
assert.match(builderSource, /electron\/plugins\/runtime\/\*\*\/\*/);
|
||||
assert.match(builderSource, /domain\/pluginRuntimePolicy\.cjs/);
|
||||
assert.match(builderSource, /domain\/pluginPermissionDescriptions\.cjs/);
|
||||
assert.match(builderSource, /domain\/pluginThemePack\.cjs/);
|
||||
assert.match(builderSource, /!electron\/plugins\/fixtures\/\*\*\/\*/);
|
||||
});
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ const { randomUUID } = require("node:crypto");
|
||||
|
||||
const { createDefinitionValidator } = require("./contractValidator.cjs");
|
||||
const validateManagedPluginId = createDefinitionValidator("PluginId");
|
||||
const validateViewFocus = createDefinitionValidator("PluginViewFocusRequest");
|
||||
|
||||
const { raceWithAbort } = require("./rpcRouter.cjs");
|
||||
const {
|
||||
@@ -15,6 +16,9 @@ const {
|
||||
const { assertSecretKey } = require("./secretStore.cjs");
|
||||
|
||||
const CHANNELS = Object.freeze({
|
||||
themeCatalog: "netcatty:plugins:theme-catalog",
|
||||
themeApply: "netcatty:plugins:theme-apply",
|
||||
themesChanged: "netcatty:plugins:themes-changed",
|
||||
status: "netcatty:plugins:status",
|
||||
interactionReady: "netcatty:plugins:interaction-ready",
|
||||
interactionResponse: "netcatty:plugins:interaction-response",
|
||||
@@ -75,6 +79,7 @@ const CHANNELS = Object.freeze({
|
||||
importerProgress: "netcatty:plugins:importer-progress",
|
||||
openView: "netcatty:plugins:open-view",
|
||||
closeView: "netcatty:plugins:close-view",
|
||||
focusView: "netcatty:plugins:focus-view",
|
||||
setViewBounds: "netcatty:plugins:set-view-bounds",
|
||||
setViewVisibility: "netcatty:plugins:set-view-visibility",
|
||||
viewMessage: "netcatty:plugins:view-message",
|
||||
@@ -721,6 +726,15 @@ function registerPluginBridge(ipcMain, options) {
|
||||
));
|
||||
handle(CHANNELS.restart, async (activeManager, payload) => activeManager.restart(payload?.pluginId));
|
||||
handle(CHANNELS.uninstall, async (activeManager, payload) => activeManager.uninstall(payload?.pluginId));
|
||||
handle(CHANNELS.themeCatalog, async () => {
|
||||
if (!options.themeService) throw new Error("Plugin themes are unavailable");
|
||||
return options.themeService.catalog();
|
||||
});
|
||||
handle(CHANNELS.themeApply, async (_manager, request) => {
|
||||
if (!options.themeService) throw new Error("Plugin themes are unavailable");
|
||||
return options.themeService.apply(request);
|
||||
});
|
||||
options.themeService?.onDidChange?.(() => options.broadcast?.(CHANNELS.themesChanged, {}));
|
||||
handle(CHANNELS.contributions, async (_activeManager, payload) => {
|
||||
if (!contributionService) throw new Error("Plugin contributions are unavailable");
|
||||
return contributionService.snapshot(payload ?? {});
|
||||
@@ -1605,6 +1619,12 @@ function registerPluginBridge(ipcMain, options) {
|
||||
await viewHost.close(payload?.instanceId, event.sender);
|
||||
return null;
|
||||
});
|
||||
handle(CHANNELS.focusView, async (_activeManager, payload, event) => {
|
||||
if (!viewHost) throw new Error("Plugin views are unavailable");
|
||||
if (!validateViewFocus(payload)) throw new TypeError("Plugin view focus request is invalid");
|
||||
viewHost.focus(payload.instanceId, event.sender);
|
||||
return null;
|
||||
});
|
||||
handle(CHANNELS.setViewBounds, async (_activeManager, payload, event) => {
|
||||
if (!viewHost) throw new Error("Plugin views are unavailable");
|
||||
viewHost.setBounds(payload?.instanceId, payload?.bounds, event.sender);
|
||||
|
||||
@@ -1714,3 +1714,10 @@ test("host interaction ready and response channels reject untrusted senders befo
|
||||
await ipcMain.handlers.get(CHANNELS.interactionResponse)({ trusted: true, sender }, response);
|
||||
assert.deepEqual(calls, [[sender, true], [sender, response]]);
|
||||
});
|
||||
|
||||
test('theme catalog and selection remain trusted app management channels',async()=>{
|
||||
const ipc=createIpcMain();let calls=0;
|
||||
registerPluginBridge(ipc,{manager:{initialize:async()=>{}},isTrustedSender:event=>event?.trusted===true,themeService:{catalog:async()=>{calls++;return{};},apply:async()=>{calls++;return{};}}});
|
||||
for(const channel of [CHANNELS.themeCatalog,CHANNELS.themeApply])await assert.rejects(ipc.handlers.get(channel)({trusted:false},{}),/Untrusted/);
|
||||
assert.equal(calls,0);await ipc.handlers.get(CHANNELS.themeCatalog)({trusted:true});await ipc.handlers.get(CHANNELS.themeApply)({trusted:true},{});assert.equal(calls,2);
|
||||
});
|
||||
@@ -62,7 +62,7 @@ class PluginViewHost {
|
||||
if (["plugin-disabled", "runtime-stopped", "runtime-error", "runtime-quarantined"].includes(event.reason)
|
||||
&& instance.pluginId === event.pluginId) {
|
||||
void this.close(instance.instanceId, undefined, event.reason);
|
||||
} else if (instance.location === "vault-page") {
|
||||
} else if (["vault-page", "terminal-sidebar"].includes(instance.location)) {
|
||||
try { this.contributionService.assertViewActivationCurrent(instance.activation, { context: instance.context }); }
|
||||
catch { void this.close(instance.instanceId, undefined, "contribution-unavailable"); }
|
||||
}
|
||||
@@ -233,7 +233,7 @@ class PluginViewHost {
|
||||
});
|
||||
contents.on("will-attach-webview", (event) => event.preventDefault());
|
||||
contents.once("render-process-gone", () => { void this.close(instanceId, undefined, "view-renderer-gone"); });
|
||||
if (view.location === "vault-page") contents.on("before-input-event", (event, input) => {
|
||||
if (["vault-page", "terminal-sidebar"].includes(view.location)) contents.on("before-input-event", (event, input) => {
|
||||
if (input.type === "keyDown" && input.key === "Escape" && !input.control && !input.meta && !input.alt && !input.shift) {
|
||||
event.preventDefault();
|
||||
void this.close(instanceId, undefined, "host-escape");
|
||||
@@ -256,9 +256,13 @@ class PluginViewHost {
|
||||
sessionRegistration,
|
||||
state: "opening",
|
||||
attached: false,
|
||||
visible: view.location !== "vault-page",
|
||||
visible: !["vault-page", "terminal-sidebar"].includes(view.location),
|
||||
};
|
||||
this.instances.set(instanceId, instance);
|
||||
contents.on("focus", () => {
|
||||
if (this.instances.get(instanceId) !== instance || instance.state !== "open") return;
|
||||
try { owner.webContents?.send("netcatty:plugins:view-focused", { instanceId }); } catch {}
|
||||
});
|
||||
this.byWebContentsId.set(contents.id, instance);
|
||||
owner.once?.("closed", ownerClosed);
|
||||
contents.once("destroyed", () => { void this.close(instanceId, undefined, "contents-destroyed"); });
|
||||
@@ -271,7 +275,7 @@ class PluginViewHost {
|
||||
this.#assertOpening(opening);
|
||||
// Embedded pages start hidden until the trusted renderer checks host
|
||||
// dialogs/lock/popovers. A late open must never cover a security surface.
|
||||
if (view.location === "vault-page") contentsView.setVisible(false);
|
||||
if (["vault-page", "terminal-sidebar"].includes(view.location)) contentsView.setVisible(false);
|
||||
owner.contentView.addChildView(contentsView);
|
||||
instance.attached = true;
|
||||
contentsView.setBounds(normalizeBounds(payload.bounds));
|
||||
@@ -303,6 +307,12 @@ class PluginViewHost {
|
||||
if (rising && instance.location === "vault-page" && instance.owner.isFocused?.()) instance.contents.focus?.();
|
||||
}
|
||||
|
||||
focus(instanceId, sender) {
|
||||
const instance = this.#ownedInstance(instanceId, sender);
|
||||
if (!instance.visible || this.isLocked() || instance.owner.isFocused?.() !== true) return;
|
||||
instance.contents.focus?.();
|
||||
}
|
||||
|
||||
async postMessage(instanceId, message, sender) {
|
||||
const instance = this.#ownedInstance(instanceId, sender);
|
||||
assertJsonValue(message, "view message");
|
||||
|
||||
@@ -25,6 +25,7 @@ function fixture(options = {}) {
|
||||
if (options.loadURL) await options.loadURL(this);
|
||||
}
|
||||
send(channel, payload) { this.sent.push([channel, payload]); }
|
||||
focus() { this.emit("focus"); }
|
||||
isDestroyed() { return this.destroyed; }
|
||||
close() { this.destroyed = true; this.emit("destroyed"); }
|
||||
}
|
||||
@@ -455,3 +456,21 @@ test("host reload, host/view renderer exit, and host Escape close embedded pages
|
||||
await value.host.shutdown();
|
||||
}
|
||||
});
|
||||
|
||||
test("sidebar native focus is explicit, owner-bound and cannot raise a hidden or locked page", async () => {
|
||||
let locked = false;
|
||||
const value = fixture({ location: "terminal-sidebar", isLocked: () => locked });
|
||||
const { owner, sender } = value.createOwner(); const other = value.createOwner();
|
||||
const events = [];
|
||||
sender.send = (channel, event) => events.push({ channel, event }); owner.webContents = sender; owner.isFocused = () => true;
|
||||
const { instanceId } = await value.host.open({ viewId: "com.example.view.panel", scopeId: "window-1:terminal-sidebar" }, sender);
|
||||
assert.equal(value.views[0].visible, false);
|
||||
value.host.focus(instanceId, sender); assert.deepEqual(events, []);
|
||||
value.host.setVisible(instanceId, true, sender); assert.deepEqual(events, [], "revealing multiple panes does not focus each one");
|
||||
assert.throws(() => value.host.focus(instanceId, other.sender), /another window/i);
|
||||
value.host.focus(instanceId, sender);
|
||||
assert.deepEqual(events, [{ channel: "netcatty:plugins:view-focused", event: { instanceId } }]);
|
||||
locked = true; value.host.focus(instanceId, sender); assert.equal(events.length, 1);
|
||||
locked = false; value.setActivationCurrent(false); value.changeListener()({ reason: "context-key-changed" });
|
||||
assert.equal(owner.children.length, 0); await value.host.shutdown();
|
||||
});
|
||||
@@ -207,7 +207,7 @@ class RuntimeSupervisor {
|
||||
|
||||
async startEnabled() {
|
||||
for (const plugin of this.database.listPlugins()) {
|
||||
if (!plugin.enabled || plugin.runtime.quarantinedAt != null) continue;
|
||||
if (!plugin.enabled || plugin.runtime.quarantinedAt != null || plugin.manifest?.main?.data) continue;
|
||||
try { await this.start(plugin.id); } catch {}
|
||||
}
|
||||
}
|
||||
@@ -240,6 +240,7 @@ class RuntimeSupervisor {
|
||||
const plugin = this.database.getActivePlugin(pluginId);
|
||||
if (!plugin?.manifest || !plugin.packageRelativePath) throw new Error(`Plugin is not installed: ${pluginId}`);
|
||||
if (!plugin.enabled) throw new Error(`Plugin is disabled: ${pluginId}`);
|
||||
if (plugin.manifest.main.data) throw new Error("Static theme packs have no executable runtime");
|
||||
if (plugin.runtime.quarantinedAt != null) throw new Error(`Plugin is quarantined: ${pluginId}`);
|
||||
const pluginCli = await import("@netcatty/plugin-cli");
|
||||
signal.throwIfAborted();
|
||||
|
||||
@@ -993,3 +993,12 @@ test('new host initializes an exact legacy API plugin with its negotiated legacy
|
||||
assert.deepEqual(observed, ['0.1.0-internal']);
|
||||
await fixture.supervisor.stop(fixture.manifest.id);
|
||||
});
|
||||
|
||||
test('data-only themes never create a runtime on startup or explicit start',async(t)=>{
|
||||
const fixture=createFixture(t,()=>{throw new Error('data pack must not execute');});
|
||||
const manifest={...fixture.manifest,main:{data:'theme'},activationEvents:[],features:{required:['appearance.theme-packs']},contributes:{themes:[{id:fixture.manifest.id+'.light',label:'Light',variant:'light',entry:'light.json'}]}};
|
||||
fixture.database.installVersion({pluginId:manifest.id,version:manifest.version,manifest,archiveSha256:'a'.repeat(64),packageRelativePath:'theme'}, {enable:true});
|
||||
await fixture.supervisor.startEnabled();assert.equal(fixture.runtimeOptions.length,0);
|
||||
await assert.rejects(fixture.supervisor.start(manifest.id),/no executable runtime/);assert.equal(fixture.runtimeOptions.length,0);
|
||||
await fixture.supervisor.shutdown();
|
||||
});
|
||||
@@ -0,0 +1,220 @@
|
||||
"use strict";
|
||||
const fs = require("node:fs/promises");
|
||||
const { constants } = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const { createDefinitionValidator } = require("./contractValidator.cjs");
|
||||
const { resolveThemePalette } = require("../../domain/pluginThemePack.cjs");
|
||||
const { isStaticThemePack } = require("../../domain/pluginRuntimePolicy.cjs");
|
||||
const validatePalette = createDefinitionValidator("ThemePackPalette");
|
||||
const validateApply = createDefinitionValidator("ThemePackApplyRequest");
|
||||
const validateSelection = createDefinitionValidator("ThemePackSelection");
|
||||
const limits = require("./generated/plugin-contract.schema.json").$defs
|
||||
.ThemePackLimits.const;
|
||||
const key = (ref) =>
|
||||
JSON.stringify([
|
||||
ref.pluginId,
|
||||
ref.themeId,
|
||||
ref.version,
|
||||
ref.digest,
|
||||
ref.variant,
|
||||
]);
|
||||
class PluginThemeService {
|
||||
constructor({ database, packageStore, contributionService }) {
|
||||
Object.assign(this, { database, packageStore });
|
||||
this.generation = 0;
|
||||
this.cache = new Map();
|
||||
this.listeners = new Set();
|
||||
this.tail = Promise.resolve();
|
||||
this.dispose = contributionService?.onDidChange?.((event) => {
|
||||
if (
|
||||
!["initialized", "plugin-enabled", "plugin-disabled"].includes(
|
||||
event.reason,
|
||||
)
|
||||
)
|
||||
return;
|
||||
this.generation++;
|
||||
this.cache.clear();
|
||||
this.emit();
|
||||
});
|
||||
}
|
||||
onDidChange(listener) {
|
||||
this.listeners.add(listener);
|
||||
return () => this.listeners.delete(listener);
|
||||
}
|
||||
emit() {
|
||||
for (const listener of this.listeners) {
|
||||
try {
|
||||
listener();
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
active(ref) {
|
||||
const p = this.database.getActivePlugin(ref.pluginId);
|
||||
return (
|
||||
p?.enabled &&
|
||||
p.activeVersion === ref.version &&
|
||||
p.archiveSha256 === ref.digest &&
|
||||
p.manifest?.contributes?.themes?.some(
|
||||
(t) => t.id === ref.themeId && t.variant === ref.variant,
|
||||
)
|
||||
);
|
||||
}
|
||||
catalog() {
|
||||
if (!this.pendingCatalog) {
|
||||
const pending = this.readCatalog().finally(() => {
|
||||
if (this.pendingCatalog === pending) this.pendingCatalog = null;
|
||||
});
|
||||
this.pendingCatalog = pending;
|
||||
}
|
||||
return this.pendingCatalog;
|
||||
}
|
||||
async readCatalog(retry = true) {
|
||||
const generation = this.generation,
|
||||
entries = [],
|
||||
diagnostics = [];
|
||||
for (const plugin of this.database.listPlugins()) {
|
||||
if (!plugin.enabled) continue;
|
||||
let packageRoot;
|
||||
for (const theme of plugin.manifest?.contributes?.themes ?? []) {
|
||||
if (entries.length >= limits.catalogEntries) {
|
||||
if (diagnostics.length < 256)
|
||||
diagnostics.push({ pluginId: plugin.id, code: "capacity" });
|
||||
break;
|
||||
}
|
||||
const ref = {
|
||||
pluginId: plugin.id,
|
||||
themeId: theme.id,
|
||||
version: plugin.activeVersion,
|
||||
digest: plugin.archiveSha256,
|
||||
variant: theme.variant,
|
||||
};
|
||||
try {
|
||||
let palette = this.cache.get(key(ref));
|
||||
if (!palette) {
|
||||
const root = (packageRoot ??=
|
||||
await this.packageStore.preparePackageRoot(plugin));
|
||||
const target = path.resolve(root, theme.entry);
|
||||
if (
|
||||
!theme.entry.endsWith(".json") ||
|
||||
!target.startsWith(root + path.sep)
|
||||
)
|
||||
throw new Error("Invalid path");
|
||||
const handle = await fs.open(
|
||||
target,
|
||||
constants.O_RDONLY | constants.O_NOFOLLOW,
|
||||
);
|
||||
try {
|
||||
const stat = await handle.stat();
|
||||
if (!stat.isFile() || stat.size > limits.jsonBytes)
|
||||
throw new Error("Invalid theme size");
|
||||
const bytes = Buffer.alloc(limits.jsonBytes + 1);
|
||||
const { bytesRead } = await handle.read(
|
||||
bytes,
|
||||
0,
|
||||
bytes.length,
|
||||
0,
|
||||
);
|
||||
if (bytesRead > limits.jsonBytes)
|
||||
throw new Error("Invalid theme size");
|
||||
palette = JSON.parse(
|
||||
new TextDecoder("utf-8", { fatal: true }).decode(
|
||||
bytes.subarray(0, bytesRead),
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
if (
|
||||
!validatePalette(palette) ||
|
||||
palette.base !==
|
||||
(theme.variant.endsWith("dark") ? "dark" : "light")
|
||||
)
|
||||
throw new Error("Invalid theme palette");
|
||||
resolveThemePalette(palette);
|
||||
// Only immutable, validated data enters the catalog/cache.
|
||||
palette = JSON.parse(JSON.stringify(palette));
|
||||
if (generation === this.generation && this.active(ref))
|
||||
this.cache.set(key(ref), palette);
|
||||
}
|
||||
if (generation !== this.generation || !this.active(ref)) continue;
|
||||
const label =
|
||||
typeof theme.label === "string"
|
||||
? theme.label
|
||||
: (theme.label.en ??
|
||||
theme.label.default ??
|
||||
Object.values(theme.label)[0]);
|
||||
entries.push({
|
||||
ref,
|
||||
label,
|
||||
pluginName: plugin.manifest.name,
|
||||
dataOnly: isStaticThemePack(plugin.manifest),
|
||||
palette,
|
||||
});
|
||||
} catch {
|
||||
if (diagnostics.length < 256)
|
||||
diagnostics.push({
|
||||
pluginId: plugin.id,
|
||||
themeId: theme.id,
|
||||
code: "invalid",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
// A package change during an async read must never publish an old palette.
|
||||
if (generation !== this.generation) {
|
||||
if (retry) return this.readCatalog(false);
|
||||
throw new Error("Theme packages changed; refresh again");
|
||||
}
|
||||
const selection = this.database.getHostThemeSelection();
|
||||
if (!validateSelection(selection))
|
||||
throw new Error("Invalid saved theme selection");
|
||||
return { selection, entries, diagnostics };
|
||||
}
|
||||
apply(request) {
|
||||
const run = async () => {
|
||||
if (!validateApply(request))
|
||||
throw new TypeError("Invalid theme selection request");
|
||||
const { selection, entries } = await this.catalog();
|
||||
if (selection.revision !== request.expectedRevision)
|
||||
throw new Error("Theme selection changed; refresh before applying");
|
||||
if (request.choice) {
|
||||
const entry = entries.find(
|
||||
(e) => key(e.ref) === key(request.choice.ref),
|
||||
);
|
||||
if (
|
||||
!entry ||
|
||||
entry.palette.base !== request.mode ||
|
||||
!this.active(entry.ref)
|
||||
)
|
||||
throw new Error("Theme is no longer available");
|
||||
}
|
||||
if (selection.revision >= Number.MAX_SAFE_INTEGER)
|
||||
throw new Error("Theme revision exhausted");
|
||||
const next = {
|
||||
...selection,
|
||||
revision: selection.revision + 1,
|
||||
[request.mode]: request.choice,
|
||||
};
|
||||
// Synchronous active check immediately before the atomic preference/audit write.
|
||||
if (request.choice && !this.active(request.choice.ref))
|
||||
throw new Error("Theme changed before apply");
|
||||
this.database.commitHostThemeSelection(
|
||||
request.expectedRevision,
|
||||
next,
|
||||
request.choice?.ref.pluginId,
|
||||
);
|
||||
this.emit();
|
||||
return this.catalog();
|
||||
};
|
||||
const result = this.tail.then(run);
|
||||
this.tail = result.catch(() => {});
|
||||
return result;
|
||||
}
|
||||
shutdown() {
|
||||
if (typeof this.dispose === "function") this.dispose();
|
||||
else this.dispose?.dispose();
|
||||
this.cache.clear();
|
||||
this.listeners.clear();
|
||||
}
|
||||
}
|
||||
module.exports = { PluginThemeService };
|
||||
@@ -0,0 +1,168 @@
|
||||
"use strict";
|
||||
const assert = require("node:assert/strict");
|
||||
const test = require("node:test");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const os = require("node:os");
|
||||
const { PluginDatabase } = require("./database.cjs");
|
||||
const { PluginThemeService } = require("./themeService.cjs");
|
||||
function fixture(t) {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-theme-test-"));
|
||||
const database = new PluginDatabase(path.join(root, "plugins.sqlite"));
|
||||
const manifest = JSON.parse(
|
||||
fs.readFileSync(
|
||||
path.join(
|
||||
__dirname,
|
||||
"../../examples/plugins/forest-theme-pack/netcatty.plugin.json",
|
||||
),
|
||||
"utf8",
|
||||
),
|
||||
);
|
||||
const install = (version = "0.1.0", digest = "a".repeat(64)) =>
|
||||
database.installVersion(
|
||||
{
|
||||
pluginId: manifest.id,
|
||||
version,
|
||||
manifest: { ...manifest, version },
|
||||
archiveSha256: digest,
|
||||
packageRelativePath: "theme",
|
||||
},
|
||||
{ enable: true },
|
||||
);
|
||||
install();
|
||||
for (const mode of ["light", "dark"])
|
||||
fs.copyFileSync(
|
||||
path.join(
|
||||
__dirname,
|
||||
"../../examples/plugins/forest-theme-pack",
|
||||
mode + ".json",
|
||||
),
|
||||
path.join(root, mode + ".json"),
|
||||
);
|
||||
let change;
|
||||
const packageStore = {
|
||||
async preparePackageRoot() {
|
||||
return root;
|
||||
},
|
||||
};
|
||||
const service = new PluginThemeService({
|
||||
database,
|
||||
packageStore,
|
||||
contributionService: {
|
||||
onDidChange(listener) {
|
||||
change = listener;
|
||||
return () => {};
|
||||
},
|
||||
},
|
||||
});
|
||||
t.after(() => {
|
||||
service.shutdown();
|
||||
database.close();
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
return {
|
||||
root,
|
||||
database,
|
||||
service,
|
||||
packageStore,
|
||||
install,
|
||||
change: () => change({ reason: "plugin-disabled" }),
|
||||
};
|
||||
}
|
||||
test("data-only themes enumerate without runtime, apply with CAS and fixed audit, retain missing preference after disable", async (t) => {
|
||||
const f = fixture(t),
|
||||
catalog = await f.service.catalog();
|
||||
assert.equal(catalog.entries.length, 2);
|
||||
assert.ok(catalog.entries.every((e) => e.dataOnly));
|
||||
assert.equal(catalog.selection.revision, 0);
|
||||
const ref = catalog.entries[0].ref;
|
||||
const applied = await f.service.apply({
|
||||
expectedRevision: 0,
|
||||
mode: "light",
|
||||
choice: { ref },
|
||||
});
|
||||
assert.equal(applied.selection.light.ref.digest, "a".repeat(64));
|
||||
await assert.rejects(
|
||||
f.service.apply({ expectedRevision: 0, mode: "light", choice: { ref } }),
|
||||
/changed/,
|
||||
);
|
||||
assert.deepEqual(f.database.listSecurityAudit(ref.pluginId)[0].details, {
|
||||
revision: 1,
|
||||
action: "apply",
|
||||
});
|
||||
f.database.setEnabled(ref.pluginId, false);
|
||||
f.change();
|
||||
const disabled = await f.service.catalog();
|
||||
assert.equal(disabled.entries.length, 0);
|
||||
assert.deepEqual(disabled.selection.light, { ref });
|
||||
await assert.rejects(
|
||||
f.service.apply({ expectedRevision: 1, mode: "light", choice: { ref } }),
|
||||
/no longer available/,
|
||||
);
|
||||
const restored = await f.service.apply({
|
||||
expectedRevision: 1,
|
||||
mode: "light",
|
||||
choice: null,
|
||||
});
|
||||
assert.equal(restored.selection.light, null);
|
||||
});
|
||||
test("palette validation rejects unsupported values, low contrast, mode mismatch and bounded oversize", async (t) => {
|
||||
const f = fixture(t);
|
||||
for (const palette of [
|
||||
{ formatVersion: 1, base: "light", colors: { accent: "var(--x)" } },
|
||||
{ formatVersion: 1, base: "light", colors: { foreground: "#ffffff" } },
|
||||
{ formatVersion: 1, base: "light", colors: { muted: "#000000", mutedForeground: "#ffffff" } },
|
||||
{ formatVersion: 1, base: "dark", colors: { accent: "#86efac" } },
|
||||
" ".repeat(65537),
|
||||
]) {
|
||||
fs.writeFileSync(
|
||||
path.join(f.root, "light.json"),
|
||||
typeof palette === "string" ? palette : JSON.stringify(palette),
|
||||
);
|
||||
f.change();
|
||||
const catalog = await f.service.catalog();
|
||||
assert.equal(catalog.entries.length, 1);
|
||||
assert.equal(catalog.diagnostics[0].code, "invalid");
|
||||
}
|
||||
});
|
||||
test("replacement during an awaited package read cannot publish or apply the old identity", async (t) => {
|
||||
const f = fixture(t);
|
||||
const before = await f.service.catalog(),
|
||||
ref = before.entries[0].ref;
|
||||
f.change();
|
||||
let once = true;
|
||||
f.packageStore.preparePackageRoot = async () => {
|
||||
if (once) {
|
||||
once = false;
|
||||
f.install("0.1.1", "b".repeat(64));
|
||||
f.change();
|
||||
}
|
||||
return f.root;
|
||||
};
|
||||
const catalog = await f.service.catalog();
|
||||
assert.ok(catalog.entries.every((e) => e.ref.version === "0.1.1"));
|
||||
await assert.rejects(
|
||||
f.service.apply({ expectedRevision: 0, mode: "light", choice: { ref } }),
|
||||
/no longer available/,
|
||||
);
|
||||
});
|
||||
test("preference and audit roll back together on audit failure and survive schema-3 migration", async (t) => {
|
||||
const f = fixture(t),
|
||||
ref = (await f.service.catalog()).entries[0].ref;
|
||||
f.database.db.exec(
|
||||
"CREATE TRIGGER reject_theme_audit BEFORE INSERT ON plugin_security_audit BEGIN SELECT RAISE(ABORT,'synthetic audit failure'); END",
|
||||
);
|
||||
await assert.rejects(
|
||||
f.service.apply({ expectedRevision: 0, mode: "light", choice: { ref } }),
|
||||
/synthetic audit failure/,
|
||||
);
|
||||
assert.equal(f.database.getHostThemeSelection().revision, 0);
|
||||
f.database.db.exec("DROP TRIGGER reject_theme_audit");
|
||||
const file = path.join(f.root, "v3.sqlite");
|
||||
const old = new PluginDatabase(file);
|
||||
old.db.exec("DROP TABLE plugin_host_preferences; PRAGMA user_version=3");
|
||||
old.close();
|
||||
const migrated = new PluginDatabase(file);
|
||||
assert.equal(migrated.getHostThemeSelection().revision, 0);
|
||||
migrated.close();
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
const assert = require("node:assert/strict");
|
||||
const test = require("node:test");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const { JSDOM } = require("jsdom");
|
||||
test("official view examples consume initial/live host HSL tokens and dispose subscriptions", () => {
|
||||
for (const name of [
|
||||
"starter-netcatty",
|
||||
"vault-scratchpad",
|
||||
"session-scratchpad",
|
||||
]) {
|
||||
const root = path.join(__dirname, "../../examples/plugins", name),
|
||||
dom = new JSDOM("<!doctype html><html></html>", {
|
||||
runScripts: "outside-only",
|
||||
});
|
||||
let listener,
|
||||
disposed = 0,
|
||||
subscriptions = 0;
|
||||
dom.window.netcattyView = {
|
||||
onDidChangeEnvironment(fn) {
|
||||
subscriptions++;
|
||||
listener = fn;
|
||||
return {
|
||||
dispose() {
|
||||
disposed++;
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
dom.window.eval(fs.readFileSync(path.join(root, "theme.js"), "utf8"));
|
||||
listener({
|
||||
theme: "light",
|
||||
themeTokens: {
|
||||
"--primary": "120 50% 30%",
|
||||
"--background": "0 0% 100%",
|
||||
"--unsupported": "ignored",
|
||||
},
|
||||
});
|
||||
assert.equal(
|
||||
dom.window.document.documentElement.style.getPropertyValue("--primary"),
|
||||
"120 50% 30%",
|
||||
);
|
||||
assert.equal(
|
||||
dom.window.document.documentElement.style.getPropertyValue(
|
||||
"--unsupported",
|
||||
),
|
||||
"",
|
||||
);
|
||||
listener({
|
||||
theme: "dark",
|
||||
reducedMotion: true,
|
||||
themeTokens: { "--primary": "120 50% 70%" },
|
||||
});
|
||||
assert.equal(dom.window.document.documentElement.style.colorScheme, "dark");
|
||||
assert.equal(
|
||||
dom.window.document.documentElement.style.getPropertyValue(
|
||||
"--background",
|
||||
),
|
||||
"",
|
||||
);
|
||||
dom.window.dispatchEvent(new dom.window.Event("pagehide"));
|
||||
assert.equal(disposed, 1);
|
||||
dom.window.dispatchEvent(new dom.window.Event("pageshow"));
|
||||
assert.equal(subscriptions, 2);
|
||||
dom.window.close();
|
||||
}
|
||||
});
|
||||
@@ -122,6 +122,13 @@ function createPreloadApi(ctx) {
|
||||
}),
|
||||
restartPlugin: (pluginId) => ipcRenderer.invoke("netcatty:plugins:restart", { pluginId }),
|
||||
uninstallPlugin: (pluginId) => ipcRenderer.invoke("netcatty:plugins:uninstall", { pluginId }),
|
||||
getPluginThemes: () => ipcRenderer.invoke("netcatty:plugins:theme-catalog"),
|
||||
applyPluginTheme: (request) => ipcRenderer.invoke("netcatty:plugins:theme-apply", request),
|
||||
onPluginThemesChanged: (callback) => {
|
||||
const listener = () => callback();
|
||||
ipcRenderer.on("netcatty:plugins:themes-changed", listener);
|
||||
return () => ipcRenderer.removeListener("netcatty:plugins:themes-changed", listener);
|
||||
},
|
||||
getPluginContributions: (options) => ipcRenderer.invoke("netcatty:plugins:contributions", options ?? {}),
|
||||
getPluginBrandIcon: (pluginId) => ipcRenderer.invoke("netcatty:plugins:brand-icon", { pluginId }),
|
||||
getPluginContributionIcon: (pluginId, icon) => ipcRenderer.invoke("netcatty:plugins:contribution-icon", { pluginId, icon }),
|
||||
@@ -270,6 +277,7 @@ function createPreloadApi(ctx) {
|
||||
},
|
||||
openPluginView: (payload) => ipcRenderer.invoke("netcatty:plugins:open-view", payload),
|
||||
closePluginView: (instanceId) => ipcRenderer.invoke("netcatty:plugins:close-view", { instanceId }),
|
||||
focusPluginView: (payload) => ipcRenderer.invoke("netcatty:plugins:focus-view", payload),
|
||||
setPluginViewBounds: (instanceId, bounds) => ipcRenderer.invoke("netcatty:plugins:set-view-bounds", { instanceId, bounds }),
|
||||
setPluginViewVisibility: (instanceId, visible) => ipcRenderer.invoke("netcatty:plugins:set-view-visibility", {
|
||||
instanceId,
|
||||
@@ -290,6 +298,11 @@ function createPreloadApi(ctx) {
|
||||
ipcRenderer.on("netcatty:plugins:view-closed", listener);
|
||||
return () => ipcRenderer.removeListener("netcatty:plugins:view-closed", listener);
|
||||
},
|
||||
onPluginViewFocused: (callback) => {
|
||||
const listener = (_event, payload) => callback(payload);
|
||||
ipcRenderer.on("netcatty:plugins:view-focused", listener);
|
||||
return () => ipcRenderer.removeListener("netcatty:plugins:view-focused", listener);
|
||||
},
|
||||
getPluginScopeCatalog: () => ipcRenderer.invoke("netcatty:plugins:get-scope-catalog"),
|
||||
setPluginScopeCatalog: (catalog) => ipcRenderer.invoke("netcatty:plugins:set-scope-catalog", catalog),
|
||||
onPluginScopeCatalogChanged: (callback) => {
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
# Forest Themes
|
||||
|
||||
A data-only light/dark host color pack. No JavaScript entrypoint, runtime activation, permissions, network, fonts or external assets. Install and enable through Settings > Plugins; select the current light/dark variant in Appearance > Plugin themes. Preview is local to this window; Apply saves only this device’s host selection. Cancel preview or Restore default returns to host-owned colors. Personal accent and fonts remain authoritative.
|
||||
|
||||
Disable, uninstall, update or integrity failure makes an applied version unavailable and falls back to a safe same-mode palette. A changed package is not automatically applied. High-contrast system preferences use the fixed host palette. Terminal palettes and font/density/icon/background changes are not part of this first color-pack contract.
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"formatVersion": 1,
|
||||
"base": "dark",
|
||||
"colors": {
|
||||
"background": "#101c16",
|
||||
"foreground": "#f0f9f3",
|
||||
"card": "#17281e",
|
||||
"cardForeground": "#f0f9f3",
|
||||
"popover": "#17281e",
|
||||
"popoverForeground": "#f0f9f3",
|
||||
"secondary": "#21392b",
|
||||
"secondaryForeground": "#f0f9f3",
|
||||
"muted": "#1b3024",
|
||||
"mutedForeground": "#c4dbc9",
|
||||
"accent": "#86efac",
|
||||
"border": "#82a98d",
|
||||
"input": "#82a98d"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"formatVersion": 1,
|
||||
"base": "light",
|
||||
"colors": {
|
||||
"background": "#f4faf6",
|
||||
"foreground": "#16251c",
|
||||
"card": "#ffffff",
|
||||
"cardForeground": "#16251c",
|
||||
"popover": "#ffffff",
|
||||
"popoverForeground": "#16251c",
|
||||
"secondary": "#dcebe1",
|
||||
"secondaryForeground": "#16251c",
|
||||
"muted": "#e7f2eb",
|
||||
"mutedForeground": "#324b3b",
|
||||
"accent": "#166534",
|
||||
"border": "#527360",
|
||||
"input": "#527360"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"manifestVersion": 1,
|
||||
"id": "com.netcatty.forest-themes",
|
||||
"name": "forest-themes",
|
||||
"displayName": {
|
||||
"en": "Forest Themes",
|
||||
"zh-CN": "森林主题"
|
||||
},
|
||||
"version": "0.1.0",
|
||||
"publisher": "netcatty",
|
||||
"license": "GPL-3.0-or-later",
|
||||
"engines": {
|
||||
"netcatty": ">=0.0.0",
|
||||
"api": ">=0.1.0-internal.1 <0.2.0"
|
||||
},
|
||||
"features": {
|
||||
"required": [
|
||||
"appearance.theme-packs"
|
||||
]
|
||||
},
|
||||
"main": {
|
||||
"data": "theme"
|
||||
},
|
||||
"contributes": {
|
||||
"themes": [
|
||||
{
|
||||
"id": "com.netcatty.forest-themes.light",
|
||||
"label": {
|
||||
"en": "Forest Light",
|
||||
"zh-CN": "森林浅色"
|
||||
},
|
||||
"variant": "light",
|
||||
"entry": "light.json"
|
||||
},
|
||||
{
|
||||
"id": "com.netcatty.forest-themes.dark",
|
||||
"label": {
|
||||
"en": "Forest Dark",
|
||||
"zh-CN": "森林深色"
|
||||
},
|
||||
"variant": "dark",
|
||||
"entry": "dark.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
# Session Scratchpad
|
||||
|
||||
Fixture ID: `com.netcatty.session-scratchpad`, version `0.1.1`.
|
||||
Required permission: **views**, for this plugin's own pages only. Required
|
||||
feature: `views.terminal-sidebar`. No terminal/session read, clipboard, network,
|
||||
Vault, filesystem, credential or shell permission is requested.
|
||||
|
||||
```sh
|
||||
node packages/plugin-cli/dist/cli.js pack examples/plugins/session-scratchpad --out /tmp/session-scratchpad.ncpkg
|
||||
```
|
||||
|
||||
Import disabled through normal Settings, inspect and enable. Parent native QA
|
||||
must obtain a fixture-specific own-views approval; no previous plugin's grant
|
||||
applies. Use only an explicitly approved synthetic terminal target, without
|
||||
entering commands or connecting to real hosts for this page check.
|
||||
|
||||
In an open terminal, choose **Extension pages** in its toolbar, then **Session
|
||||
scratchpad**. This is a real existing side-panel pane. Save synthetic text,
|
||||
split beside a built-in tool, resize/move/magnify, return and reopen. The host
|
||||
keeps this page's state in a placement/session bucket; the page never receives
|
||||
the session ID, endpoint, cwd, title, history, selection or terminal output.
|
||||
This is plugin-owned text, not a Vault Note, and is not automatically synced.
|
||||
|
||||
For a separately approved second synthetic terminal/workspace focus target,
|
||||
verify that navigation closes the old native view and uses a separate draft
|
||||
bucket. Switch back to verify the saved first draft. Clear it to verify
|
||||
deletion. Close the pane, press Escape, disable/revoke, and verify cleanup.
|
||||
Settings/permission dialogs and overlapping menus must stay above native
|
||||
content. No new native target or persistent grant should be inferred from this
|
||||
README; parent coordinates the exact test and records source SHA/package hash.
|
||||
|
||||
The page subscribes to `window.netcattyView.onDidChangeEnvironment()` in `theme.js`. It follows the seven public HSL theme variables, including accent changes, without reading host settings or DOM. The subscription is disposed on page hide.
|
||||
@@ -0,0 +1 @@
|
||||
export default { activate() {} };
|
||||
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"manifestVersion": 1,
|
||||
"id": "com.netcatty.session-scratchpad",
|
||||
"name": "session-scratchpad",
|
||||
"displayName": {
|
||||
"en": "Session Scratchpad",
|
||||
"zh-CN": "会话草稿板"
|
||||
},
|
||||
"version": "0.1.1",
|
||||
"publisher": "netcatty",
|
||||
"license": "GPL-3.0-or-later",
|
||||
"engines": {
|
||||
"netcatty": ">=0.0.0",
|
||||
"api": ">=0.1.0-internal.1 <0.2.0"
|
||||
},
|
||||
"features": {
|
||||
"required": [
|
||||
"views.terminal-sidebar"
|
||||
]
|
||||
},
|
||||
"main": {
|
||||
"browser": "browser.js"
|
||||
},
|
||||
"activationEvents": [
|
||||
"onView:com.netcatty.session-scratchpad.page"
|
||||
],
|
||||
"permissions": {
|
||||
"required": [
|
||||
"views"
|
||||
]
|
||||
},
|
||||
"contributes": {
|
||||
"views": [
|
||||
{
|
||||
"id": "com.netcatty.session-scratchpad.page",
|
||||
"title": {
|
||||
"en": "Session scratchpad",
|
||||
"zh-CN": "会话草稿板"
|
||||
},
|
||||
"location": "terminal-sidebar",
|
||||
"entry": "page.html",
|
||||
"order": 10
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
:root { color-scheme: light dark; font: 14px/1.6 system-ui, sans-serif; }
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; padding: 24px; background: hsl(var(--background, 0 0% 100%)); color: hsl(var(--foreground, 222 47% 12%)); }
|
||||
main { min-width: 0; overflow-wrap: anywhere; max-width: 720px; margin: auto; }
|
||||
h1 { margin: 0 0 8px; font-size: 22px; line-height: 1.3; letter-spacing: -.02em; }
|
||||
p { color: hsl(var(--muted-foreground, 215 20% 35%)); }
|
||||
label { display: block; font-weight: 600; }
|
||||
textarea { display: block; width: 100%; min-width: 0; resize: vertical; min-height: 140px; margin: 10px 0 16px; padding: 12px; border: 1px solid hsl(var(--border, 215 20% 75%)); border-radius: 10px; background: hsl(var(--muted, 210 20% 96%)); color: inherit; font: inherit; }
|
||||
button { padding: 8px 14px; margin: 0 8px 8px 0; border: 1px solid hsl(var(--border, 215 20% 75%)); border-radius: 8px; background: hsl(var(--background, 0 0% 100%)); color: inherit; font: inherit; font-weight: 500; cursor: pointer; }
|
||||
#save, #greet { border-color: transparent; background: hsl(var(--primary, 221 83% 45%)); color: hsl(var(--primary-foreground, 0 0% 100%)); }
|
||||
button:hover:not(:disabled) { filter: brightness(.96); }
|
||||
button:disabled { opacity: .5; cursor: default; }
|
||||
button:focus-visible, textarea:focus-visible { outline: 2px solid hsl(var(--primary, 221 83% 45%)); outline-offset: 3px; }
|
||||
#status { min-height: 1.5em; font-size: 12px; }
|
||||
#result { white-space: pre-wrap; overflow-wrap: anywhere; padding: 16px; border-radius: 10px; background: hsl(var(--muted, 210 20% 96%)); min-height: 48px; }
|
||||
.eyebrow { color: hsl(var(--muted-foreground, 215 20% 35%)); font-size: 11px; letter-spacing: .1em; }
|
||||
@media (max-width: 360px) { body { padding: 16px; } }
|
||||
@media (forced-colors: active) { button, textarea { border: 1px solid ButtonText; } }
|
||||
|
||||
@media (prefers-color-scheme: dark) { :root { --background: 221 39% 11%; --foreground: 210 20% 98%; --muted: 219 27% 21%; --muted-foreground: 220 13% 91%; --border: 218 11% 65%; --primary: 213 97% 87%; --primary-foreground: 221 39% 11%; } }
|
||||
@@ -0,0 +1,7 @@
|
||||
<!doctype html>
|
||||
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Session Scratchpad</title><link rel="stylesheet" href="page.css"></head>
|
||||
<body><main><h1>Session scratchpad</h1><p>This page stores only text you type here, in this plugin's own view state. It cannot read Vault hosts, notes, credentials, sessions or the clipboard.</p>
|
||||
<label for="draft">Synthetic draft</label><textarea id="draft" maxlength="4096" rows="8" disabled></textarea>
|
||||
<button type="button" id="save" disabled>Save draft</button><button type="button" id="clear" disabled>Clear draft</button>
|
||||
<p id="status" role="status">Loading your plugin draft…</p></main><script src="theme.js"></script><script src="page.js"></script></body></html>
|
||||
@@ -0,0 +1,25 @@
|
||||
"use strict";
|
||||
/* global document, window */
|
||||
const draft = document.getElementById("draft");
|
||||
const status = document.getElementById("status");
|
||||
let busy = false;
|
||||
async function save(clear) {
|
||||
if (busy) return;
|
||||
busy = true;
|
||||
try {
|
||||
const text = clear ? "" : draft.value;
|
||||
await window.netcattyView.setState({ version: 1, text });
|
||||
if (clear) draft.value = "";
|
||||
status.textContent = clear ? "Draft cleared." : "Draft saved in this plugin only.";
|
||||
} catch { status.textContent = "Could not save this draft."; }
|
||||
finally { busy = false; }
|
||||
}
|
||||
document.getElementById("save").addEventListener("click", () => { void save(false); });
|
||||
document.getElementById("clear").addEventListener("click", () => { void save(true); });
|
||||
window.netcattyView.getState().then((state) => {
|
||||
if (state?.version === 1 && typeof state.text === "string" && state.text.length <= 4096) draft.value = state.text;
|
||||
status.textContent = "Ready. Use synthetic text for this example.";
|
||||
draft.disabled = false;
|
||||
document.getElementById("save").disabled = false;
|
||||
document.getElementById("clear").disabled = false;
|
||||
}).catch(() => { status.textContent = "Could not load this plugin draft."; });
|
||||
@@ -0,0 +1,24 @@
|
||||
"use strict";
|
||||
/* global document, window */
|
||||
// This page owns its DOM. Only the host's public semantic colors are copied.
|
||||
(() => {
|
||||
const names = ["--background", "--foreground", "--muted", "--muted-foreground", "--border", "--primary", "--primary-foreground"];
|
||||
let subscription;
|
||||
const subscribe = () => {
|
||||
if (subscription) return;
|
||||
subscription = window.netcattyView.onDidChangeEnvironment((environment) => {
|
||||
const root = document.documentElement;
|
||||
root.style.colorScheme = environment.theme === "system" ? "light dark" : environment.theme;
|
||||
root.dataset.reducedMotion = String(Boolean(environment.reducedMotion));
|
||||
root.dataset.highContrast = String(Boolean(environment.highContrast));
|
||||
for (const name of names) {
|
||||
const value = environment.themeTokens?.[name];
|
||||
if (typeof value === "string" && value.length <= 96) root.style.setProperty(name, value);
|
||||
else root.style.removeProperty(name);
|
||||
}
|
||||
});
|
||||
};
|
||||
subscribe();
|
||||
window.addEventListener("pagehide", () => { subscription?.dispose(); subscription = undefined; });
|
||||
window.addEventListener("pageshow", subscribe);
|
||||
})();
|
||||
@@ -35,3 +35,5 @@ 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).
|
||||
|
||||
The page subscribes to `window.netcattyView.onDidChangeEnvironment()` in `theme.js`. It follows the seven public HSL theme variables, including accent changes, without reading host settings or DOM. The subscription is disposed on page hide.
|
||||
@@ -7,7 +7,7 @@
|
||||
"zh-CN": "插件入门"
|
||||
},
|
||||
"description": "First-party settings and command example. No terminal, network, filesystem or secret access.",
|
||||
"version": "0.1.2",
|
||||
"version": "0.1.3",
|
||||
"publisher": "netcatty",
|
||||
"license": "GPL-3.0-or-later",
|
||||
"engines": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@netcatty/example-starter-plugin",
|
||||
"version": "0.1.2",
|
||||
"version": "0.1.3",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -1 +1,20 @@
|
||||
:root { color-scheme: light dark; font: 15px/1.6 system-ui, sans-serif; } body { margin: 0; } main { padding: 32px; max-width: 640px; margin: auto; } .eyebrow { font-size: 11px; letter-spacing: .12em; opacity: .65; } h1 { font-size: 24px; } #description { opacity: .75; } button { font: inherit; color: inherit; background: transparent; border: 1px solid currentColor; border-radius: 8px; padding: 8px 16px; cursor: pointer; } button:disabled { opacity: .5; } #result { white-space: pre-wrap; overflow-wrap: anywhere; padding: 16px; border-radius: 8px; background: light-dark(#f3f4f6, #252525); min-height: 24px; }
|
||||
:root { color-scheme: light dark; font: 14px/1.6 system-ui, sans-serif; }
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; padding: 24px; background: hsl(var(--background, 0 0% 100%)); color: hsl(var(--foreground, 222 47% 12%)); }
|
||||
main { min-width: 0; overflow-wrap: anywhere; max-width: 720px; margin: auto; }
|
||||
h1 { margin: 0 0 8px; font-size: 22px; line-height: 1.3; letter-spacing: -.02em; }
|
||||
p { color: hsl(var(--muted-foreground, 215 20% 35%)); }
|
||||
label { display: block; font-weight: 600; }
|
||||
textarea { display: block; width: 100%; min-width: 0; resize: vertical; min-height: 140px; margin: 10px 0 16px; padding: 12px; border: 1px solid hsl(var(--border, 215 20% 75%)); border-radius: 10px; background: hsl(var(--muted, 210 20% 96%)); color: inherit; font: inherit; }
|
||||
button { padding: 8px 14px; margin: 0 8px 8px 0; border: 1px solid hsl(var(--border, 215 20% 75%)); border-radius: 8px; background: hsl(var(--background, 0 0% 100%)); color: inherit; font: inherit; font-weight: 500; cursor: pointer; }
|
||||
#save, #greet { border-color: transparent; background: hsl(var(--primary, 221 83% 45%)); color: hsl(var(--primary-foreground, 0 0% 100%)); }
|
||||
button:hover:not(:disabled) { filter: brightness(.96); }
|
||||
button:disabled { opacity: .5; cursor: default; }
|
||||
button:focus-visible, textarea:focus-visible { outline: 2px solid hsl(var(--primary, 221 83% 45%)); outline-offset: 3px; }
|
||||
#status { min-height: 1.5em; font-size: 12px; }
|
||||
#result { white-space: pre-wrap; overflow-wrap: anywhere; padding: 16px; border-radius: 10px; background: hsl(var(--muted, 210 20% 96%)); min-height: 48px; }
|
||||
.eyebrow { color: hsl(var(--muted-foreground, 215 20% 35%)); font-size: 11px; letter-spacing: .1em; }
|
||||
@media (max-width: 360px) { body { padding: 16px; } }
|
||||
@media (forced-colors: active) { button, textarea { border: 1px solid ButtonText; } }
|
||||
|
||||
@media (prefers-color-scheme: dark) { :root { --background: 221 39% 11%; --foreground: 210 20% 98%; --muted: 219 27% 21%; --muted-foreground: 220 13% 91%; --border: 218 11% 65%; --primary: 213 97% 87%; --primary-foreground: 221 39% 11%; } }
|
||||
@@ -1,3 +1,3 @@
|
||||
<!doctype html>
|
||||
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><link rel="stylesheet" href="panel.css"><title>Plugin Starter</title></head>
|
||||
<body><main><p class="eyebrow">NETCATTY · PLUGIN STARTER</p><h1 id="title">Your first plugin</h1><p id="description">Read your Greeting preference and show it here. No hosts, terminals or secrets are accessed.</p><button id="greet" type="button">Show greeting</button><p id="result" role="status" aria-live="polite"></p></main><script src="panel.js"></script></body></html>
|
||||
<body><main><p class="eyebrow">NETCATTY · PLUGIN STARTER</p><h1 id="title">Your first plugin</h1><p id="description">Read your Greeting preference and show it here. No hosts, terminals or secrets are accessed.</p><button id="greet" type="button">Show greeting</button><p id="result" role="status" aria-live="polite"></p></main><script src="theme.js"></script><script src="panel.js"></script></body></html>
|
||||
@@ -0,0 +1,24 @@
|
||||
"use strict";
|
||||
/* global document, window */
|
||||
// This page owns its DOM. Only the host's public semantic colors are copied.
|
||||
(() => {
|
||||
const names = ["--background", "--foreground", "--muted", "--muted-foreground", "--border", "--primary", "--primary-foreground"];
|
||||
let subscription;
|
||||
const subscribe = () => {
|
||||
if (subscription) return;
|
||||
subscription = window.netcattyView.onDidChangeEnvironment((environment) => {
|
||||
const root = document.documentElement;
|
||||
root.style.colorScheme = environment.theme === "system" ? "light dark" : environment.theme;
|
||||
root.dataset.reducedMotion = String(Boolean(environment.reducedMotion));
|
||||
root.dataset.highContrast = String(Boolean(environment.highContrast));
|
||||
for (const name of names) {
|
||||
const value = environment.themeTokens?.[name];
|
||||
if (typeof value === "string" && value.length <= 96) root.style.setProperty(name, value);
|
||||
else root.style.removeProperty(name);
|
||||
}
|
||||
});
|
||||
};
|
||||
subscribe();
|
||||
window.addEventListener("pagehide", () => { subscription?.dispose(); subscription = undefined; });
|
||||
window.addEventListener("pageshow", subscribe);
|
||||
})();
|
||||
@@ -25,3 +25,5 @@ modal or overlapping menu must hide the native view while it is present.
|
||||
|
||||
This source example is not independent/native acceptance evidence. Parent QA
|
||||
records the precise tested commit and package hash separately.
|
||||
|
||||
The page subscribes to `window.netcattyView.onDidChangeEnvironment()` in `theme.js`. It follows the seven public HSL theme variables, including accent changes, without reading host settings or DOM. The subscription is disposed on page hide.
|
||||
@@ -3,7 +3,7 @@
|
||||
"id": "com.netcatty.vault-scratchpad",
|
||||
"name": "vault-scratchpad",
|
||||
"displayName": { "en": "Vault Scratchpad", "zh-CN": "Vault 草稿板" },
|
||||
"version": "0.1.0",
|
||||
"version": "0.1.1",
|
||||
"publisher": "netcatty",
|
||||
"license": "GPL-3.0-or-later",
|
||||
"engines": { "netcatty": ">=0.0.0", "api": ">=0.1.0-internal.1 <0.2.0" },
|
||||
|
||||
@@ -1,5 +1,20 @@
|
||||
:root { color-scheme: light dark; font-family: system-ui, sans-serif; }
|
||||
body { margin: 0; padding: 24px; } main { max-width: 720px; margin: auto; }
|
||||
textarea { display: block; box-sizing: border-box; width: 100%; margin: 12px 0; padding: 10px; font: inherit; }
|
||||
button { padding: 8px 12px; margin: 0 8px 0 0; font: inherit; }
|
||||
#status { min-height: 1.5em; }
|
||||
:root { color-scheme: light dark; font: 14px/1.6 system-ui, sans-serif; }
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; padding: 24px; background: hsl(var(--background, 0 0% 100%)); color: hsl(var(--foreground, 222 47% 12%)); }
|
||||
main { min-width: 0; overflow-wrap: anywhere; max-width: 720px; margin: auto; }
|
||||
h1 { margin: 0 0 8px; font-size: 22px; line-height: 1.3; letter-spacing: -.02em; }
|
||||
p { color: hsl(var(--muted-foreground, 215 20% 35%)); }
|
||||
label { display: block; font-weight: 600; }
|
||||
textarea { display: block; width: 100%; min-width: 0; resize: vertical; min-height: 140px; margin: 10px 0 16px; padding: 12px; border: 1px solid hsl(var(--border, 215 20% 75%)); border-radius: 10px; background: hsl(var(--muted, 210 20% 96%)); color: inherit; font: inherit; }
|
||||
button { padding: 8px 14px; margin: 0 8px 8px 0; border: 1px solid hsl(var(--border, 215 20% 75%)); border-radius: 8px; background: hsl(var(--background, 0 0% 100%)); color: inherit; font: inherit; font-weight: 500; cursor: pointer; }
|
||||
#save, #greet { border-color: transparent; background: hsl(var(--primary, 221 83% 45%)); color: hsl(var(--primary-foreground, 0 0% 100%)); }
|
||||
button:hover:not(:disabled) { filter: brightness(.96); }
|
||||
button:disabled { opacity: .5; cursor: default; }
|
||||
button:focus-visible, textarea:focus-visible { outline: 2px solid hsl(var(--primary, 221 83% 45%)); outline-offset: 3px; }
|
||||
#status { min-height: 1.5em; font-size: 12px; }
|
||||
#result { white-space: pre-wrap; overflow-wrap: anywhere; padding: 16px; border-radius: 10px; background: hsl(var(--muted, 210 20% 96%)); min-height: 48px; }
|
||||
.eyebrow { color: hsl(var(--muted-foreground, 215 20% 35%)); font-size: 11px; letter-spacing: .1em; }
|
||||
@media (max-width: 360px) { body { padding: 16px; } }
|
||||
@media (forced-colors: active) { button, textarea { border: 1px solid ButtonText; } }
|
||||
|
||||
@media (prefers-color-scheme: dark) { :root { --background: 221 39% 11%; --foreground: 210 20% 98%; --muted: 219 27% 21%; --muted-foreground: 220 13% 91%; --border: 218 11% 65%; --primary: 213 97% 87%; --primary-foreground: 221 39% 11%; } }
|
||||
@@ -4,4 +4,4 @@
|
||||
<body><main><h1>Scratchpad</h1><p>This page stores only text you type here, in this plugin's own view state. It cannot read Vault hosts, notes, credentials, sessions or the clipboard.</p>
|
||||
<label for="draft">Synthetic draft</label><textarea id="draft" maxlength="4096" rows="8" disabled></textarea>
|
||||
<button type="button" id="save" disabled>Save draft</button><button type="button" id="clear" disabled>Clear draft</button>
|
||||
<p id="status" role="status">Loading your plugin draft…</p></main><script src="page.js"></script></body></html>
|
||||
<p id="status" role="status">Loading your plugin draft…</p></main><script src="theme.js"></script><script src="page.js"></script></body></html>
|
||||
@@ -0,0 +1,24 @@
|
||||
"use strict";
|
||||
/* global document, window */
|
||||
// This page owns its DOM. Only the host's public semantic colors are copied.
|
||||
(() => {
|
||||
const names = ["--background", "--foreground", "--muted", "--muted-foreground", "--border", "--primary", "--primary-foreground"];
|
||||
let subscription;
|
||||
const subscribe = () => {
|
||||
if (subscription) return;
|
||||
subscription = window.netcattyView.onDidChangeEnvironment((environment) => {
|
||||
const root = document.documentElement;
|
||||
root.style.colorScheme = environment.theme === "system" ? "light dark" : environment.theme;
|
||||
root.dataset.reducedMotion = String(Boolean(environment.reducedMotion));
|
||||
root.dataset.highContrast = String(Boolean(environment.highContrast));
|
||||
for (const name of names) {
|
||||
const value = environment.themeTokens?.[name];
|
||||
if (typeof value === "string" && value.length <= 96) root.style.setProperty(name, value);
|
||||
else root.style.removeProperty(name);
|
||||
}
|
||||
});
|
||||
};
|
||||
subscribe();
|
||||
window.addEventListener("pagehide", () => { subscription?.dispose(); subscription = undefined; });
|
||||
window.addEventListener("pageshow", subscribe);
|
||||
})();
|
||||
Vendored
+5
@@ -454,6 +454,9 @@ declare global {
|
||||
setPluginEnabled?(pluginId: string, enabled: boolean): Promise<NetcattyInstalledPlugin>;
|
||||
restartPlugin?(pluginId: string): Promise<NetcattyInstalledPlugin>;
|
||||
uninstallPlugin?(pluginId: string): Promise<boolean>;
|
||||
getPluginThemes?(): Promise<import("@netcatty/plugin-contract").ThemePackCatalog>;
|
||||
applyPluginTheme?(request: import("@netcatty/plugin-contract").ThemePackApplyRequest): Promise<import("@netcatty/plugin-contract").ThemePackCatalog>;
|
||||
onPluginThemesChanged?(callback: () => void): () => void;
|
||||
getPluginContributions?(options?: NetcattyPluginContributionQuery): Promise<NetcattyPluginContributionSnapshot>;
|
||||
getPluginBrandIcon?(pluginId: string): Promise<{ light: string; dark?: string } | null>;
|
||||
getPluginContributionIcon?(pluginId: string, icon: Extract<NetcattyPluginIconReference, { kind: 'package' }>): Promise<{ light: string; dark?: string }>;
|
||||
@@ -581,10 +584,12 @@ declare global {
|
||||
closePluginView?(instanceId: string): Promise<void>;
|
||||
setPluginViewBounds?(instanceId: string, bounds: { x: number; y: number; width: number; height: number }): Promise<void>;
|
||||
setPluginViewVisibility?(instanceId: string, visible: boolean): Promise<void>;
|
||||
focusPluginView?(payload: import('@netcatty/plugin-contract').PluginViewFocusRequest): Promise<void>;
|
||||
postPluginViewMessage?(instanceId: string, message: unknown): Promise<void>;
|
||||
onPluginContributionsChanged?(callback: (event: { reason: string; pluginId: string | null; revision: number }) => void): () => void;
|
||||
onPluginViewMessage?(callback: (event: { pluginId: string; viewId: string; message: unknown }) => void): () => void;
|
||||
onPluginViewClosed?(callback: (event: NetcattyPluginViewClosedEvent) => void): () => void;
|
||||
onPluginViewFocused?(callback: (event: import('@netcatty/plugin-contract').PluginViewFocusEvent) => void): () => void;
|
||||
getPluginScopeCatalog?(): Promise<NetcattyPluginScopeCatalog>;
|
||||
setPluginScopeCatalog?(catalog: NetcattyPluginScopeCatalog): Promise<void>;
|
||||
onPluginScopeCatalogChanged?(callback: (catalog: NetcattyPluginScopeCatalog) => void): () => void;
|
||||
|
||||
@@ -2199,3 +2199,49 @@ html[data-theme-transition] *::after {
|
||||
@container monitor-main (max-width: 760px) {
|
||||
.monitor-header-action-label { display: none; }
|
||||
}
|
||||
|
||||
/* Host-owned security surfaces never inherit third-party theme colors. */
|
||||
:root[data-plugin-theme-active] :is([role="dialog"], [role="alertdialog"], [data-app-lock-overlay], [data-host-security-surface], [data-section="host-details-panel"], [data-section="keychain-security-panel"], [data-testid="password-credential-picker"], [data-port-forward-host-key-tray-prompt]) {
|
||||
--background: var(--host-security-background) !important;
|
||||
--foreground: var(--host-security-foreground) !important;
|
||||
--card: var(--host-security-card) !important;
|
||||
--card-foreground: var(--host-security-card-foreground) !important;
|
||||
--popover: var(--host-security-popover) !important;
|
||||
--popover-foreground: var(--host-security-popover-foreground) !important;
|
||||
--primary: var(--host-security-primary) !important;
|
||||
--primary-foreground: var(--host-security-primary-foreground) !important;
|
||||
--secondary: var(--host-security-secondary) !important;
|
||||
--secondary-foreground: var(--host-security-secondary-foreground) !important;
|
||||
--muted: var(--host-security-muted) !important;
|
||||
--muted-foreground: var(--host-security-muted-foreground) !important;
|
||||
--accent: var(--host-security-accent) !important;
|
||||
--accent-foreground: var(--host-security-accent-foreground) !important;
|
||||
--destructive: var(--host-security-destructive) !important;
|
||||
--destructive-foreground: var(--host-security-destructive-foreground) !important;
|
||||
--border: var(--host-security-border) !important;
|
||||
--input: var(--host-security-input) !important;
|
||||
--ring: var(--host-security-ring) !important;
|
||||
/* Tailwind theme aliases resolve where declared; pin them in this scope too. */
|
||||
--color-background: hsl(var(--host-security-background)) !important;
|
||||
--color-foreground: hsl(var(--host-security-foreground)) !important;
|
||||
--color-card: hsl(var(--host-security-card)) !important;
|
||||
--color-card-foreground: hsl(var(--host-security-card-foreground)) !important;
|
||||
--color-popover: hsl(var(--host-security-popover)) !important;
|
||||
--color-popover-foreground: hsl(var(--host-security-popover-foreground)) !important;
|
||||
--color-primary: hsl(var(--host-security-primary)) !important;
|
||||
--color-primary-foreground: hsl(var(--host-security-primary-foreground)) !important;
|
||||
--color-secondary: hsl(var(--host-security-secondary)) !important;
|
||||
--color-secondary-foreground: hsl(var(--host-security-secondary-foreground)) !important;
|
||||
--color-muted: hsl(var(--host-security-muted)) !important;
|
||||
--color-muted-foreground: hsl(var(--host-security-muted-foreground)) !important;
|
||||
--color-accent: hsl(var(--host-security-accent)) !important;
|
||||
--color-accent-foreground: hsl(var(--host-security-accent-foreground)) !important;
|
||||
--color-destructive: hsl(var(--host-security-destructive)) !important;
|
||||
--color-destructive-foreground: hsl(var(--host-security-destructive-foreground)) !important;
|
||||
--color-border: hsl(var(--host-security-border)) !important;
|
||||
--color-input: hsl(var(--host-security-input)) !important;
|
||||
--color-ring: hsl(var(--host-security-ring)) !important;
|
||||
color: hsl(var(--foreground)) !important;
|
||||
background-color: hsl(var(--background)) !important;
|
||||
border-color: hsl(var(--border)) !important;
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import type {
|
||||
ThemePackCatalog,
|
||||
ThemePackEntry,
|
||||
} from "@netcatty/plugin-contract";
|
||||
import type { UiThemePreset, UiThemeTokens } from "./uiThemes";
|
||||
import themeColors from "@/domain/pluginThemePack.cjs";
|
||||
const colors = themeColors as unknown as {
|
||||
resolveThemePalette(palette: ThemePackEntry["palette"]): UiThemeTokens;
|
||||
securityThemeTokens(mode: "light" | "dark", hc?: boolean): UiThemeTokens;
|
||||
};
|
||||
let catalog: ThemePackCatalog = {
|
||||
selection: { formatVersion: 1, revision: 0, light: null, dark: null },
|
||||
entries: [],
|
||||
diagnostics: [],
|
||||
};
|
||||
let revision = 0,
|
||||
highContrast = false;
|
||||
let preview: { owner: string; entry: ThemePackEntry; revision: number } | null =
|
||||
null;
|
||||
const listeners = new Set<() => void>();
|
||||
const refKey = (ref: ThemePackEntry["ref"]) =>
|
||||
JSON.stringify([
|
||||
ref.pluginId,
|
||||
ref.themeId,
|
||||
ref.version,
|
||||
ref.digest,
|
||||
ref.variant,
|
||||
]);
|
||||
const emit = () => {
|
||||
revision++;
|
||||
for (const listener of listeners) listener();
|
||||
};
|
||||
export const pluginThemeRegistry = {
|
||||
subscribe(listener: () => void) {
|
||||
listeners.add(listener);
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
},
|
||||
getRevision: () => revision,
|
||||
getCatalog: () => catalog,
|
||||
invalidate() {
|
||||
preview = null;
|
||||
catalog = { ...catalog, entries: [] };
|
||||
emit();
|
||||
},
|
||||
setCatalog(next: ThemePackCatalog) {
|
||||
if (next.selection.revision < catalog.selection.revision) return;
|
||||
catalog = next;
|
||||
if (
|
||||
preview &&
|
||||
(preview.revision !== next.selection.revision ||
|
||||
!next.entries.some((e) => refKey(e.ref) === refKey(preview!.entry.ref)))
|
||||
)
|
||||
preview = null;
|
||||
emit();
|
||||
},
|
||||
setHighContrast(value: boolean) {
|
||||
if (value !== highContrast) {
|
||||
highContrast = value;
|
||||
emit();
|
||||
}
|
||||
},
|
||||
isHighContrast: () => highContrast,
|
||||
startPreview(owner: string, entry: ThemePackEntry) {
|
||||
if (!catalog.entries.some((e) => refKey(e.ref) === refKey(entry.ref)))
|
||||
throw new Error("Theme unavailable");
|
||||
preview = { owner, entry, revision: catalog.selection.revision };
|
||||
emit();
|
||||
},
|
||||
cancelPreview(owner: string) {
|
||||
if (preview?.owner === owner) {
|
||||
preview = null;
|
||||
emit();
|
||||
}
|
||||
},
|
||||
isActive(mode: "light" | "dark") {
|
||||
return Boolean(
|
||||
(preview?.entry.palette.base === mode ? preview : null) ||
|
||||
catalog.selection[mode],
|
||||
);
|
||||
},
|
||||
getTheme(
|
||||
mode: "light" | "dark",
|
||||
includePreview = true,
|
||||
): UiThemePreset | null {
|
||||
const selected = catalog.selection[mode];
|
||||
const entry =
|
||||
includePreview && preview?.entry.palette.base === mode
|
||||
? preview.entry
|
||||
: selected
|
||||
? catalog.entries.find((e) => refKey(e.ref) === refKey(selected.ref))
|
||||
: null;
|
||||
// Forced/high contrast always uses a fixed host palette. No downgrade to an ordinary pack.
|
||||
if (highContrast && (entry || selected))
|
||||
return {
|
||||
id: "host-high-contrast",
|
||||
name: "High contrast",
|
||||
tokens: colors.securityThemeTokens(mode, true),
|
||||
};
|
||||
if (!entry)
|
||||
return selected
|
||||
? {
|
||||
id: "host-plugin-fallback",
|
||||
name: "Default",
|
||||
tokens: colors.securityThemeTokens(mode),
|
||||
}
|
||||
: null;
|
||||
return {
|
||||
id: `plugin:${entry.ref.pluginId}:${entry.ref.themeId}`,
|
||||
name: entry.label,
|
||||
tokens: colors.resolveThemePalette(entry.palette),
|
||||
};
|
||||
},
|
||||
securityTokens: (mode: "light" | "dark") =>
|
||||
colors.securityThemeTokens(mode, highContrast),
|
||||
};
|
||||
@@ -1,3 +1,4 @@
|
||||
import { pluginThemeRegistry } from "./pluginThemeRegistry";
|
||||
export type UiThemeTokens = {
|
||||
background: string;
|
||||
foreground: string;
|
||||
@@ -3156,7 +3157,9 @@ export const DARK_UI_THEMES: UiThemePreset[] = [
|
||||
...EXTENDED_DARK_UI_THEMES,
|
||||
];
|
||||
|
||||
export const getUiThemeById = (theme: "light" | "dark", id: string): UiThemePreset => {
|
||||
export const getUiThemeById = (theme: "light" | "dark", id: string, includePreview = true): UiThemePreset => {
|
||||
const pluginTheme = pluginThemeRegistry.getTheme(theme, includePreview);
|
||||
if (pluginTheme) return pluginTheme;
|
||||
const list = theme === "dark" ? DARK_UI_THEMES : LIGHT_UI_THEMES;
|
||||
const found = list.find((preset) => preset.id === id);
|
||||
return found || list[0];
|
||||
|
||||
Generated
+1
-1
@@ -141,7 +141,7 @@
|
||||
},
|
||||
"examples/plugins/starter-netcatty": {
|
||||
"name": "@netcatty/example-starter-plugin",
|
||||
"version": "0.1.2",
|
||||
"version": "0.1.3",
|
||||
"dependencies": {
|
||||
"@netcatty/plugin-sdk": "0.1.0-internal.1"
|
||||
}
|
||||
|
||||
+1
-1
@@ -58,7 +58,7 @@
|
||||
"test": "node --test --import tsx electron/*.test.cjs electron/preload/*.test.cjs electron/mcp/*.test.cjs electron/shared/*Privacy*.test.cjs electron/scripts/*.test.cjs electron/terminalWorker/*.test.cjs electron/capabilities/*.test.cjs electron/capabilities/*/*.test.cjs electron/capabilities/*/*/*.test.cjs electron/cli/*.test.cjs electron/hostBrowser/*Store.test.cjs electron/hostBrowser/hostBrowserManager.test.cjs electron/bridges/*.test.cjs electron/bridges/*/*.test.cjs electron/bridges/aiBridge/codexAppServer/*.test.cjs electron/bridges/aiBridge/sdk/*.test.cjs scripts/*.test.cjs application/*.test.ts application/app/*.test.ts application/i18n/locales/*.test.ts application/state/*.test.ts application/state/*/*.test.ts components/*.test.ts components/*.test.tsx components/editor/*.test.ts components/editor/*.test.tsx components/notes/*.test.ts components/notes/*.test.tsx components/port-forwarding/*.test.ts components/scripts/*.test.tsx components/systemManager/*.test.ts components/terminalLayer/*.test.ts components/settings/*.test.tsx components/settings/tabs/*.test.ts components/settings/tabs/ai/*.test.ts components/settings/tabs/ai/*.test.tsx components/ai/*.test.ts components/ai/*.test.tsx components/ai/toolArtifacts/*.test.ts components/ai/toolArtifacts/*.test.tsx components/ai-elements/*.test.tsx components/sftp/*.test.ts components/terminal/*.test.ts components/terminal/*.test.tsx components/terminal/runtime/*.test.ts components/vault/*.test.ts components/vault/*.test.tsx domain/*.test.ts domain/*/*.test.ts domain/*/*.test.cjs infrastructure/ai/*.test.ts infrastructure/ai/*/*.test.ts infrastructure/ai/sdk/*.test.ts infrastructure/config/*.test.ts infrastructure/monaco/*.test.ts infrastructure/persistence/*.test.ts infrastructure/services/*.test.ts infrastructure/services/*/*.test.ts lib/*.test.ts packages/*/src/*.test.ts",
|
||||
"test:plugin-contract": "node --test --import tsx packages/*/src/*.test.ts",
|
||||
"pretest:plugin-runtime": "npm run build:plugin-runtime-deps && npm run build --workspace @netcatty/example-starter-plugin",
|
||||
"test:plugin-runtime": "node --test electron/plugins/*.test.cjs domain/pluginRuntimePolicy.test.cjs domain/pluginPermissionDescriptions.test.cjs",
|
||||
"test:plugin-runtime": "node --test electron/plugins/*.test.cjs domain/pluginRuntimePolicy.test.cjs domain/pluginPermissionDescriptions.test.cjs domain/pluginThemePack.test.cjs",
|
||||
"pretest:plugin-runtime:electron": "npm run build:plugin-runtime-deps",
|
||||
"test:plugin-runtime:electron": "electron electron/plugins/electronRuntime.live.test.cjs",
|
||||
"test:sync-crdt": "node --test --import tsx domain/convergentSync/*.test.ts",
|
||||
|
||||
@@ -297,6 +297,7 @@ async function scanPackageDirectory(
|
||||
manifest.main.browser,
|
||||
manifest.main.node,
|
||||
...(manifest.contributes?.views ?? []).map(({ entry }) => entry),
|
||||
...(manifest.contributes?.themes ?? []).map(({ entry }) => entry),
|
||||
...(manifest.contributes?.commands ?? []).flatMap(({ icon }) => packageIconPaths(icon)),
|
||||
...(manifest.contributes?.menus ?? []).flatMap(({ icon }) => packageIconPaths(icon)),
|
||||
...(manifest.contributes?.views ?? []).flatMap(({ icon }) => packageIconPaths(icon)),
|
||||
@@ -754,6 +755,7 @@ async function inspectPluginPackage(
|
||||
manifest.main.browser,
|
||||
manifest.main.node,
|
||||
...(manifest.contributes?.views ?? []).map(({ entry }) => entry),
|
||||
...(manifest.contributes?.themes ?? []).map(({ entry }) => entry),
|
||||
...(manifest.contributes?.commands ?? []).flatMap(({ icon }) => packageIconPaths(icon)),
|
||||
...(manifest.contributes?.menus ?? []).flatMap(({ icon }) => packageIconPaths(icon)),
|
||||
...(manifest.contributes?.views ?? []).flatMap(({ icon }) => packageIconPaths(icon)),
|
||||
|
||||
@@ -310,6 +310,7 @@ function findDuplicateIds(manifest: PluginManifest): string[] {
|
||||
["settings", manifest.contributes?.settings],
|
||||
["commands", manifest.contributes?.commands],
|
||||
["views", manifest.contributes?.views],
|
||||
["themes", manifest.contributes?.themes],
|
||||
["providers", manifest.contributes?.providers],
|
||||
["companionExecutables", manifest.companionExecutables],
|
||||
] as const;
|
||||
@@ -336,6 +337,7 @@ function validateOwnedContributionIds(manifest: PluginManifest): string[] {
|
||||
["setting", manifest.contributes?.settings],
|
||||
["command", manifest.contributes?.commands],
|
||||
["view", manifest.contributes?.views],
|
||||
["theme", manifest.contributes?.themes],
|
||||
["provider", manifest.contributes?.providers],
|
||||
["companion executable", manifest.companionExecutables],
|
||||
] as const;
|
||||
@@ -367,6 +369,10 @@ function validateSemantics(manifest: PluginManifest): string[] {
|
||||
}
|
||||
|
||||
const requiredFeatures = new Set(manifest.features?.required ?? []);
|
||||
if ((manifest.main.data || manifest.contributes?.themes?.length) && !requiredFeatures.has('appearance.theme-packs')) errors.push('Theme packs require appearance.theme-packs');
|
||||
for (const theme of manifest.contributes?.themes ?? []) if (!theme.entry.endsWith('.json')) errors.push('Theme palette entry must be JSON');
|
||||
if (manifest.contributes?.views?.some((view) => view.location === 'terminal-sidebar')
|
||||
&& !requiredFeatures.has('views.terminal-sidebar')) errors.push('Terminal sidebar pages require views.terminal-sidebar');
|
||||
if (manifest.contributes?.views?.some((view) => view.location === 'vault-page')
|
||||
&& !requiredFeatures.has('views.vault-pages')) {
|
||||
errors.push('Vault pages require views.vault-pages');
|
||||
@@ -689,6 +695,7 @@ function validateSemantics(manifest: PluginManifest): string[] {
|
||||
manifest.main.browser,
|
||||
manifest.main.node,
|
||||
...(manifest.contributes?.views ?? []).map(({ entry }) => entry),
|
||||
...(manifest.contributes?.themes ?? []).map(({ entry }) => entry),
|
||||
...(manifest.contributes?.commands ?? []).flatMap(({ icon }) => packageIconPaths(icon)),
|
||||
...(manifest.contributes?.menus ?? []).flatMap(({ icon }) => packageIconPaths(icon)),
|
||||
...(manifest.contributes?.views ?? []).flatMap(({ icon }) => packageIconPaths(icon)),
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { validateManifestValue } from './manifest.js';
|
||||
import { checkPluginCompatibility } from './compatibility.js';
|
||||
const manifest = JSON.parse(await readFile(new URL('../../../examples/plugins/session-scratchpad/netcatty.plugin.json', import.meta.url), 'utf8'));
|
||||
test('terminal sidebar pages require views and their exact feature without session data permissions', () => {
|
||||
assert.equal(validateManifestValue(manifest).valid, true);
|
||||
assert.equal(validateManifestValue({ ...manifest, features: {} }).valid, false);
|
||||
assert.equal(validateManifestValue({ ...manifest, permissions: {} }).valid, false);
|
||||
assert.equal(checkPluginCompatibility(manifest, { netcattyVersion: '1.0.0', features: ['views.vault-pages'] }).compatible, false);
|
||||
assert.equal(checkPluginCompatibility(manifest, { netcattyVersion: '1.0.0', features: ['views.terminal-sidebar'] }).compatible, true);
|
||||
assert.deepEqual(manifest.permissions.required, ['views']);
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import test from "node:test";
|
||||
import { validateManifestValue } from "./manifest.js";
|
||||
import { checkPluginCompatibility } from "./compatibility.js";
|
||||
const manifest = JSON.parse(
|
||||
await readFile(
|
||||
new URL(
|
||||
"../../../examples/plugins/forest-theme-pack/netcatty.plugin.json",
|
||||
import.meta.url,
|
||||
),
|
||||
"utf8",
|
||||
),
|
||||
);
|
||||
test("static theme admission requires explicit feature, namespaces, JSON entries and no executable or permission mixture", () => {
|
||||
assert.equal(validateManifestValue(manifest).valid, true);
|
||||
assert.equal(
|
||||
checkPluginCompatibility(manifest, {
|
||||
netcattyVersion: "1.0.0",
|
||||
apiVersion: "0.1.0-internal.1",
|
||||
features: [],
|
||||
}).compatible,
|
||||
false,
|
||||
);
|
||||
for (const change of [
|
||||
{ main: { data: "theme", browser: "index.js" } },
|
||||
{ permissions: { required: ["network"] } },
|
||||
{ activationEvents: ["onStartupFinished"] },
|
||||
{
|
||||
contributes: {
|
||||
...manifest.contributes,
|
||||
commands: [{ id: manifest.id + ".run", title: "Run" }],
|
||||
},
|
||||
},
|
||||
{ features: { required: [] } },
|
||||
{
|
||||
contributes: {
|
||||
themes: [
|
||||
{ ...manifest.contributes.themes[0], id: "another.plugin.theme" },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
contributes: {
|
||||
themes: [{ ...manifest.contributes.themes[0], entry: "theme.css" }],
|
||||
},
|
||||
},
|
||||
])
|
||||
assert.equal(
|
||||
validateManifestValue({ ...manifest, ...change }).valid,
|
||||
false,
|
||||
JSON.stringify(change),
|
||||
);
|
||||
});
|
||||
@@ -793,11 +793,32 @@
|
||||
"PluginEntrypoints": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"browser": { "$ref": "#/$defs/RelativePackagePath" },
|
||||
"node": { "$ref": "#/$defs/RelativePackagePath" }
|
||||
"browser": {
|
||||
"$ref": "#/$defs/RelativePackagePath"
|
||||
},
|
||||
"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",
|
||||
@@ -1005,11 +1026,21 @@
|
||||
},
|
||||
"ViewLocation": {
|
||||
"type": "string",
|
||||
"enum": ["aside", "panel", "tab", "modal", "settings", "vault-page"]
|
||||
"enum": ["aside", "panel", "tab", "modal", "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": ["id", "title", "location", "entry"],
|
||||
@@ -1880,12 +1911,56 @@
|
||||
"PluginContributions": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"settings": { "type": "array", "maxItems": 512, "items": { "$ref": "#/$defs/SettingContribution" } },
|
||||
"commands": { "type": "array", "maxItems": 512, "items": { "$ref": "#/$defs/CommandContribution" } },
|
||||
"keybindings": { "type": "array", "maxItems": 1024, "items": { "$ref": "#/$defs/KeybindingContribution" } },
|
||||
"menus": { "type": "array", "maxItems": 1024, "items": { "$ref": "#/$defs/MenuContribution" } },
|
||||
"views": { "type": "array", "maxItems": 128, "items": { "$ref": "#/$defs/ViewContribution" } },
|
||||
"providers": { "type": "array", "maxItems": 256, "items": { "$ref": "#/$defs/ProviderContribution" } }
|
||||
"themes": {
|
||||
"type": "array",
|
||||
"maxItems": 32,
|
||||
"items": {
|
||||
"$ref": "#/$defs/ThemePackContribution"
|
||||
},
|
||||
"minItems": 1
|
||||
},
|
||||
"settings": {
|
||||
"type": "array",
|
||||
"maxItems": 512,
|
||||
"items": {
|
||||
"$ref": "#/$defs/SettingContribution"
|
||||
}
|
||||
},
|
||||
"commands": {
|
||||
"type": "array",
|
||||
"maxItems": 512,
|
||||
"items": {
|
||||
"$ref": "#/$defs/CommandContribution"
|
||||
}
|
||||
},
|
||||
"keybindings": {
|
||||
"type": "array",
|
||||
"maxItems": 1024,
|
||||
"items": {
|
||||
"$ref": "#/$defs/KeybindingContribution"
|
||||
}
|
||||
},
|
||||
"menus": {
|
||||
"type": "array",
|
||||
"maxItems": 1024,
|
||||
"items": {
|
||||
"$ref": "#/$defs/MenuContribution"
|
||||
}
|
||||
},
|
||||
"views": {
|
||||
"type": "array",
|
||||
"maxItems": 128,
|
||||
"items": {
|
||||
"$ref": "#/$defs/ViewContribution"
|
||||
}
|
||||
},
|
||||
"providers": {
|
||||
"type": "array",
|
||||
"maxItems": 256,
|
||||
"items": {
|
||||
"$ref": "#/$defs/ProviderContribution"
|
||||
}
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
@@ -1903,38 +1978,136 @@
|
||||
},
|
||||
"PluginManifest": {
|
||||
"type": "object",
|
||||
"required": ["manifestVersion", "id", "name", "version", "publisher", "engines", "main"],
|
||||
"required": [
|
||||
"manifestVersion",
|
||||
"id",
|
||||
"name",
|
||||
"version",
|
||||
"publisher",
|
||||
"engines",
|
||||
"main"
|
||||
],
|
||||
"properties": {
|
||||
"$schema": { "type": "string", "format": "uri-reference" },
|
||||
"manifestVersion": { "const": 1 },
|
||||
"id": { "$ref": "#/$defs/PluginId" },
|
||||
"name": { "type": "string", "minLength": 1, "maxLength": 128 },
|
||||
"displayName": { "$ref": "#/$defs/LocalizedText" },
|
||||
"description": { "$ref": "#/$defs/LocalizedText" },
|
||||
"version": { "$ref": "#/$defs/SemanticVersion" },
|
||||
"publisher": { "type": "string", "minLength": 1, "maxLength": 128 },
|
||||
"license": { "type": "string", "maxLength": 128 },
|
||||
"icon": { "$ref": "#/$defs/IconReference" },
|
||||
"homepage": { "type": "string", "format": "uri" },
|
||||
"repository": { "type": "string", "format": "uri" },
|
||||
"engines": { "$ref": "#/$defs/PluginEngines" },
|
||||
"features": { "$ref": "#/$defs/PluginFeatures" },
|
||||
"main": { "$ref": "#/$defs/PluginEntrypoints" },
|
||||
"$schema": {
|
||||
"type": "string",
|
||||
"format": "uri-reference"
|
||||
},
|
||||
"manifestVersion": {
|
||||
"const": 1
|
||||
},
|
||||
"id": {
|
||||
"$ref": "#/$defs/PluginId"
|
||||
},
|
||||
"name": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 128
|
||||
},
|
||||
"displayName": {
|
||||
"$ref": "#/$defs/LocalizedText"
|
||||
},
|
||||
"description": {
|
||||
"$ref": "#/$defs/LocalizedText"
|
||||
},
|
||||
"version": {
|
||||
"$ref": "#/$defs/SemanticVersion"
|
||||
},
|
||||
"publisher": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 128
|
||||
},
|
||||
"license": {
|
||||
"type": "string",
|
||||
"maxLength": 128
|
||||
},
|
||||
"icon": {
|
||||
"$ref": "#/$defs/IconReference"
|
||||
},
|
||||
"homepage": {
|
||||
"type": "string",
|
||||
"format": "uri"
|
||||
},
|
||||
"repository": {
|
||||
"type": "string",
|
||||
"format": "uri"
|
||||
},
|
||||
"engines": {
|
||||
"$ref": "#/$defs/PluginEngines"
|
||||
},
|
||||
"features": {
|
||||
"$ref": "#/$defs/PluginFeatures"
|
||||
},
|
||||
"main": {
|
||||
"$ref": "#/$defs/PluginEntrypoints"
|
||||
},
|
||||
"activationEvents": {
|
||||
"type": "array",
|
||||
"maxItems": 256,
|
||||
"uniqueItems": true,
|
||||
"items": { "$ref": "#/$defs/ActivationEvent" }
|
||||
"items": {
|
||||
"$ref": "#/$defs/ActivationEvent"
|
||||
}
|
||||
},
|
||||
"permissions": {
|
||||
"$ref": "#/$defs/PermissionSet"
|
||||
},
|
||||
"contributes": {
|
||||
"$ref": "#/$defs/PluginContributions"
|
||||
},
|
||||
"permissions": { "$ref": "#/$defs/PermissionSet" },
|
||||
"contributes": { "$ref": "#/$defs/PluginContributions" },
|
||||
"companionExecutables": {
|
||||
"type": "array",
|
||||
"maxItems": 32,
|
||||
"items": { "$ref": "#/$defs/CompanionExecutable" }
|
||||
"items": {
|
||||
"$ref": "#/$defs/CompanionExecutable"
|
||||
}
|
||||
}
|
||||
},
|
||||
"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": [
|
||||
@@ -2563,6 +2736,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"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -488,6 +488,7 @@ export type PermissionSet = {
|
||||
};
|
||||
|
||||
export type PluginContributions = {
|
||||
themes?: Array<ThemePackContribution>;
|
||||
settings?: Array<SettingContribution>;
|
||||
commands?: Array<CommandContribution>;
|
||||
keybindings?: Array<KeybindingContribution>;
|
||||
@@ -509,6 +510,7 @@ export type PluginEngines = {
|
||||
export type PluginEntrypoints = {
|
||||
browser?: RelativePackagePath;
|
||||
node?: RelativePackagePath;
|
||||
data?: "theme";
|
||||
};
|
||||
|
||||
export type PluginErrorData = {
|
||||
@@ -587,6 +589,12 @@ export type PluginNetworkResponse = {
|
||||
|
||||
export type PluginPermission = "storage" | "runtime.advanced" | "settings.read" | "settings.write" | "commands" | "menus" | "views" | "ui.interact" | "clipboard.read" | "clipboard.write" | "terminal.metadata" | "terminal.output" | "terminal.input" | "terminal.decorate" | "terminal.complete" | "terminal.intercept.input" | "terminal.intercept.output" | "vault.metadata" | "vault.write" | "vault.credentials" | "sftp.read" | "sftp.write" | "network" | "filesystem.read" | "filesystem.write" | "secrets" | "companion.execute" | "provider.terminal" | "provider.connection" | "provider.authentication" | "provider.sync" | "provider.importer";
|
||||
|
||||
export type PluginViewFocusEvent = {
|
||||
instanceId: string;
|
||||
};
|
||||
|
||||
export type PluginViewFocusRequest = PluginViewFocusEvent;
|
||||
|
||||
export type PluginWireErrorCode = -32001 | -32002 | -32003 | -32004 | -32005 | -32006 | -32007 | -32008 | -32009 | -32010 | -32011 | -32012 | -32013 | -32014 | -32015 | -32016;
|
||||
|
||||
export type ProgressBegin = {
|
||||
@@ -1035,11 +1043,93 @@ export type TerminalSessionSnapshot = {
|
||||
alternateScreen?: boolean;
|
||||
};
|
||||
|
||||
export type TerminalSidebarFeature = "views.terminal-sidebar";
|
||||
|
||||
export type ThemeIcon = {
|
||||
kind: "theme";
|
||||
name: string;
|
||||
};
|
||||
|
||||
export type ThemePackApplyRequest = {
|
||||
expectedRevision: SafeUnsignedInteger;
|
||||
mode: "light" | "dark";
|
||||
choice: (ThemePackChoice) | (null);
|
||||
};
|
||||
|
||||
export type ThemePackCatalog = {
|
||||
selection: ThemePackSelection;
|
||||
entries: Array<ThemePackEntry>;
|
||||
diagnostics: Array<{
|
||||
pluginId: PluginId;
|
||||
themeId?: string;
|
||||
code: "invalid" | "unavailable" | "capacity";
|
||||
}>;
|
||||
};
|
||||
|
||||
export type ThemePackChoice = {
|
||||
ref: ThemePackReference;
|
||||
};
|
||||
|
||||
export type ThemePackColors = {
|
||||
background?: string;
|
||||
foreground?: string;
|
||||
card?: string;
|
||||
cardForeground?: string;
|
||||
popover?: string;
|
||||
popoverForeground?: string;
|
||||
secondary?: string;
|
||||
secondaryForeground?: string;
|
||||
muted?: string;
|
||||
mutedForeground?: string;
|
||||
accent?: string;
|
||||
destructive?: string;
|
||||
destructiveForeground?: string;
|
||||
border?: string;
|
||||
input?: string;
|
||||
};
|
||||
|
||||
export type ThemePackContribution = {
|
||||
id: ContributionId;
|
||||
label: LocalizedText;
|
||||
variant: ThemePackVariant;
|
||||
entry: RelativePackagePath;
|
||||
};
|
||||
|
||||
export type ThemePackEntry = {
|
||||
ref: ThemePackReference;
|
||||
label: string;
|
||||
pluginName: string;
|
||||
dataOnly: boolean;
|
||||
palette: ThemePackPalette;
|
||||
};
|
||||
|
||||
export type ThemePackFeature = "appearance.theme-packs";
|
||||
|
||||
export type ThemePackLimits = {"jsonBytes":65536,"catalogEntries":256,"themesPerPackage":32};
|
||||
|
||||
export type ThemePackPalette = {
|
||||
formatVersion: 1;
|
||||
base: "light" | "dark";
|
||||
colors: ThemePackColors;
|
||||
};
|
||||
|
||||
export type ThemePackReference = {
|
||||
pluginId: PluginId;
|
||||
themeId: ContributionId;
|
||||
version: SemanticVersion;
|
||||
digest: string;
|
||||
variant: ThemePackVariant;
|
||||
};
|
||||
|
||||
export type ThemePackSelection = {
|
||||
formatVersion: 1;
|
||||
revision: SafeUnsignedInteger;
|
||||
light: (ThemePackChoice) | (null);
|
||||
dark: (ThemePackChoice) | (null);
|
||||
};
|
||||
|
||||
export type ThemePackVariant = "light" | "dark";
|
||||
|
||||
export type VaultPagesFeature = "views.vault-pages";
|
||||
|
||||
export type ViewContribution = {
|
||||
@@ -1053,6 +1143,6 @@ export type ViewContribution = {
|
||||
retainContextWhenHidden?: boolean;
|
||||
};
|
||||
|
||||
export type ViewLocation = "aside" | "panel" | "tab" | "modal" | "settings" | "vault-page";
|
||||
export type ViewLocation = "aside" | "panel" | "tab" | "modal" | "settings" | "vault-page" | "terminal-sidebar";
|
||||
|
||||
export type WireIntegerLimits = {"maxSafeInteger":9007199254740991};
|
||||
@@ -56,4 +56,7 @@ export const PLUGIN_NETWORK_CREDENTIAL_LEASE_FEATURE = "network.credential-lease
|
||||
export const PLUGIN_HOST_INTERACTION_FEATURE = "host.interactions" as const satisfies import("./generated/plugin-contract.js").HostInteractionFeature;
|
||||
|
||||
export const PLUGIN_VAULT_PAGES_FEATURE = "views.vault-pages" as const satisfies import("./generated/plugin-contract.js").VaultPagesFeature;
|
||||
export const PLUGIN_TERMINAL_SIDEBAR_FEATURE = "views.terminal-sidebar" as const satisfies import("./generated/plugin-contract.js").TerminalSidebarFeature;
|
||||
export const PLUGIN_INTERACTIVE_COMMAND_FEATURE = "commands.interactive" as const satisfies import("./generated/plugin-contract.js").InteractiveCommandFeature;
|
||||
|
||||
export const PLUGIN_THEME_PACK_FEATURE = "appearance.theme-packs" as const satisfies import("./generated/plugin-contract.js").ThemePackFeature;
|
||||
Loaded 100 of 103 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user