feat(plugins): add validated data-only host color themes

This commit is contained in:
陈奇 committed 2026-10-26 11:46:08 +00:00
1 parent ffabb9e9c7
commit fdeeaf74bb
76 files changed
+2928 -90

No files matched your search

+2 -1
View File
@@ -167,7 +167,8 @@ function AppPluginKeybindingThemeHost({
sessions: any[];
workspaces: any[];
}) {
const appThemeStyle = useAppThemeStyle();
// View environment is global in the current host: publish committed colors only.
const appThemeStyle = useAppThemeStyle(false);
const { resolvedTheme, uiLanguage } = useSettingsChromeStore();
const pluginThemeTokens = useMemo(
() => selectPluginThemeTokens(appThemeStyle as Record<string, unknown>),
+6 -2
View File
@@ -1,3 +1,4 @@
import { usePluginThemeRevision } from '../state/usePluginThemeRuntime';
import type React from 'react';
import { useMemo } from 'react';
@@ -12,17 +13,20 @@ import { getUiThemeById } from '../../infrastructure/config/uiThemes';
* settingsChromeStore so accent drags only re-render the leaf that applies the
* vars, never the App shell.
*/
export function useAppThemeStyle(): React.CSSProperties {
export function useAppThemeStyle(includePreview = true): React.CSSProperties {
const pluginThemeRevision = usePluginThemeRevision();
const { accentMode, customAccent } = useAppearanceChromeStore();
const { resolvedTheme, darkUiThemeId, lightUiThemeId } = useSettingsChromeStore();
return useMemo(() => {
void pluginThemeRevision; // getUiThemeById reads this external registry revision.
const tokens = getUiThemeById(
resolvedTheme,
resolvedTheme === 'dark' ? darkUiThemeId : lightUiThemeId,
includePreview,
).tokens;
return {
...buildAppThemeCssVars(tokens, accentMode, customAccent),
colorScheme: resolvedTheme,
} as React.CSSProperties;
}, [accentMode, customAccent, darkUiThemeId, lightUiThemeId, resolvedTheme]);
}, [accentMode, customAccent, darkUiThemeId, lightUiThemeId, resolvedTheme, pluginThemeRevision, includePreview]);
}
+13
View File
@@ -1280,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.",
+13
View File
@@ -1280,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.",
+13
View File
@@ -1175,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 остаются на устройстве. Синхронизируются только явно отмеченные несекретные настройки в зашифрованных данных. Пакеты и разрешения не синхронизируются.",
+13
View File
@@ -1195,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。插件包和权限授权不参与同步。",
+13
View File
@@ -1198,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,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);
+8 -2
View File
@@ -1,3 +1,6 @@
import { pluginThemeRegistry } from '../../infrastructure/config/pluginThemeRegistry';
import { usePluginThemeRevision } from './usePluginThemeRuntime';
import { useSettingsChromeStore } from './settingsChromeStore';
import { useLayoutEffect, useRef } from "react";
import type { TerminalTheme } from "../../domain/models";
import {
@@ -271,12 +274,15 @@ export function useActiveChromeTheme({
activeTheme: TerminalTheme | null;
applyAppTheme: () => void;
}) {
const pluginThemeRevision = usePluginThemeRevision();
const { resolvedTheme } = useSettingsChromeStore();
const effectiveTheme = pluginThemeRegistry.isActive(resolvedTheme) ? null : activeTheme;
const applyAppThemeRef = useRef(applyAppTheme);
applyAppThemeRef.current = applyAppTheme;
useLayoutEffect(() => {
syncActiveChromeTheme(activeTheme, applyAppTheme);
}, [activeTheme, applyAppTheme]);
syncActiveChromeTheme(effectiveTheme, applyAppTheme);
}, [effectiveTheme, applyAppTheme, pluginThemeRevision]);
useLayoutEffect(() => {
return () => {
@@ -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.
}
+5 -2
View File
@@ -1,3 +1,4 @@
import { usePluginThemeRuntime } from './usePluginThemeRuntime';
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type SetStateAction } from 'react';
import { runThemeTransition, type ThemeTransitionMode } from './themeTransition';
@@ -217,6 +218,7 @@ import {
} from './appearanceSync';
export const useSettingsState = (options: { enableSettingsSync?: boolean; enableSystemEffects?: boolean } = {}) => {
const pluginThemeRevision = usePluginThemeRuntime();
const enableSettingsSync = options.enableSettingsSync !== false;
const enableSystemEffects = options.enableSystemEffects !== false;
const initialCustomKeyBindingsRecord =
@@ -1212,7 +1214,7 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
notifySettingsChanged(STORAGE_KEY_COLOR, customAccentRecord);
}
}
}, [theme, resolvedTheme, lightUiThemeId, darkUiThemeId, accentMode, customAccent, customAccentRecord, notifySettingsChanged]);
}, [theme, resolvedTheme, lightUiThemeId, darkUiThemeId, accentMode, customAccent, customAccentRecord, notifySettingsChanged, pluginThemeRevision]);
// Listen for OS color scheme changes to keep systemPreference in sync
useEffect(() => {
@@ -1979,9 +1981,10 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
}, [setTerminalSettings]);
const applyAppTheme = useCallback(() => {
void pluginThemeRevision; // Invalidate the closure when host theme data changes.
const tokens = getUiThemeById(resolvedTheme, resolvedTheme === 'dark' ? darkUiThemeId : lightUiThemeId).tokens;
applyThemeTokens(theme, resolvedTheme, tokens, accentMode, customAccent);
}, [theme, resolvedTheme, lightUiThemeId, darkUiThemeId, accentMode, customAccent]);
}, [theme, resolvedTheme, lightUiThemeId, darkUiThemeId, accentMode, customAccent, pluginThemeRevision]);
const setEditorWordWrap = useCallback((enabled: boolean) => {
setEditorWordWrapState(enabled);
+1
View File
@@ -882,6 +882,7 @@ echo $3 >> "$FILE"`);
{/* Slide-out Panel */}
{panel.type !== "closed" && (
<AsidePanel
dataSection="keychain-security-panel"
open={true}
onClose={closePanel}
title={panelTitle}
+6 -1
View File
@@ -1,3 +1,5 @@
import { useAppThemeStyle } from "../application/app/useAppThemeStyle";
import { selectPluginThemeTokens } from "../application/state/pluginContributionEnvironment";
/**
* Settings Page - Standalone settings window content
* This component is rendered in a separate Electron window
@@ -322,6 +324,9 @@ const SettingsSyncTabWithVault: React.FC<{ onSettingsApplied?: () => void }> = (
};
const SettingsPageContent: React.FC<{ settings: SettingsState; appLock?: AppLockState }> = ({ settings, appLock }) => {
// This publisher also runs in detached Settings: never broadcast a local preview.
const committedAppThemeStyle = useAppThemeStyle(false);
const pluginThemeTokens = useMemo(() => selectPluginThemeTokens(committedAppThemeStyle as Record<string, unknown>), [committedAppThemeStyle]);
const { t } = useI18n();
const { request, clearFocus, openSearch } = useSettingsFocus();
const { notifyRendererReady, closeSettingsWindow, onWindowCommandCloseRequested } = useWindowControls();
@@ -715,7 +720,7 @@ const SettingsPageContent: React.FC<{ settings: SettingsState; appLock?: AppLock
</Tabs>
</div>
<ExternalMcpApprovalsHost />
<PluginContributionHost locale={settings.uiLanguage} theme={settings.resolvedTheme} />
<PluginContributionHost locale={settings.uiLanguage} theme={settings.resolvedTheme} themeTokens={pluginThemeTokens} />
</>
);
};
@@ -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",
+54 -1
View File
@@ -27,7 +27,7 @@ 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 |
@@ -111,3 +111,56 @@ This stage builds on ordinary merges containing #3960 8e4dd0c and the #3971
stale-layout callback fix. Prior QA heads and PR bases are not rewritten. The
ThemePack implementation, selected-context/drafts and file handles remain
unfinished; parent review/native gates still apply to each delivered head.
## Color-pack and remaining workflow checkpoint
The first [ThemePack color layer](./theme-packs.md) now has an implemented host
catalog, data-only admission, Appearance selector, local preview, revision-safe
application/reset, missing-version fallback and host security palette boundary.
It is not native-accepted until the new exact head is independently reviewed,
built by CI and exercised through normal Settings. Terminal palette linkage is
not accepted by this contract yet, so a declared but ignored linkage cannot be
counted as coverage. Font/density/icon/background packs remain separate product
and accessibility decisions, rather than arbitrary CSS permissions.
Parent native evidence for sidebar `a8adeb86` now covers the original Local
Terminal blocker, own-views Once, save, magnify/restore, a split with empty Notes,
and side/bottom docking. `733f44c` adds only the production bridge projection
regression. The older `0c097a1` CI run `40308057623` remains **failed and
unattributed**: 9447 top-level TAP results are `ok`, the aggregate footer is
missing and the process exits 1. A newer passing run does not change that record.
The native Vault success retains its original `0c097a1` evidence label.
The next implementation stages are bounded by useful workflows:
1. **Selected terminal sharing and drafts (A).** Reuse host-signed action
contexts for an explicit selection preview/share, then let the plugin submit
a bounded input draft to the host compose surface. No Enter, raw PTY, history
enumeration or background terminal listener. First add a host-selected
session handle with a minimal from-scratch DTO and independent lifecycle
watch; navigation alone grants no content. Bind handles to actual session and
connection identity, runtime/version, owner, mode and TTL. Cover cancel,
reconnect, close, revoke and late draft results with synthetic terminals.
2. **Selected file conversion/transfer (A).** The host picker issues read or
explicit-destination write handles. Start with one file, stat/read and saveAs
or upload, not recursive traversal or arbitrary paths. Require destination
preview/overwrite confirmation, byte/concurrency limits, cancellation,
session/endpoint identity and final active checks. Observe/cancel only the
plugin's own transfer. Real native SFTP targets require parent-coordinated
concrete approval; local tests use isolated fixtures.
3. **Versioned document proposals (selected B).** Reuse snapshot/handle and host
review primitives for one explicitly shared editor document or Note. Apply
only an expected-version patch to its buffer after a visible diff; saving a
remote file remains a separate file operation. Reuse the draft path for a
selected snippet or new snippet template; do not run scripts or add onConnect
bindings. Cover edits after snapshot, detached ownership, closure and revoke.
4. **Final reassessment.** Recheck the matrix by actual workflow and eight gates.
Read-only system aggregates, selected port-rule status and bounded navigation
may be added only where the shared primitives leave a concrete useful gap.
Full process arguments/env/inspect, tunnel start, arbitrary AI/MCP tools,
background jobs, general KV sync and raw high-privilege access remain deferred
or retained boundaries. Do not invent new C work to prolong the task.
These stages remain unfinished work, not API-name coverage. Exact schemas and
ordering will follow the existing brokers/adapters rather than a parallel RPC
stack or one namespace per product module.
+10
View File
@@ -630,3 +630,13 @@ negotiated API version and runtime initialization uses and verifies that exact
version. Host version bounds remain independently enforced; unknown required
features still fail closed. No old manifest is rewritten and no permission grant
is migrated by this revision.
## Declarative host color contributions
`appearance.theme-packs` adds `contributes.themes` and generated palette/reference
schemas. A pure package uses `main: { "data": "theme" }`, contributes only themes
and omits permissions/activation/companions. It has no executable runtime. Normal
admission accepts this data-only form alongside browser-only code packages; Node
and arbitrary IPC remain advanced/unsupported public boundaries. Selection is a
trusted host Appearance action, not an SDK setter or `appearance.write` grant.
See [Theme packs](./theme-packs.md) for limits, preview, persistence and fallback.
+5
View File
@@ -300,3 +300,8 @@ installs plugins but fails to start them outside the repository checkout.
separate `npm run test:plugin-runtime:electron` smoke launches both a real
sandboxed BrowserWindow plugin and a real utilityProcess plugin, verifies
bidirectional storage RPC, and checks the recorded runtime ownership.
Static `appearance.theme-packs` packages are an explicit non-executable admission
form: `main.data = "theme"`, theme-only contributions and no permissions,
activation events or companions. The runtime supervisor never starts them. This
exception does not admit Node code or arbitrary host access in normal Settings.
+112
View File
@@ -0,0 +1,112 @@
# 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.
+13 -4
View File
@@ -3,6 +3,14 @@
// Shared by host placement, release admission, and the installed-package UI.
// A Node entrypoint does not change default placement, but is not admitted to
// the normal browser-only product path.
function isStaticThemePack(manifest) {
return manifest?.main?.data === "theme" && Object.keys(manifest.main).length === 1
&& Array.isArray(manifest.contributes?.themes) && manifest.contributes.themes.length > 0
&& Object.keys(manifest.contributes).every((key) => key === "themes")
&& !Object.keys(manifest.permissions ?? {}).length && !manifest.activationEvents?.length
&& !manifest.companionExecutables?.length
&& manifest.features?.required?.includes("appearance.theme-packs");
}
function describePluginRuntime(manifest) {
const providers = manifest?.contributes?.providers ?? [];
const permissions = [...(manifest?.permissions?.required ?? []), ...(manifest?.permissions?.optional ?? [])];
@@ -18,19 +26,20 @@ function describePluginRuntime(manifest) {
const hasNode = Boolean(manifest?.main?.node);
const defaultRuntimeKind = hasBrowser && !hasCompanions && !hasInterceptors ? "browser" : "utility";
return {
defaultRuntimeKind,
defaultRuntimeKind: isStaticThemePack(manifest) ? "data" : defaultRuntimeKind,
dataOnly: isStaticThemePack(manifest),
hasNode,
declaresAdvanced,
browserOnly: hasBrowser && !hasNode && !hasCompanions && !hasInterceptors && !declaresAdvanced,
browserOnly: !manifest?.main?.data && hasBrowser && !hasNode && !hasCompanions && !hasInterceptors && !declaresAdvanced,
};
}
function assertBrowserOnlyPlugin(manifest) {
if (!describePluginRuntime(manifest).browserOnly) {
if (!describePluginRuntime(manifest).browserOnly && !isStaticThemePack(manifest)) {
const error = new Error("This package requires advanced developer mode; normal plugin management accepts browser-only packages");
error.code = "PLUGIN_UNSUPPORTED_RUNTIME";
throw error;
}
}
module.exports = { describePluginRuntime, assertBrowserOnlyPlugin };
module.exports = { describePluginRuntime, assertBrowserOnlyPlugin, isStaticThemePack };
+6
View File
@@ -19,3 +19,9 @@ test('normal release admission rejects advanced declarations and privileged prov
assert.equal(describePluginRuntime(manifest).defaultRuntimeKind, 'utility');
assert.throws(() => assertBrowserOnlyPlugin(manifest), /developer mode/);
});
test('normal admission accepts only explicit non-executable theme-only data packages',()=>{
const manifest={main:{data:'theme'},features:{required:['appearance.theme-packs']},contributes:{themes:[{id:'com.example.theme.light'}]}};
assert.doesNotThrow(()=>assertBrowserOnlyPlugin(manifest));assert.equal(describePluginRuntime(manifest).dataOnly,true);assert.equal(describePluginRuntime(manifest).defaultRuntimeKind,'data');
for(const change of [{main:{data:'theme',browser:'index.js'}},{permissions:{required:['network']}},{activationEvents:['onStartupFinished']},{contributes:{themes:[],commands:[]}}])assert.throws(()=>assertBrowserOnlyPlugin({...manifest,...change}),/developer mode/);
});
+139
View File
@@ -0,0 +1,139 @@
"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");
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 };
+22
View File
@@ -0,0 +1,22 @@
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/,
);
});
+1
View File
@@ -70,6 +70,7 @@ module.exports = {
'domain/terminalPromptSecurity.shared.cjs',
'domain/pluginRuntimePolicy.cjs',
'domain/pluginPermissionDescriptions.cjs',
'domain/pluginThemePack.cjs',
// Main-process SSH, SFTP, and port-forward bridges share this matcher.
// TypeScript under domain/ is excluded below, so the .cjs must be listed.
'domain/systemManager/containerRuntime.shared.cjs',
+1
View File
@@ -244,6 +244,7 @@ function createBridgeRegistrar(context) {
},
resolveBrandIcon: (pluginId) => pluginHostService?.contributionIconService?.resolveBrand(pluginId),
resolveContributionIcon: (payload) => pluginHostService?.contributionIconService?.resolve(payload),
themeService: pluginHostService?.themeService,
viewHost: pluginHostService?.viewHost,
env: process.env,
isTrustedSender: createTrustedPluginBridgeSender({ devServerUrl: effectiveDevServerUrl }),
+33 -12
View File
@@ -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",
@@ -3295,6 +3312,14 @@
"PluginContributions": {
"type": "object",
"properties": {
"themes": {
"type": "array",
"maxItems": 32,
"items": {
"$ref": "#/$defs/ThemePackContribution"
},
"minItems": 1
},
"settings": {
"type": "array",
"maxItems": 512,
@@ -3457,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": [
@@ -4772,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"
]
}
}
}
+6 -2
View File
@@ -22,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");
@@ -98,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, TERMINAL_SIDEBAR_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,
@@ -238,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, TERMINAL_SIDEBAR_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,
@@ -370,6 +371,7 @@ function createPluginHostService(options) {
subscribeLock: options.subscribeAppLock,
})
: null;
const themeService = new PluginThemeService({database,packageStore,contributionService});
const manager = new PluginManager({
permissionEngine,
database,
@@ -377,6 +379,7 @@ function createPluginHostService(options) {
runtimeSupervisor,
contributionService,
beforeClose: async () => {
themeService.shutdown();
interactionService.shutdown();
terminalDataPipelineService?.shutdown();
extensionProviderService.shutdown();
@@ -389,6 +392,7 @@ function createPluginHostService(options) {
},
});
return {
themeService,
interactionService,
catalogService: new RegistryCatalogService({ manager, fetch: options.electron.net?.fetch?.bind(options.electron.net) }),
companionSupervisor,
+1
View File
@@ -23,6 +23,7 @@ test("packaged application declares every plugin host runtime dependency and res
assert.match(builderSource, /electron\/plugins\/runtime\/\*\*\/\*/);
assert.match(builderSource, /domain\/pluginRuntimePolicy\.cjs/);
assert.match(builderSource, /domain\/pluginPermissionDescriptions\.cjs/);
assert.match(builderSource, /domain\/pluginThemePack\.cjs/);
assert.match(builderSource, /!electron\/plugins\/fixtures\/\*\*\/\*/);
});
+12
View File
@@ -16,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",
@@ -723,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 ?? {});
+7
View File
@@ -1714,3 +1714,10 @@ test("host interaction ready and response channels reject untrusted senders befo
await ipcMain.handlers.get(CHANNELS.interactionResponse)({ trusted: true, sender }, response);
assert.deepEqual(calls, [[sender, true], [sender, response]]);
});
test('theme catalog and selection remain trusted app management channels',async()=>{
const ipc=createIpcMain();let calls=0;
registerPluginBridge(ipc,{manager:{initialize:async()=>{}},isTrustedSender:event=>event?.trusted===true,themeService:{catalog:async()=>{calls++;return{};},apply:async()=>{calls++;return{};}}});
for(const channel of [CHANNELS.themeCatalog,CHANNELS.themeApply])await assert.rejects(ipc.handlers.get(channel)({trusted:false},{}),/Untrusted/);
assert.equal(calls,0);await ipc.handlers.get(CHANNELS.themeCatalog)({trusted:true});await ipc.handlers.get(CHANNELS.themeApply)({trusted:true},{});assert.equal(calls,2);
});
+2 -1
View File
@@ -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();
});
+220
View File
@@ -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 };
+167
View File
@@ -0,0 +1,167 @@
"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: "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();
}
});
+7
View File
@@ -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 }),
@@ -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"
}
]
}
}
@@ -1,6 +1,6 @@
# Session Scratchpad
Fixture ID: `com.netcatty.session-scratchpad`, version `0.1.0`.
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.
@@ -28,3 +28,5 @@ 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.
@@ -6,7 +6,7 @@
"en": "Session Scratchpad",
"zh-CN": "会话草稿板"
},
"version": "0.1.0",
"version": "0.1.1",
"publisher": "netcatty",
"license": "GPL-3.0-or-later",
"engines": {
+20 -5
View File
@@ -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>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="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);
})();
@@ -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": {
+20 -1
View File
@@ -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 -1
View File
@@ -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" },
+20 -5
View File
@@ -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%; } }
+1 -1
View File
@@ -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
+3
View File
@@ -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 }>;
+26
View File
@@ -2199,3 +2199,29 @@ 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;
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),
};
+4 -1
View File
@@ -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];
+1 -1
View File
@@ -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
View File
@@ -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",
+2
View File
@@ -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)),
+5
View File
@@ -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,8 @@ 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')
@@ -691,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,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",
@@ -1890,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
},
@@ -1913,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": [
@@ -2573,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 = {
@@ -1048,6 +1050,86 @@ export type ThemeIcon = {
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 = {
+2
View File
@@ -58,3 +58,5 @@ export const PLUGIN_HOST_INTERACTION_FEATURE = "host.interactions" as const sati
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;
+2
View File
@@ -933,3 +933,5 @@ export { PLUGIN_HOST_INTERACTION_FEATURE } from "@netcatty/plugin-contract";
export { PLUGIN_VAULT_PAGES_FEATURE } from "@netcatty/plugin-contract";
export { PLUGIN_TERMINAL_SIDEBAR_FEATURE } from "@netcatty/plugin-contract";
export { PLUGIN_INTERACTIVE_COMMAND_FEATURE } from "@netcatty/plugin-contract";
export { PLUGIN_THEME_PACK_FEATURE } from "@netcatty/plugin-contract";
+4 -2
View File
@@ -40,8 +40,10 @@ async function checkDevImport() {
}
const managerResponse = await fetch(new URL("/components/settings/tabs/PluginPackageManager.tsx", origin));
assert.equal(managerResponse.status, 200);
const managerSource = await managerResponse.text();
for (const [helper, exported] of [["pluginRuntimePolicy", "describePluginRuntime"], ["pluginPermissionDescriptions", "describePluginPermission"]]) {
const themeResponse = await fetch(new URL("/infrastructure/config/pluginThemeRegistry.ts", origin));
assert.equal(themeResponse.status, 200);
const managerSource = await managerResponse.text() + "\n" + await themeResponse.text();
for (const [helper, exported] of [["pluginRuntimePolicy", "describePluginRuntime"], ["pluginPermissionDescriptions", "describePluginPermission"], ["pluginThemePack", "resolveThemePalette"]]) {
const match = managerSource.match(new RegExp('from "([^"\\n]*' + helper + '[^"\\n]*)"'));
assert.ok(match, `Missing prebundled ${helper} import`);
const module = await load(new URL(match[1], origin).href);
+2 -1
View File
@@ -85,7 +85,7 @@ export default defineConfig(() => {
build: {
// The shared runtime is also required by Electron; convert it for browser builds.
commonjsOptions: {
include: [/node_modules/, /domain\/systemManager\/containerRuntime\.shared\.cjs$/, /domain\/plugin(?:RuntimePolicy|PermissionDescriptions)\.cjs$/],
include: [/node_modules/, /domain\/systemManager\/containerRuntime\.shared\.cjs$/, /domain\/plugin(?:RuntimePolicy|PermissionDescriptions|ThemePack)\.cjs$/],
},
chunkSizeWarningLimit: 3000,
target: 'esnext', // Required for top-level await in WASM modules
@@ -132,6 +132,7 @@ export default defineConfig(() => {
'@/domain/systemManager/containerRuntime.shared.cjs',
'@/domain/pluginRuntimePolicy.cjs',
'@/domain/pluginPermissionDescriptions.cjs',
'@/domain/pluginThemePack.cjs',
'ai',
'@ai-sdk/openai',
'@ai-sdk/anthropic',