Compare commits

...
227 changed files with 8298 additions and 4172 deletions

No files matched your search

+3
View File
@@ -170,3 +170,6 @@ build_with_vs2022.bat
/resources/et/*/et.exe
/resources/et/*/et-*-dlls/
/resources/et/*/*.dll
# Local plugin example staging and bundles
/examples/plugins/*/.package/
+5
View File
@@ -127,6 +127,11 @@ second private RPC shape when extending the host.
- `PluginRpcRouter` owns correlation, cancellation, deadlines, stream credit and
protocol-failure containment. Runtime identity is assigned by the host and is
never accepted from request parameters.
- Custom views use only host-rendered DOM: plugin scripts stay in the isolated
runtime; the host validates incoming trees, styles, resources and events before
rendering. Never reintroduce native child views, iframe UI, or plugin code in
the host renderer. See `docs/plugin-platform/host-rendered-ui.md` for authoring,
lifecycle, limits and verification.
- App quit goes through `runPluginShutdown()` after the dirty-editor guard; do
not add another independent quit interception path.
+2 -1
View File
@@ -16,6 +16,7 @@ import { isPortForwardingAutoReconnectEnabled } from '../../domain/portForwardin
import { buildNumberShortcutTabTargets } from './tabShortcutTargets';
import { captureInheritedCwd } from '../state/inheritedCwd';
import { fromEditorTabId, isEditorTabId } from '../state/activeTabStore';
import { isPluginViewEditingTarget } from '../state/pluginKeybindings';
import { resolveBatchTabCloseFocus } from '../state/pluginViewTabStore';
type AppContextGetter = () => Record<string, any>;
@@ -264,7 +265,7 @@ export function handleGlobalHotkeyKeyDownImpl(getCtx: AppContextGetter, e: Keybo
}
}
const isFormElement = target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable;
const isFormElement = target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable || isPluginViewEditingTarget(target);
const isMonacoElement =
target instanceof HTMLElement &&
!!target.closest?.('.monaco-editor, .monaco-diff-editor, .monaco-inputbox');
+3
View File
@@ -59,6 +59,7 @@ import { collectSessionIds } from '../../domain/workspace';
import { resolveAgentHostWorkspace } from '../../domain/agentHostWorkspace';
import { useHostOpenSessionReuse } from '../state/useHostOpenSessionReuse';
import type { PaneMagnificationController } from '../../domain/paneMagnification';
import { isPluginViewEditingTarget } from '../state/pluginKeybindings';
import { resolveCloseIntent } from '../state/resolveCloseIntent';
import { resolveSnippetsShortcutIntent } from '../state/resolveSnippetsShortcutIntent';
import { isPrimaryModifierWBinding, resolveWindowCommandCloseIntent } from '../state/windowCommandClose';
@@ -1156,6 +1157,8 @@ export function AppSideEffects() {
const topmostOpenDialog = openDialogs[openDialogs.length - 1] ?? null;
const topmostDialogClose = topmostOpenDialog?.querySelector<HTMLElement>('[data-dialog-close="true"]');
if (!topmostDialogClose && isPluginViewEditingTarget(document.activeElement)) return;
const intent = resolveWindowCommandCloseIntent({
activeTabId: activeTabStore.getActiveTabId(),
editorTabIds: editorTabs.map((tab) => toEditorTabId(tab.id)),
+1
View File
@@ -1401,6 +1401,7 @@ Highlight the focused split pane:
"settings.plugins.sources.local": "Local import",
"settings.plugins.sources.all": "All sources",
"settings.plugins.title": "Plugins",
"settings.plugins.configuration": "Plugin settings",
"settings.plugins.description": "Manage your plugins and explore the catalog.",
'settings.plugins.loading': 'Loading plugin settings…',
'settings.plugins.empty': 'No enabled plugin contributes settings.',
+1
View File
@@ -1401,6 +1401,7 @@ Resalta el panel dividido enfocado:
"settings.plugins.sources.local": "Local import",
"settings.plugins.sources.all": "All sources",
"settings.plugins.title": "Plugins",
"settings.plugins.configuration": "Ajustes del plugin",
"settings.plugins.description": "Gestiona tus plugins y explora el catálogo.",
'settings.plugins.loading': 'Cargando ajustes del plugin…',
'settings.plugins.empty': 'Ningún plugin activado aporta ajustes.',
+1
View File
@@ -1266,6 +1266,7 @@ export const ruCoreMessages: Messages = {
"settings.plugins.sources.local": "Local import",
"settings.plugins.sources.all": "All sources",
"settings.plugins.title": "Плагины",
"settings.plugins.configuration": "Настройки плагина",
"settings.plugins.description": "Управляйте плагинами и просматривайте каталог.",
'settings.plugins.loading': 'Загрузка настроек плагинов…',
'settings.plugins.empty': 'Включённые плагины не предоставляют настройки.',
+1
View File
@@ -1316,6 +1316,7 @@ Vaults 标签保持扁平,不使用这块背景。
"settings.plugins.sources.local": "本地导入",
"settings.plugins.sources.all": "全部来源",
"settings.plugins.title": "插件",
"settings.plugins.configuration": "插件设置",
"settings.plugins.description": "管理已安装插件,浏览精选目录。",
'settings.plugins.loading': '正在加载插件设置…',
'settings.plugins.empty': '当前没有已启用插件提供设置。',
+1
View File
@@ -1287,6 +1287,7 @@ Vaults 分頁維持扁平,不使用這塊背景。
"settings.plugins.sources.local": "本地导入",
"settings.plugins.sources.all": "全部来源",
"settings.plugins.title": "插件",
"settings.plugins.configuration": "插件設定",
"settings.plugins.description": "管理已安裝插件,瀏覽精選目錄。",
'settings.plugins.loading': '正在載入外掛程式設定…',
"settings.plugins.market.results": "{count} 個插件",
@@ -1,36 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { JSDOM } from 'jsdom';
import { observePluginHostSecurityVisibility } from './pluginHostSecurityVisibility';
test('native modal yields to the host chooser, restores on dismissal, and never restores after cleanup', async () => {
const dom = new JSDOM('<body><div role="dialog" aria-modal="true"><div id="mount"></div></div></body>');
try {
const { document } = dom.window;
const calls: boolean[] = [];
const stop = observePluginHostSecurityVisibility(document.getElementById('mount')!, (visible) => calls.push(visible));
const flush = () => new Promise<void>((resolve) => dom.window.queueMicrotask(resolve));
assert.deepEqual(calls, []); // Its own modal does not hide the plugin.
const chooser = document.createElement('div');
chooser.dataset.hostSecuritySurface = '';
document.body.append(chooser);
await flush(); assert.deepEqual(calls, [false]);
chooser.remove(); await flush(); assert.deepEqual(calls, [false, true]);
document.body.append(chooser); await flush();
assert.deepEqual(calls, [false, true, false]);
stop(); chooser.remove(); await flush();
assert.deepEqual(calls, [false, true, false]);
} finally { dom.window.close(); }
});
test('hidden security surfaces do not occlude a native view', async () => {
const dom = new JSDOM('<body><div id="mount"></div><div hidden><div data-host-security-surface></div></div></body>');
try {
const calls: boolean[] = [];
const stop = observePluginHostSecurityVisibility(dom.window.document.getElementById('mount')!, (visible) => calls.push(visible));
assert.deepEqual(calls, []);
dom.window.document.querySelector('[hidden]')!.removeAttribute('hidden');
await new Promise<void>((resolve) => dom.window.queueMicrotask(resolve));
assert.deepEqual(calls, [false]); stop();
} finally { dom.window.close(); }
});
@@ -1,21 +0,0 @@
/** Native plugin views must yield while a host-owned security dialog is open. */
export function observePluginHostSecurityVisibility(mount: HTMLElement, publish: (visible: boolean) => void): () => void {
const document = mount.ownerDocument;
let visible = true;
const update = () => {
const blocked = [...document.querySelectorAll('[data-host-security-surface]')].some((surface) => {
const style = document.defaultView!.getComputedStyle(surface);
return style.display !== 'none' && style.visibility !== 'hidden'
&& !surface.closest('[hidden], [inert], [aria-hidden="true"]');
});
if (visible === !blocked) return;
visible = !blocked;
publish(visible);
};
const observer = new document.defaultView!.MutationObserver(update);
observer.observe(document.body, { childList: true, subtree: true, attributes: true,
attributeFilter: ['class', 'style', 'hidden', 'inert', 'aria-hidden', 'data-host-security-surface'] });
update();
// The view lifecycle owns close/retention. Cleanup must never reveal an old view.
return () => observer.disconnect();
}
+5
View File
@@ -33,6 +33,7 @@ const NAMED_KEYS = new Map<string, string>([
['tab', 'tab'],
]);
const PLUGIN_SHORTCUT_EDITABLE_SELECTOR = [
'[data-plugin-editing]',
'input',
'textarea',
'select',
@@ -44,6 +45,10 @@ const PLUGIN_SHORTCUT_EDITABLE_SELECTOR = [
'.monaco-menu-container',
].join(', ');
export function isPluginViewEditingTarget(node: EventTarget | null): boolean {
return Boolean((node as Element | null)?.closest?.('[data-plugin-editing]'));
}
function hasEditableShortcutAncestor(node: unknown): boolean {
const closest = (node as { closest?: (selector: string) => unknown } | null)?.closest;
return typeof closest === 'function'
@@ -0,0 +1,67 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { JSDOM } from 'jsdom';
test('React 19 changes are staged as safe data before any host rendering', async () => {
const dom = new JSDOM('<!doctype html><div id="remote"></div>', { url: 'http://localhost', pretendToBeVisual: true });
const saved = new Map<string, PropertyDescriptor | undefined>();
for (const key of ['window', 'document', 'navigator', 'HTMLElement', 'Element', 'Node', 'MutationObserver', 'Event', 'MouseEvent']) {
saved.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
Object.defineProperty(globalThis, key, { configurable: true, writable: true, value: key === 'window' ? dom.window : dom.window[key as keyof typeof dom.window] });
}
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', { configurable: true, writable: true, value: true });
const { default: React, act, useState } = await import('react');
const { createRoot } = await import('react-dom/client');
const { RemoteMutationObserver } = await import('@remote-dom/core/elements');
const { createPluginRemoteTree } = await import('./pluginRemoteTree');
const staged = createPluginRemoteTree();
const snapshots: ReturnType<typeof staged.snapshot>[] = [];
const observer = new RemoteMutationObserver({
mutate(records) { snapshots.push(staged.apply(JSON.parse(JSON.stringify(records)))); },
call() { throw new Error('Host method calls are unavailable'); },
});
const container = document.getElementById('remote')!;
observer.observe(container);
const root = createRoot(container);
function Example() {
const [count, setCount] = useState(0);
return <section className="card" style={{ display: 'grid', gap: '12px' }}><h1>Hello</h1><button type="button" onClick={() => setCount((value) => value + 1)}>Count {count}</button></section>;
}
try {
await act(async () => { root.render(<Example />); await new Promise((resolve) => setTimeout(resolve, 10)); });
const first = snapshots.at(-1)!;
const section = first.children[0]; assert.equal(section.type, 1);
if (section.type !== 1) throw new Error('Expected section');
assert.equal(section.attributes.class, 'card');
assert.match(section.attributes.style, /display:\s*grid/);
await act(async () => { container.querySelector('button')!.click(); await new Promise((resolve) => setTimeout(resolve, 10)); });
const updated = JSON.stringify(snapshots.at(-1));
assert.match(updated, /Count /); assert.match(updated, /"data":"1"/);
assert.equal(staged.isFailed(), false);
assert.throws(() => staged.apply([[3, section.id, 'onclick', 'attack()']]));
assert.deepEqual(staged.snapshot(), { children: [] });
assert.equal(staged.isFailed(), true);
assert.throws(() => staged.apply([]));
} finally {
observer.disconnect(); await act(async () => { root.unmount(); });
for (const [key, value] of saved) { if (value) Object.defineProperty(globalThis, key, value); else Reflect.deleteProperty(globalThis, key); }
Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT'); dom.window.close();
}
});
test('removed attributes do not keep checkboxes, menus or old inline styles active', async () => {
const { createPluginRemoteTree } = await import('./pluginRemoteTree');
const tree = createPluginRemoteTree();
tree.apply([[0, '~', { id: 'control', type: 1, element: 'input', attributes: { type: 'checkbox', checked: '', style: 'color:red' }, children: [] }, 0]]);
const updated = tree.apply([[3, 'control', 'checked', null], [3, 'control', 'style', null]]);
assert.deepEqual(updated.children[0], { id: 'control', type: 1, element: 'input', attributes: { type: 'checkbox' }, children: [] });
});
test('inserting a child into an existing void element fails the view', async () => {
const { createPluginRemoteTree } = await import('./pluginRemoteTree');
const tree = createPluginRemoteTree();
tree.apply([[0, '~', { id: 'input', type: 1, element: 'input', attributes: {}, children: [] }, 0]]);
assert.throws(() => tree.apply([[0, 'input', { id: 'child', type: 3, data: 'invalid' }, 0]]), /Void view elements/);
assert.equal(tree.isFailed(), true);
assert.deepEqual(tree.snapshot(), { children: [] });
});
+41
View File
@@ -0,0 +1,41 @@
import { RemoteReceiver } from '@remote-dom/core/receivers';
import { validatePluginMutationBatch, validatePluginRemoteTree } from '@/domain/pluginRemoteTreePolicy.cjs';
type WireNode = { id: string; type: 3 | 8; data: string } | {
id: string; type: 1; element: string; attributes: Record<string, string>; children: WireNode[];
};
type WireTree = { children: WireNode[] };
/** Data-only staging area: untrusted changes are never applied directly to the host DOM. */
export function createPluginRemoteTree() {
const receiver = new RemoteReceiver();
let current: WireTree = { children: [] };
let failed = false;
const project = (node: unknown): WireNode => {
const value = node as { id: string; type: number; data?: string; element?: string; attributes?: Record<string, string>; children?: unknown[] };
if (value.type === 3 || value.type === 8) return { id: value.id, type: value.type, data: value.data ?? '' };
return { id: value.id, type: 1, element: value.element ?? '', attributes: Object.fromEntries(Object.entries(value.attributes ?? {}).filter(([, attribute]) => attribute !== null)), children: (value.children ?? []).map(project) };
};
return {
apply(input: unknown): WireTree {
if (failed) throw new Error('Plugin view has been closed after an invalid update');
try {
validatePluginMutationBatch(input);
// Ordinary DOM attribute mutations do not specify a channel. RemoteReceiver
// defaults those to properties, so explicitly mark them as attributes.
const batch = (input as unknown[][]).map((record) => record[0] === 3 && record.length === 4 ? [...record, 2] : record);
receiver.connection.mutate(batch as unknown as Parameters<typeof receiver.connection.mutate>[0]);
const next = { children: receiver.root.children.map(project) };
validatePluginRemoteTree(next);
current = next;
return next;
} catch (error) {
failed = true;
current = { children: [] };
throw error;
}
},
snapshot(): WireTree { return current; },
isFailed(): boolean { return failed; },
};
}
+2 -133
View File
@@ -1,111 +1,7 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
PluginViewLifecycleController,
consumeClosedPluginViewInstance,
reconcilePluginViewTabCatalog,
markPluginViewOpenTokensClosed,
reconcileClosedPluginView,
rememberClosedPluginViewInstance,
resolvePluginViewSnapshotSelection,
shouldReconcilePluginViewTabCatalog,
withdrawPluginViewTab,
type HostedPluginViewState,
} from './pluginViewLifecycle.ts';
import { PluginViewTabStore } from './pluginViewTabStore.ts';
function view(id: string, tabId?: string): HostedPluginViewState {
return {
id,
viewId: `view.${id}`,
scopeId: 'window:main',
retainContextWhenHidden: false,
...(tabId ? { tabId } : {}),
};
}
test('host close events clear the active renderer instance and identify its native tab', () => {
const current = view('active', 'plugin-view:publisher.plugin:view.active');
const result = reconcileClosedPluginView({
current,
retained: new Map([['retained', view('retained')]]),
instanceId: 'active',
});
assert.equal(result.current, null);
assert.equal(result.matchedCurrent, true);
assert.equal(result.closedTabId, current.tabId);
assert.equal(result.retained.size, 1);
});
test('host close events remove retained instances without dismissing another active view', () => {
const current = view('active');
const retainedTab = view('retained-closed', 'plugin-view:publisher.plugin:view.retained');
const result = reconcileClosedPluginView({
current,
retained: new Map([
['closed', retainedTab],
['kept', view('retained-kept')],
]),
instanceId: 'retained-closed',
});
assert.equal(result.current, current);
assert.equal(result.matchedCurrent, false);
assert.equal(result.matchedRetained, true);
assert.deepEqual([...result.retained.keys()], ['kept']);
assert.equal(result.closedTabId, retainedTab.tabId);
});
test('explicit native-tab close destroys active and retained instances instead of retaining them', () => {
const tabId = 'plugin-view:publisher.plugin:view.shared';
const result = withdrawPluginViewTab({
current: view('active', tabId),
retained: new Map([
['same-tab', view('retained-same', tabId)],
['other-tab', view('retained-other', 'plugin-view:publisher.plugin:view.other')],
]),
tabId,
});
assert.equal(result.current, null);
assert.equal(result.matchedCurrent, true);
assert.equal(result.matchedRetained, true);
assert.deepEqual(result.instanceIds, ['active', 'retained-same']);
assert.deepEqual([...result.retained.keys()], ['other-tab']);
});
test('an early host close tombstone is consumed when the open response arrives later', () => {
const tombstones = new Set<string>();
rememberClosedPluginViewInstance(tombstones, 'instance-early');
assert.equal(consumeClosedPluginViewInstance(tombstones, 'instance-early'), true);
assert.equal(consumeClosedPluginViewInstance(tombstones, 'instance-early'), false);
for (let index = 0; index < 300; index += 1) {
rememberClosedPluginViewInstance(tombstones, `instance-${index}`);
}
assert.equal(tombstones.size, 256);
assert.equal(tombstones.has('instance-0'), false);
});
test('explicit close marks only in-flight opens owned by the closed surface', () => {
const first = Symbol('first');
const second = Symbol('second');
const explicitlyClosed = new Set<symbol>();
const opening = new Map<string, Set<symbol>>([
['window:main\0view.first', new Set([first])],
['window:main\0view.second', new Set([second])],
]);
assert.equal(markPluginViewOpenTokensClosed(
opening,
explicitlyClosed,
'window:main\0view.first',
), 1);
assert.deepEqual([...explicitlyClosed], [first]);
assert.equal(markPluginViewOpenTokensClosed(opening, explicitlyClosed, null), 0);
});
import { reconcilePluginViewTabCatalog, resolvePluginViewSnapshotSelection, shouldReconcilePluginViewTabCatalog } from './pluginViewLifecycle';
import { PluginViewTabStore } from './pluginViewTabStore';
test('locale-only snapshot refresh keeps the owned view alive without weakening context fail-closed behavior', () => {
const previous = {
@@ -184,30 +80,3 @@ test('active-tab context refresh cannot withdraw the plugin tab that triggered i
assert.equal(store.getTabs()[0]?.title, 'Localized View');
assert.equal(activeTabId, tab.id);
});
test('lifecycle controller owns retained views, open tokens, tombstones, and teardown', () => {
const controller = new PluginViewLifecycleController<HostedPluginViewState>();
const tabId = 'plugin-view:publisher.plugin:view.shared';
const active = view('active', tabId);
const retained = view('retained');
controller.setCurrent(active);
controller.retain('window:main\0view.retained', retained);
const token = controller.beginOpen({
viewKey: 'window:main\0view.active',
tabId,
});
const closed = controller.handleTabClose(tabId);
assert.deepEqual(closed.instanceIds, [active.id]);
assert.equal(controller.shouldCloseOpen(token), true);
controller.finishOpen({ token, viewKey: 'window:main\0view.active', tabId });
assert.equal(controller.shouldCloseOpen(token), false);
const early = controller.handleHostClose('instance-before-open-response');
assert.equal(early.matchedCurrent, false);
assert.equal(early.matchedRetained, false);
assert.equal(controller.consumeHostClose('instance-before-open-response'), true);
assert.deepEqual(controller.drain().map((candidate) => candidate.id), [retained.id]);
assert.equal(controller.getCurrent(), null);
});
-246
View File
@@ -1,11 +1,3 @@
export interface HostedPluginViewState {
id: string;
viewId: string;
scopeId: string;
retainContextWhenHidden: boolean;
tabId?: string;
}
export interface PluginViewSnapshotSelection<T> {
requestViewId: string;
contextKey: string;
@@ -81,241 +73,3 @@ export function reconcilePluginViewTabCatalog({
store.refreshMetadata(entries);
return true;
}
export function reconcileClosedPluginView<T extends HostedPluginViewState>({
current,
retained,
instanceId,
}: {
current: T | null;
retained: ReadonlyMap<string, T>;
instanceId: string;
}): {
current: T | null;
retained: Map<string, T>;
matchedCurrent: boolean;
matchedRetained: boolean;
closedTabId?: string;
} {
const matchedCurrent = current?.id === instanceId;
let matchedRetained = false;
let retainedTabId: string | undefined;
const nextRetained = new Map<string, T>();
for (const [key, view] of retained) {
if (view.id === instanceId) {
matchedRetained = true;
retainedTabId = view.tabId;
}
else nextRetained.set(key, view);
}
return {
current: matchedCurrent ? null : current,
retained: nextRetained,
matchedCurrent,
matchedRetained,
...((matchedCurrent ? current?.tabId : retainedTabId)
? { closedTabId: matchedCurrent ? current?.tabId : retainedTabId }
: {}),
};
}
export function withdrawPluginViewTab<T extends HostedPluginViewState>({
current,
retained,
tabId,
}: {
current: T | null;
retained: ReadonlyMap<string, T>;
tabId: string;
}): {
current: T | null;
retained: Map<string, T>;
instanceIds: string[];
matchedCurrent: boolean;
matchedRetained: boolean;
} {
const matchedCurrent = current?.tabId === tabId;
let matchedRetained = false;
const instanceIds: string[] = [];
if (matchedCurrent && current) instanceIds.push(current.id);
const nextRetained = new Map<string, T>();
for (const [key, view] of retained) {
if (view.tabId === tabId) {
matchedRetained = true;
instanceIds.push(view.id);
} else {
nextRetained.set(key, view);
}
}
return {
current: matchedCurrent ? null : current,
retained: nextRetained,
instanceIds,
matchedCurrent,
matchedRetained,
};
}
export function rememberClosedPluginViewInstance(
tombstones: Set<string>,
instanceId: string,
limit = 256,
): void {
if (tombstones.size >= limit) {
const oldest = tombstones.values().next().value;
if (typeof oldest === 'string') tombstones.delete(oldest);
}
tombstones.add(instanceId);
}
export function consumeClosedPluginViewInstance(tombstones: Set<string>, instanceId: string): boolean {
return tombstones.delete(instanceId);
}
export function markPluginViewOpenTokensClosed(
openingTokens: ReadonlyMap<string, ReadonlySet<symbol>>,
explicitlyClosedTokens: Set<symbol>,
ownerKey: string | null | undefined,
): number {
if (!ownerKey) return 0;
const tokens = openingTokens.get(ownerKey);
if (!tokens) return 0;
for (const token of tokens) explicitlyClosedTokens.add(token);
return tokens.size;
}
export class PluginViewLifecycleController<T extends HostedPluginViewState = HostedPluginViewState> {
private current: T | null = null;
private retained = new Map<string, T>();
private readonly closedInstanceIds = new Set<string>();
private readonly openingTokensByTab = new Map<string, Set<symbol>>();
private readonly openingTokensByView = new Map<string, Set<symbol>>();
private readonly explicitlyClosedOpenTokens = new Set<symbol>();
getCurrent(): T | null {
return this.current;
}
setCurrent(view: T | null): void {
this.current = view;
}
takeCurrent(): T | null {
const current = this.current;
this.current = null;
return current;
}
retain(key: string, view: T): void {
this.retained.set(key, view);
}
takeRetained(key: string): T | null {
const retained = this.retained.get(key) ?? null;
if (retained) this.retained.delete(key);
return retained;
}
removeRetained(key: string): void {
this.retained.delete(key);
}
removeRetainedWhere(predicate: (view: T) => boolean): T[] {
const removed: T[] = [];
for (const [key, view] of this.retained) {
if (!predicate(view)) continue;
this.retained.delete(key);
removed.push(view);
}
return removed;
}
handleHostClose(instanceId: string) {
const next = reconcileClosedPluginView({
current: this.current,
retained: this.retained,
instanceId,
});
this.current = next.current;
this.retained = next.retained;
if (!next.matchedCurrent && !next.matchedRetained) {
rememberClosedPluginViewInstance(this.closedInstanceIds, instanceId);
}
return next;
}
handleTabClose(tabId: string) {
const next = withdrawPluginViewTab({
current: this.current,
retained: this.retained,
tabId,
});
this.current = next.current;
this.retained = next.retained;
markPluginViewOpenTokensClosed(
this.openingTokensByTab,
this.explicitlyClosedOpenTokens,
tabId,
);
return next;
}
markViewClosed(viewKey: string | null | undefined): number {
return markPluginViewOpenTokensClosed(
this.openingTokensByView,
this.explicitlyClosedOpenTokens,
viewKey,
);
}
beginOpen({
viewKey,
tabId,
label,
}: {
viewKey: string;
tabId?: string;
label?: string;
}): symbol {
const token = Symbol(label ?? tabId ?? viewKey);
const viewTokens = this.openingTokensByView.get(viewKey) ?? new Set<symbol>();
viewTokens.add(token);
this.openingTokensByView.set(viewKey, viewTokens);
if (tabId) {
const tabTokens = this.openingTokensByTab.get(tabId) ?? new Set<symbol>();
tabTokens.add(token);
this.openingTokensByTab.set(tabId, tabTokens);
}
return token;
}
finishOpen({ token, viewKey, tabId }: { token: symbol; viewKey: string; tabId?: string }): void {
this.explicitlyClosedOpenTokens.delete(token);
const viewTokens = this.openingTokensByView.get(viewKey);
viewTokens?.delete(token);
if (viewTokens?.size === 0) this.openingTokensByView.delete(viewKey);
if (!tabId) return;
const tabTokens = this.openingTokensByTab.get(tabId);
tabTokens?.delete(token);
if (tabTokens?.size === 0) this.openingTokensByTab.delete(tabId);
}
shouldCloseOpen(token: symbol): boolean {
return this.explicitlyClosedOpenTokens.has(token);
}
consumeHostClose(instanceId: string): boolean {
return consumeClosedPluginViewInstance(this.closedInstanceIds, instanceId);
}
drain(): T[] {
const values = [this.current, ...this.retained.values()].filter((view): view is T => Boolean(view));
this.current = null;
this.retained.clear();
this.closedInstanceIds.clear();
this.openingTokensByTab.clear();
this.openingTokensByView.clear();
this.explicitlyClosedOpenTokens.clear();
return values;
}
}
@@ -0,0 +1,41 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import type { HostDrawnViewMessage, HostDrawnViewOpenRequest } from '@netcatty/plugin-contract';
type Update = Extract<HostDrawnViewMessage, { kind: 'host-ui.update' }> & { viewId: string };
import { PluginViewSessions } from './pluginViewSession';
function fixture() {
let updates: (data: Update) => void = () => {}; let closed: (data: NetcattyPluginViewClosedEvent) => void = () => {};
const opened: HostDrawnViewOpenRequest[] = []; const closes: string[] = []; const events: unknown[] = [];
const bridge = { openHostDrawnPluginView: async (request: HostDrawnViewOpenRequest) => { opened.push(request); return { instanceId: request.instanceId }; },
closeHostDrawnPluginView: async (id: string) => { closes.push(id); },
onHostDrawnPluginViewUpdate: (listener: typeof updates) => { updates = listener; return () => {}; },
onPluginViewClosed: (listener: typeof closed) => { closed = listener; return () => {}; },
sendHostDrawnPluginViewEvent: async (id: string, event: unknown) => { events.push({ id, event }); } };
return { bridge, opened, closes, events, update: (data: Update) => updates(data), close: (data: { instanceId: string }) => closed({ ...data, viewId: request.viewId, pluginId: 'com.example', reason: 'test' }) };
}
const request = { viewId: 'com.example.view', scopeId: 'window:one', context: {} };
test('a hidden view resumes its existing state and plugin instance', async () => {
const f = fixture(); const store = new PluginViewSessions();
const first = store.acquire('view', f.bridge, request); const id = f.opened[0].instanceId;
f.update({ kind: 'host-ui.update', viewId: request.viewId, instanceId: id, sequence: 1, css: '', mutations: [[0, '~', { id: 'text', type: 3, data: 'kept' }, 0]] });
first.release(); const next = store.acquire('view', f.bridge, request);
assert.equal(next, first); assert.equal(f.opened.length, 1); assert.deepEqual(next.getSnapshot().tree.children[0], { id: 'text', type: 3, data: 'kept' });
next.release(); store.dispose(); await Promise.resolve(); assert.equal(new Set(f.closes).size, 1);
});
test('host invalidation clears retained content and a reopen creates a new instance', () => {
const f = fixture(); const store = new PluginViewSessions(); const old = store.acquire('view', f.bridge, request);
f.close({ instanceId: f.opened[0].instanceId }); assert.equal(old.getSnapshot().error, true); assert.deepEqual(old.getSnapshot().tree.children, []);
const next = store.acquire('view', f.bridge, request); assert.notEqual(old, next); store.dispose();
});
test('unacknowledged input is not overwritten by an older plugin update', () => {
const f = fixture(); const store = new PluginViewSessions(); const view = store.acquire('view', f.bridge, request);
view.send({ type: 'input', nodeId: 'field', value: 'latest' }); assert.equal(view.getSnapshot().settled, false);
const base = { kind: 'host-ui.update' as const, viewId: request.viewId, instanceId: f.opened[0].instanceId, css: '', mutations: [] };
f.update({ ...base, sequence: 1, acknowledgedEvent: 0 }); assert.equal(view.getSnapshot().settled, false);
f.update({ ...base, sequence: 2, acknowledgedEvent: 1 }); assert.equal(view.getSnapshot().settled, true); store.dispose();
});
test('idle instances are evicted while active instances are never silently replaced', () => {
const f = fixture(); const store = new PluginViewSessions(1); const first = store.acquire('one', f.bridge, request);
assert.throws(() => store.acquire('two', f.bridge, request), /Too many/); first.release();
const second = store.acquire('two', f.bridge, request); assert.notEqual(second, first); assert.equal(f.closes.length, 1); store.dispose();
});
+78
View File
@@ -0,0 +1,78 @@
import { createPluginRemoteTree } from './pluginRemoteTree';
import { validatePluginStylesheet } from '@/domain/pluginViewStylePolicy.cjs';
import type { HostDrawnViewEvent } from '@netcatty/plugin-contract';
type Bridge = Pick<NonNullable<ReturnType<typeof import('../../infrastructure/services/netcattyBridge').netcattyBridge.get>>, 'openHostDrawnPluginView' | 'closeHostDrawnPluginView' | 'onHostDrawnPluginViewUpdate' | 'onPluginViewClosed' | 'sendHostDrawnPluginViewEvent' | 'getPluginViewResource'>;
export type PluginViewSnapshot = { tree: ReturnType<ReturnType<typeof createPluginRemoteTree>['snapshot']>; css: string; loading: boolean; error: boolean; settled: boolean };
const empty = (): PluginViewSnapshot => ({ tree: { children: [] }, css: '', loading: true, error: false, settled: true });
/** Owns a window's bounded, retained UI instances independently of React mounts. */
export class PluginViewSessions {
private sessions = new Map<string, ReturnType<PluginViewSessions['create']>>();
constructor(private readonly capacity = 16, private readonly idleMs = 300_000) {}
private create(key: string, bridge: Bridge, request: Omit<import('@netcatty/plugin-contract').HostDrawnViewOpenRequest, 'instanceId'>) {
const id = crypto.randomUUID();
const tree = createPluginRemoteTree();
let state = empty(); let sequence = 1; let eventId = 0; let disposed = false; let users = 0;
let timer: ReturnType<typeof setTimeout> | undefined;
const listeners = new Set<() => void>();
const publish = (next: PluginViewSnapshot) => { state = next; for (const listener of listeners) listener(); };
const cleanup: Array<() => void> = [];
const dispose = (failed = false) => {
if (disposed) return;
disposed = true; clearTimeout(timer); cleanup.forEach((stop) => stop());
this.sessions.delete(key);
publish({ ...empty(), loading: false, error: failed });
void bridge.closeHostDrawnPluginView?.(id).catch(() => {});
};
if (!bridge.openHostDrawnPluginView || !bridge.onHostDrawnPluginViewUpdate || !bridge.onPluginViewClosed) throw new Error('Plugin views unavailable');
cleanup.push(bridge.onHostDrawnPluginViewUpdate((update) => {
if (disposed || update.kind !== 'host-ui.update' || update.instanceId !== id || update.viewId !== request.viewId) return;
try {
if (update.sequence !== sequence++) throw new Error('Invalid plugin update order');
publish({ tree: tree.apply(update.mutations), css: validatePluginStylesheet(update.css), loading: false, error: false, settled: (update.acknowledgedEvent ?? 0) >= eventId });
} catch { dispose(true); }
}));
cleanup.push(bridge.onPluginViewClosed((event) => { if (event.instanceId === id) dispose(true); }));
void bridge.openHostDrawnPluginView({ ...request, instanceId: id }).then(() => {
if (disposed) void bridge.closeHostDrawnPluginView?.(id).catch(() => {});
}).catch(() => dispose(true));
return {
viewId: request.viewId,
scope: request.scopeId,
image: async (path: string) => {
if (disposed || !bridge.getPluginViewResource) throw new Error('Plugin image unavailable');
const result = await bridge.getPluginViewResource({ instanceId: id, path });
if (disposed) throw new Error('Plugin view closed');
return result.url;
},
get idle() { return users === 0; },
getSnapshot: () => state,
subscribe(listener: () => void) { listeners.add(listener); return () => { listeners.delete(listener); }; },
acquire: () => { clearTimeout(timer); users++; },
release: () => { if (--users === 0 && !disposed) timer = setTimeout(() => dispose(), this.idleMs); },
send: (event: HostDrawnViewEvent) => {
if (disposed || users === 0) return;
publish({ ...state, settled: false });
void bridge.sendHostDrawnPluginViewEvent?.(id, { ...event, eventId: ++eventId }).catch(() => dispose(true));
},
dispose,
};
}
acquire(key: string, bridge: Bridge, request: Omit<import('@netcatty/plugin-contract').HostDrawnViewOpenRequest, 'instanceId'>) {
let session = this.sessions.get(key);
if (!session) {
while (this.sessions.size >= this.capacity) {
const candidate = [...this.sessions.values()].find((item) => item.idle);
if (!candidate) throw new Error('Too many open plugin views');
candidate.dispose();
}
session = this.create(key, bridge, request); this.sessions.set(key, session);
}
session.acquire(); return session;
}
closeView(viewId: string) { for (const session of [...this.sessions.values()]) if (session.viewId === viewId) session.dispose(); }
closeScope(scope: string) { for (const session of [...this.sessions.values()]) if (session.scope === scope) session.dispose(); }
dispose() { for (const session of [...this.sessions.values()]) session.dispose(); }
}
export const pluginViewSessions = new PluginViewSessions();
@@ -0,0 +1,44 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { JSDOM } from 'jsdom';
test('plugin tools stay in the toolbar and retain placement across catalog changes and remount', async () => {
const dom = new JSDOM('<!doctype html><div id="root"></div>', { url: 'http://localhost' });
const previous = new Map<string, PropertyDescriptor | undefined>();
for (const key of ['window', 'document', 'navigator', 'localStorage', 'StorageEvent', 'CustomEvent', 'Event']) {
previous.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
Object.defineProperty(globalThis, key, { configurable: true, writable: true, value: key === 'window' ? dom.window : dom.window[key as keyof typeof dom.window] });
}
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', { configurable: true, writable: true, value: true });
const { default: React, act } = await import('react');
const { createRoot } = await import('react-dom/client');
const { useTerminalSidePanelTabOrder, TERMINAL_SIDE_PANEL_TAB_DEFAULT_ORDER } = await import('./terminalSidePanelTabs');
const { STORAGE_KEY_TERMINAL_SIDE_PANEL_TAB_LAYOUT } = await import('../../infrastructure/config/storageKeys');
const plugin = 'plugin-view:com.example.sidebar.page' as const;
dom.window.localStorage.setItem(STORAGE_KEY_TERMINAL_SIDE_PANEL_TAB_LAYOUT, JSON.stringify({ order: TERMINAL_SIDE_PANEL_TAB_DEFAULT_ORDER, placement: {} }));
let model: ReturnType<typeof useTerminalSidePanelTabOrder>;
let tools: typeof plugin[] = [];
function Fixture() { model = useTerminalSidePanelTabOrder(tools); return <div />; }
const root = createRoot(document.getElementById('root')!);
const render = async () => { await act(async () => { root.render(<Fixture />); }); };
try {
await render();
tools = [plugin]; await render();
assert.equal(model!.sidePanelTabOrder[1], plugin, 'new plugin is a visible toolbar item');
assert.ok(model!.partition([...TERMINAL_SIDE_PANEL_TAB_DEFAULT_ORDER, plugin]).shown.includes(plugin));
await act(async () => { model!.setPlacement(plugin, 'collapse', [...TERMINAL_SIDE_PANEL_TAB_DEFAULT_ORDER, plugin]); });
tools = []; await render();
assert.ok(!model!.partition(TERMINAL_SIDE_PANEL_TAB_DEFAULT_ORDER).collapsed.includes(plugin));
tools = [plugin]; await render();
assert.ok(model!.partition([...TERMINAL_SIDE_PANEL_TAB_DEFAULT_ORDER, plugin]).collapsed.includes(plugin));
await act(async () => { root.unmount(); });
const nextRoot = createRoot(document.getElementById('root')!);
tools = []; await act(async () => { nextRoot.render(<Fixture />); });
tools = [plugin]; await act(async () => { nextRoot.render(<Fixture />); });
assert.ok(model!.partition([...TERMINAL_SIDE_PANEL_TAB_DEFAULT_ORDER, plugin]).collapsed.includes(plugin), 'cold catalog loading preserves preference');
await act(async () => { nextRoot.unmount(); });
} finally {
for (const [key, descriptor] of previous) { if (descriptor) Object.defineProperty(globalThis, key, descriptor); else Reflect.deleteProperty(globalThis, key); }
Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT'); dom.window.close();
}
});
+40 -4
View File
@@ -1,4 +1,4 @@
import { useCallback, useMemo } from 'react';
import { useCallback, useEffect, useMemo, useRef } from 'react';
import {
normalizeToolbarItemLayout,
@@ -13,7 +13,7 @@ import {
} from '../../infrastructure/config/storageKeys';
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
import { useToolbarItemLayout } from './useToolbarItemLayout';
import type { BuiltinSidePanelTool, SidePanelTool } from '../../domain/sidePanelLayout';
import { isPluginSidePanelTool, type BuiltinSidePanelTool, type SidePanelTool } from '../../domain/sidePanelLayout';
export type TerminalSidePanelTabId = SidePanelTool;
@@ -101,7 +101,7 @@ function migrateSidePanelTabLayoutIfNeeded(): void {
migrateSidePanelTabLayoutIfNeeded();
export function useTerminalSidePanelTabOrder(): {
export function useTerminalSidePanelTabOrder(pluginTools: readonly SidePanelTool[] = []): {
sidePanelTabOrder: TerminalSidePanelTabId[];
setSidePanelTabOrder: (order: TerminalSidePanelTabId[]) => void;
layout: ToolbarItemLayout;
@@ -111,6 +111,23 @@ export function useTerminalSidePanelTabOrder(): {
resetLayout: () => void;
partition: ReturnType<typeof useToolbarItemLayout>['partition'];
} {
// Keep preferences for temporarily unavailable/disabled plugins, including
// across a cold start before the contribution catalog finishes loading.
const knownPluginTools = useRef<Set<string> | null>(null);
if (!knownPluginTools.current) {
let stored: unknown;
try { stored = localStorageAdapter.read(STORAGE_KEY_TERMINAL_SIDE_PANEL_TAB_LAYOUT); } catch { stored = null; }
const order = stored && typeof stored === 'object' && !Array.isArray(stored)
? (stored as { order?: unknown }).order : [];
knownPluginTools.current = new Set(Array.isArray(order)
? order.filter((id): id is string => typeof id === 'string' && isPluginSidePanelTool(id)) : []);
}
for (const tool of pluginTools) if (isPluginSidePanelTool(tool)) knownPluginTools.current.add(tool);
const pluginKey = [...knownPluginTools.current].join('\0');
const defaults = useMemo<ToolbarItemLayoutDefaults>(() => {
const plugins = pluginKey ? pluginKey.split('\0') : [];
return { order: [TERMINAL_SIDE_PANEL_TAB_DEFAULT_ORDER[0], ...plugins, ...TERMINAL_SIDE_PANEL_TAB_DEFAULT_ORDER.slice(1)] };
}, [pluginKey]);
const {
layout,
setPlacement,
@@ -121,9 +138,28 @@ export function useTerminalSidePanelTabOrder(): {
partition,
} = useToolbarItemLayout(
STORAGE_KEY_TERMINAL_SIDE_PANEL_TAB_LAYOUT,
TERMINAL_SIDE_PANEL_TAB_LAYOUT_DEFAULTS,
defaults,
);
// Newly installed plugins get a visible slot near the start even when an
// older saved layout already exists. Existing choices always keep their order.
const activePluginKey = pluginTools.filter(isPluginSidePanelTool).join('\0');
useEffect(() => {
const active = activePluginKey ? activePluginKey.split('\0') : [];
if (!active.length || active.some((id) => !layout.order.includes(id))) return;
let stored: unknown;
try { stored = localStorageAdapter.read(STORAGE_KEY_TERMINAL_SIDE_PANEL_TAB_LAYOUT); } catch { return; }
const storedOrder = stored && typeof stored === 'object' && !Array.isArray(stored)
? (stored as { order?: unknown }).order : null;
const saved = new Set(Array.isArray(storedOrder) ? storedOrder : []);
const fresh = active.filter((id) => !saved.has(id));
if (!fresh.length) return;
const rest = layout.order.filter((id) => !fresh.includes(id));
const index = Math.max(0, rest.indexOf('sftp') + 1);
const next = [...rest.slice(0, index), ...fresh, ...rest.slice(index)];
if (next.some((id, i) => id !== layout.order[i])) setOrder(next);
}, [activePluginKey, layout.order, setOrder]);
const sidePanelTabOrder = layout.order as TerminalSidePanelTabId[];
const setSidePanelTabOrder = useCallback(
-119
View File
@@ -1,119 +0,0 @@
import { useEffect, useRef, useState } from 'react';
import { PluginViewLifecycleController, type HostedPluginViewState } from './pluginViewLifecycle';
import { resolvePluginViewWindowScope } from './pluginViewScopes';
import { isPluginViewOccluded } from './pluginViewOcclusion';
import type { UsePluginContributionsResult } from './usePluginContributions';
type ViewAdapter = Pick<UsePluginContributionsResult, 'openView' | 'closeView' | 'focusView' | 'setViewBounds' | 'setViewVisibility' | 'onViewClosed' | 'onViewFocused'>;
/** Host-owned mounting only. No plugin router, DOM, session or vault data API. */
export function useEmbeddedPluginView({ viewId, version, slot, active, context, adapter, subscribeHidden, unavailable, focused, focusOnReveal = false }: {
viewId: string;
version: string;
slot: string;
active: boolean;
context: Record<string, unknown>;
adapter: ViewAdapter;
subscribeHidden(listener: () => void): () => void;
unavailable(): void;
focused?(): void;
focusOnReveal?: boolean;
}) {
const mountRef = useRef<HTMLDivElement>(null);
const [loading, setLoading] = useState(true);
const adapterRef = useRef(adapter);
const unavailableRef = useRef(unavailable);
const focusedRef = useRef(focused);
const revealFocusRef = useRef(focusOnReveal);
const requestFocusRef = useRef<() => void>(() => {});
revealFocusRef.current = focusOnReveal;
focusedRef.current = focused;
adapterRef.current = adapter;
unavailableRef.current = unavailable;
useEffect(() => {
const mount = mountRef.current;
if (!active || !mount) return;
let cancelled = false;
const lifecycle = new PluginViewLifecycleController<HostedPluginViewState>();
const instanceId = crypto.randomUUID();
const scopeId = resolvePluginViewWindowScope({ pathname: window.location.pathname,
search: window.location.search, hash: `${window.location.hash}|plugin-slot:${slot}` });
const { openView, closeView, focusView, setViewBounds, setViewVisibility, onViewClosed, onViewFocused } = adapterRef.current;
const close = () => {
if (cancelled) return;
cancelled = true;
lifecycle.markViewClosed(viewId);
void closeView(instanceId).catch(() => {});
};
const failCurrent = () => {
if (cancelled) return;
close();
unavailableRef.current();
};
const unsubscribe = onViewClosed((event) => {
lifecycle.handleHostClose(event.instanceId);
if (event.instanceId === instanceId && !cancelled) {
close();
unavailableRef.current();
}
});
const stopOnHide = subscribeHidden(close);
const stopOnFocus = onViewFocused((event) => {
if (!cancelled && event.instanceId === instanceId) focusedRef.current?.();
});
const bounds = () => {
const rect = mount.getBoundingClientRect();
return { x: Math.round(rect.x), y: Math.round(rect.y), width: Math.max(1, Math.round(rect.width)), height: Math.max(1, Math.round(rect.height)) };
};
let opened = false;
let lastBounds = '';
let visible = false;
const focus = () => {
if (!cancelled && opened && visible && revealFocusRef.current && !isPluginViewOccluded(mount)) void focusView(instanceId).catch(() => {});
};
requestFocusRef.current = focus;
const updateVisibility = () => {
if (!opened || cancelled) return;
const rect = mount.getBoundingClientRect();
const next = rect.width > 0 && rect.height > 0 && !isPluginViewOccluded(mount);
if (visible === next) return;
visible = next;
void setViewVisibility(instanceId, next).then(() => { if (next) focus(); }).catch(failCurrent);
};
const resize = () => {
if (opened && !cancelled) {
const next = bounds();
const key = JSON.stringify(next);
if (key !== lastBounds) { lastBounds = key; void setViewBounds(instanceId, next).catch(failCurrent); }
}
updateVisibility();
};
const observer = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(resize);
observer?.observe(mount);
const mutations = new MutationObserver(resize);
mutations.observe(document.body, { childList: true, subtree: true, attributes: true,
attributeFilter: ['class', 'style', 'hidden', 'inert', 'role', 'aria-modal', 'aria-hidden', 'data-state', 'data-app-lock-overlay', 'data-radix-popper-content-wrapper', 'data-native-view-occluder'] });
window.addEventListener('resize', resize);
document.addEventListener('scroll', resize, true);
setLoading(true);
const opening = lifecycle.beginOpen({ viewKey: viewId });
void openView({ viewId, instanceId, scopeId, bounds: bounds(), context }).then(async (result) => {
if (cancelled || lifecycle.shouldCloseOpen(opening) || lifecycle.consumeHostClose(result.instanceId)) {
await closeView(result.instanceId);
return;
}
opened = true;
lifecycle.setCurrent({ id: result.instanceId, viewId, scopeId, retainContextWhenHidden: false });
resize();
setLoading(false);
}).catch(() => {
if (!cancelled) { close(); unavailableRef.current(); }
}).finally(() => lifecycle.finishOpen({ token: opening, viewKey: viewId }));
return () => {
close(); unsubscribe(); stopOnHide(); stopOnFocus(); observer?.disconnect(); mutations.disconnect();
window.removeEventListener('resize', resize); document.removeEventListener('scroll', resize, true);
};
}, [active, context, slot, subscribeHidden, version, viewId]);
useEffect(() => { if (focusOnReveal) requestFocusRef.current(); }, [focusOnReveal]);
return { mountRef, loading };
}
@@ -0,0 +1,42 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
import { resolvePluginViewWindowScope } from './pluginViewScopes';
import { pluginViewSessions, type PluginViewSnapshot } from './pluginViewSession';
import type { HostDrawnViewEvent } from '@netcatty/plugin-contract';
const initial = (): PluginViewSnapshot => ({ tree: { children: [] }, css: '', loading: true, error: false, settled: true });
export function useHostDrawnPluginView({ viewId, version, slot, active, context, subscribeHidden, unavailable }: {
viewId: string; version: string; slot: string; active: boolean; context: Record<string, unknown>;
subscribeHidden(listener: () => void): () => void; unavailable(): void;
}) {
const contextKey = JSON.stringify(context);
const key = JSON.stringify([viewId, version, slot, contextKey]);
const [state, setState] = useState<{ key: string; snapshot: PluginViewSnapshot }>({ key: '', snapshot: initial() });
const [hidden, setHidden] = useState(false);
const unavailableRef = useRef(unavailable); unavailableRef.current = unavailable;
const sessionRef = useRef<{ key: string; image(path: string): Promise<string>; send(event: HostDrawnViewEvent): void } | null>(null);
useEffect(() => {
if (!active) return;
const bridge = netcattyBridge.get();
if (!bridge) { unavailableRef.current(); return; }
const scopeId = resolvePluginViewWindowScope({ pathname: window.location.pathname, search: window.location.search, hash: `${window.location.hash}|plugin-slot:${slot}` });
try {
const session = pluginViewSessions.acquire(JSON.stringify([scopeId, key]), bridge, { viewId, scopeId, context: JSON.parse(contextKey) });
const update = () => { const snapshot = session.getSnapshot(); setState({ key, snapshot }); if (snapshot.error) unavailableRef.current(); };
const stop = session.subscribe(update); update(); setHidden(false);
sessionRef.current = { key, send: session.send, image: session.image };
const stopHidden = subscribeHidden(() => { sessionRef.current = null; setHidden(true); });
return () => { sessionRef.current = null; stopHidden(); stop(); session.release(); };
} catch { unavailableRef.current(); }
}, [active, contextKey, key, slot, subscribeHidden, viewId]);
const loadImage = useCallback((path: string) => {
const session = sessionRef.current;
return active && session?.key === key ? session.image(path) : Promise.reject(new Error('Plugin view unavailable'));
}, [active, key]);
const sendEvent = useCallback((event: HostDrawnViewEvent) => {
const session = sessionRef.current; if (active && session?.key === key) session.send(event);
}, [active, key]);
// A newly selected plugin must never receive values from the previous tree,
// even during the render before effect cleanup and the next activation.
return { ...(state.key === key ? state.snapshot : initial()), visible: active && !hidden, loadImage, sendEvent };
}
@@ -86,14 +86,8 @@ export interface UsePluginContributionsResult {
updateSetting(pluginId: string, settingId: string, value: unknown, scopeId?: string): Promise<{ restartRequired: boolean }>;
resetSetting(pluginId: string, settingId: string, scopeId?: string): Promise<{ restartRequired: boolean }>;
selectSettingPath(kind: 'file' | 'directory', title: string, defaultPath?: string): Promise<string | null>;
openView(payload: NetcattyPluginViewOpenRequest): Promise<{ instanceId: string }>;
closeView(instanceId: string): Promise<void>;
focusView(instanceId: string): Promise<void>;
setViewBounds(instanceId: string, bounds: { x: number; y: number; width: number; height: number }): Promise<void>;
setViewVisibility(instanceId: string, visible: boolean): Promise<void>;
setEnvironment(environment: NetcattyPluginEnvironment): Promise<void>;
onViewClosed(callback: (event: NetcattyPluginViewClosedEvent) => void): () => void;
onViewFocused(callback: (event: import('@netcatty/plugin-contract').PluginViewFocusEvent) => void): () => void;
}
export function usePluginContributions(
@@ -189,32 +183,6 @@ export function usePluginContributions(
return picker(title, defaultPath);
}, [bridge]);
const openView = useCallback(async (payload: NetcattyPluginViewOpenRequest) => {
if (!bridge?.openPluginView) throw new Error('Plugin views are unavailable');
return bridge.openPluginView(payload);
}, [bridge]);
const closeView = useCallback(async (instanceId: string) => {
if (!bridge?.closePluginView) return;
await bridge.closePluginView(instanceId);
}, [bridge]);
const focusView = useCallback(async (instanceId: string) => {
await bridge?.focusPluginView?.({ instanceId });
}, [bridge]);
const setViewBounds = useCallback(async (
instanceId: string,
bounds: { x: number; y: number; width: number; height: number },
) => {
if (!bridge?.setPluginViewBounds) return;
await bridge.setPluginViewBounds(instanceId, bounds);
}, [bridge]);
const setViewVisibility = useCallback(async (instanceId: string, visible: boolean) => {
if (!bridge?.setPluginViewVisibility) return;
await bridge.setPluginViewVisibility(instanceId, visible);
}, [bridge]);
const setEnvironment = useCallback(async (environment: NetcattyPluginEnvironment) => {
if (!bridge?.setPluginEnvironment) return;
await bridge.setPluginEnvironment(environment);
@@ -223,10 +191,6 @@ export function usePluginContributions(
const onViewClosed = useCallback((callback: (event: NetcattyPluginViewClosedEvent) => void) => (
bridge?.onPluginViewClosed?.(callback) ?? (() => {})
), [bridge]);
const onViewFocused = useCallback((callback: (event: import('@netcatty/plugin-contract').PluginViewFocusEvent) => void) => (
bridge?.onPluginViewFocused?.(callback) ?? (() => {})
), [bridge]);
const currentLoadState = resolvePluginContributionLoadState({
currentQueryKey: queryKey,
loadedQueryKey: loadedSnapshot.queryKey,
@@ -245,13 +209,7 @@ export function usePluginContributions(
updateSetting,
resetSetting,
selectSettingPath,
openView,
closeView,
focusView,
setViewBounds,
setViewVisibility,
setEnvironment,
onViewClosed,
onViewFocused,
};
}
+6 -4
View File
@@ -38,18 +38,20 @@ export function usePluginManager() {
const refreshGeneration = generation;
return () => { refreshGeneration.current++; unsubscribe?.(); window.removeEventListener('netcatty:plugin-packages-changed', update); };
}, [refresh]);
const run = useCallback(async (action: (bridge: NetcattyBridge) => Promise<unknown>) => {
const run = useCallback(async (action: (bridge: NetcattyBridge) => Promise<unknown>, throwOnError = false) => {
if (mutation.current) return;
mutation.current = true;
setBusy(true);
setError(null);
let failure: Error | null = null;
try { await action(netcattyBridge.require()); }
catch (cause) { setError(cause instanceof Error ? cause.message : String(cause)); }
catch (cause) { failure = cause instanceof Error ? cause : new Error(String(cause)); setError(failure.message); }
finally {
try { await refresh(); } catch (cause) { setError(String(cause)); }
try { await refresh(); } catch (cause) { failure ??= cause instanceof Error ? cause : new Error(String(cause)); setError(failure.message); }
mutation.current = false;
setBusy(false);
}
if (throwOnError && failure) throw failure;
}, [refresh]);
return {
plugins, available, statusKnown, busy, error, credentialStorageAvailable, refresh,
@@ -70,6 +72,6 @@ export function usePluginManager() {
if (!bridge.resetPluginSource) throw new Error('Private source reset unavailable');
await bridge.resetPluginSource(id, source.id);
}
}),
}, true),
};
}
@@ -0,0 +1,22 @@
import { useEffect, useMemo, useState } from 'react';
import { useI18n } from '../i18n/I18nProvider';
import { usePluginContributions } from './usePluginContributions';
import { resolvePluginSettingScopeSelection, usePluginSettingScopeCatalog } from './usePluginSettingScopeCatalog';
export function usePluginSettingsModel(pluginId: string) {
const { locale } = useI18n();
const catalog = usePluginSettingScopeCatalog();
const [scopeIds, setScopeIds] = useState<Partial<Record<NetcattyPluginSettingScopeKind, string>>>({});
useEffect(() => {
setScopeIds(current => resolvePluginSettingScopeSelection(catalog, current));
}, [catalog]);
const query = useMemo<NetcattyPluginContributionQuery>(() => ({
locale, context: { 'netcatty.surface': 'settings' }, scopeIds,
}), [locale, scopeIds]);
const contributions = usePluginContributions(query);
const plugin = contributions.snapshot.plugins.find(item => item.id === pluginId);
const settings = plugin?.settings.filter(setting => setting.visible) ?? [];
const contextualScopes = [...new Set(settings.map(setting => setting.scope)
.filter((scope): scope is NetcattyPluginSettingScopeKind => scope !== 'application'))];
return { ...contributions, plugin, settings, catalog, scopeIds, setScopeIds, contextualScopes };
}
+9 -209
View File
@@ -14,19 +14,12 @@ import {
usePluginViewTabs,
} from './pluginViewTabStore';
import {
PluginViewLifecycleController,
reconcilePluginViewTabCatalog,
resolvePluginViewSnapshotSelection,
type HostedPluginViewState,
type PluginViewSnapshotSelection,
} from './pluginViewLifecycle';
import {
canRetainPluginViewInScope,
resolvePluginRetainedViewKey,
resolvePluginViewWindowScope,
} from './pluginViewScopes';
import { pluginViewSessions } from './pluginViewSession';
import { usePluginContributions } from './usePluginContributions';
import { observePluginHostSecurityVisibility } from './pluginHostSecurityVisibility';
export const OPEN_PLUGIN_VIEW_EVENT = 'netcatty:open-plugin-view';
@@ -70,17 +63,8 @@ export function usePluginViewLifecycle({
const {
snapshot,
executeCommand,
openView,
closeView,
setViewBounds,
setViewVisibility,
setEnvironment,
onViewClosed,
} = contributions;
const [instance, setInstance] = useState<HostedPluginViewState | null>(null);
const lifecycleRef = useRef(new PluginViewLifecycleController<HostedPluginViewState>());
const closeViewRef = useRef(closeView);
const mountRef = useRef<HTMLDivElement>(null);
const resolvedActiveView = useMemo(() => viewContributions.snapshot.plugins
.flatMap((plugin) => plugin.views.map((view) => ({ plugin, view })))
.find(({ view }) => view.id === effectiveRequested?.viewId && view.visible && !['vault-page', 'terminal-sidebar'].includes(view.location)) ?? null,
@@ -93,16 +77,6 @@ export function usePluginViewLifecycle({
requestedViewId: effectiveRequested?.viewId,
contextKey: viewQueryContextKey,
});
const activeViewId = activeView?.view.id;
const viewScopeId = typeof window === 'undefined'
? 'window:server'
: resolvePluginViewWindowScope(window.location);
const retainedViewKey = activeViewId
? resolvePluginRetainedViewKey(activeViewId, viewScopeId)
: null;
useEffect(() => { closeViewRef.current = closeView; }, [closeView]);
useEffect(() => {
if (viewContributions.loading) return;
stableActiveViewRef.current = resolvedActiveView && effectiveRequested?.viewId
@@ -114,26 +88,6 @@ export function usePluginViewLifecycle({
: null;
}, [effectiveRequested?.viewId, resolvedActiveView, viewContributions.loading, viewQueryContextKey]);
useEffect(() => onViewClosed((event) => {
const next = lifecycleRef.current.handleHostClose(event.instanceId);
if (next.matchedCurrent) {
setInstance(null);
setRequested(null);
}
if (next.closedTabId) pluginViewTabStore.close(next.closedTabId);
}), [onViewClosed]);
useEffect(() => pluginViewTabStore.onDidClose(({ tab }) => {
const next = lifecycleRef.current.handleTabClose(tab.id);
if (next.matchedCurrent) {
setInstance(null);
setRequested(null);
}
for (const instanceId of next.instanceIds) {
void closeViewRef.current(instanceId).catch(() => {});
}
}), []);
useEffect(() => {
const bindings = snapshot.plugins.flatMap((plugin) => plugin.keybindings)
.filter((binding) => binding.enabled)
@@ -182,164 +136,16 @@ export function usePluginViewLifecycle({
setRequested(null);
}, [activeView, requested]);
const hideOrClose = useCallback(async (current: HostedPluginViewState) => {
if (!current.retainContextWhenHidden) {
await closeView(current.id);
return;
}
const key = resolvePluginRetainedViewKey(current.viewId, current.scopeId);
lifecycleRef.current.retain(key, current);
try {
await setViewVisibility(current.id, false);
} catch {
lifecycleRef.current.removeRetained(key);
await closeView(current.id);
}
}, [closeView, setViewVisibility]);
useEffect(() => {
const stop = pluginViewTabStore.onDidClose(({ tab }) => pluginViewSessions.closeView(tab.viewId));
return () => { stop(); };
}, []);
const close = useCallback(async () => {
lifecycleRef.current.markViewClosed(retainedViewKey);
const current = lifecycleRef.current.takeCurrent();
setInstance(null);
setRequested(null);
if (current) await closeView(current.id);
}, [closeView, retainedViewKey]);
useEffect(() => {
const removed = lifecycleRef.current.removeRetainedWhere(
(retained) => !canRetainPluginViewInScope(retained.scopeId, viewScopeId),
);
for (const retained of removed) void closeView(retained.id).catch(() => {});
}, [closeView, viewScopeId]);
useEffect(() => {
if (!instance) return;
if (effectiveRequested?.viewId === instance.viewId
&& activeViewId === instance.viewId
&& instance.scopeId === viewScopeId) return;
lifecycleRef.current.takeCurrent();
setInstance(null);
if (canRetainPluginViewInScope(instance.scopeId, viewScopeId)) {
void hideOrClose(instance);
} else {
void closeView(instance.id).catch(() => {});
}
}, [activeViewId, closeView, effectiveRequested?.viewId, hideOrClose, instance, viewScopeId]);
useEffect(() => {
if (!activeViewId || !retainedViewKey || !mountRef.current || instance) return;
let cancelled = false;
const openingTabId = activePluginTab?.id;
const openingToken = lifecycleRef.current.beginOpen({
viewKey: retainedViewKey,
tabId: openingTabId,
label: activePluginTab?.id ?? activeViewId,
});
const bounds = mountRef.current.getBoundingClientRect();
const nextBounds = {
x: Math.round(bounds.x),
y: Math.round(bounds.y),
width: Math.max(1, Math.round(bounds.width)),
height: Math.max(1, Math.round(bounds.height)),
};
void (async () => {
let opened = lifecycleRef.current.takeRetained(retainedViewKey);
if (opened) {
try {
await setViewBounds(opened.id, nextBounds);
await setViewVisibility(opened.id, true);
} catch {
try { await closeView(opened.id); } catch { /* best-effort cleanup after restore failure */ }
opened = null;
}
}
if (!opened) {
const result = await openView({
viewId: activeViewId,
scopeId: viewScopeId,
bounds: nextBounds,
context: effectiveRequested?.context,
});
opened = {
id: result.instanceId,
viewId: activeViewId,
scopeId: viewScopeId,
retainContextWhenHidden: activeView.view.retainContextWhenHidden === true,
...(activePluginTab ? { tabId: activePluginTab.id } : {}),
};
}
if (lifecycleRef.current.consumeHostClose(opened.id)) {
throw new Error('Plugin view closed while its open response was in flight');
}
if (lifecycleRef.current.shouldCloseOpen(openingToken)) {
await closeView(opened.id);
return;
}
if (cancelled) {
await hideOrClose(opened);
return;
}
lifecycleRef.current.setCurrent(opened);
setInstance(opened);
})().catch(() => {
if (cancelled) return;
if (activePluginTab) pluginViewTabStore.close(activePluginTab.id);
else setRequested(null);
}).finally(() => {
lifecycleRef.current.finishOpen({
token: openingToken,
viewKey: retainedViewKey,
tabId: openingTabId,
});
});
return () => { cancelled = true; };
}, [
activePluginTab,
activeView?.view.retainContextWhenHidden,
activeViewId,
closeView,
effectiveRequested?.context,
hideOrClose,
instance,
openView,
retainedViewKey,
setViewBounds,
setViewVisibility,
viewScopeId,
]);
useEffect(() => {
if (!instance || !mountRef.current) return;
let disposed = false;
const stop = observePluginHostSecurityVisibility(mountRef.current, (visible) => {
void setViewVisibility(instance.id, visible).catch(() => {
if (!disposed) void closeView(instance.id).catch(() => {});
});
});
return () => { disposed = true; stop(); };
}, [closeView, instance, setViewVisibility]);
useEffect(() => {
if (!instance || !mountRef.current) return;
const update = () => {
const bounds = mountRef.current?.getBoundingClientRect();
if (!bounds) return;
void setViewBounds(instance.id, {
x: Math.round(bounds.x),
y: Math.round(bounds.y),
width: Math.max(1, Math.round(bounds.width)),
height: Math.max(1, Math.round(bounds.height)),
});
};
const observer = new ResizeObserver(update);
observer.observe(mountRef.current);
window.addEventListener('resize', update);
update();
return () => {
observer.disconnect();
window.removeEventListener('resize', update);
};
}, [instance, setViewBounds]);
if (activeView) pluginViewSessions.closeView(activeView.view.id);
if (activePluginTab) pluginViewTabStore.close(activePluginTab.id);
}, [activePluginTab, activeView]);
useEffect(() => {
if (!contributions.available) return;
@@ -376,16 +182,10 @@ export function usePluginViewLifecycle({
};
}, [contributions.available, locale, setEnvironment, suppliedThemeTokens, theme]);
useEffect(() => () => {
const views = lifecycleRef.current.drain();
const ids = new Set(views.map((view) => view.id));
for (const id of ids) void closeViewRef.current(id);
}, []);
return {
activeView,
close,
effectiveRequested,
mountRef,
viewQueryContext,
};
}
@@ -63,6 +63,9 @@ export function useToolbarItemLayout(
const [layout, setLayout] = useState<ToolbarItemLayout>(() => readLayout(storageKey, defaults));
useEffect(() => {
// Defaults can include dynamically discovered items; re-read without writing
// so temporary catalog changes never erase stored toolbar preferences.
setLayout(readLayout(storageKey, defaults));
const sync = (event: Event) => {
if (event instanceof StorageEvent && event.key !== storageKey) return;
if (event instanceof CustomEvent && event.detail?.key !== storageKey) return;
+7 -7
View File
@@ -147,8 +147,8 @@ test('cold plugin host reaches the local fixture through the actual main provide
await service.manager.initialize();
const staging = path.join(fixtureRoot, 'package'); fs.mkdirSync(path.join(staging, 'dist'), { recursive: true });
const sample = path.join(rootPath, 'examples/plugins/assistant');
for (const file of ['netcatty.plugin.json', 'panel.html', 'panel.css', 'panel.js', 'README.md']) fs.copyFileSync(path.join(sample, file), path.join(staging, file));
fs.copyFileSync(path.join(sample, 'src/index.js'), path.join(staging, 'dist/index.js'));
for (const file of ['netcatty.plugin.json', 'README.md']) fs.copyFileSync(path.join(sample, file), path.join(staging, file));
fs.copyFileSync(path.join(sample, 'dist/host.js'), path.join(staging, 'dist/host.js'));
const { buildPluginPackage } = await import('@netcatty/plugin-cli');
const archive = path.join(fixtureRoot, 'assistant.ncpkg'); await buildPluginPackage(staging, archive);
service.database.setPermissionMode('com.netcatty.assistant', 'context.selected.read', 'ask');
@@ -161,13 +161,13 @@ test('cold plugin host reaches the local fixture through the actual main provide
await act(async () => { await new Promise((resolve) => setTimeout(resolve, 5)); });
}
};
panel = new JSDOM(fs.readFileSync(path.join(sample, 'panel.html'), 'utf8'), { runScripts: 'outside-only' });
Object.defineProperty(panel.window, 'netcattyView', { value: {
panel = new JSDOM(fs.readFileSync(path.join(sample, 'view.html'), 'utf8'), { runScripts: 'outside-only' });
Object.defineProperty(panel.window, 'pluginContext', { value: { commands: {
executeCommand: async (command: string, args: unknown) => structuredClone(await service!.contributionService.executeCommand(command, structuredClone(args))),
onDidReceiveMessage: () => ({ dispose() {} }),
}, views: { onDidReceiveMessage: () => ({ dispose() {} }) }
} });
diagnosticPhase = 'initial-selection';
await act(async () => panel!.window.eval(fs.readFileSync(path.join(sample, 'panel.js'), 'utf8')));
await act(async () => panel!.window.eval(fs.readFileSync(path.join(sample, 'panel.js'), 'utf8').replace('export function mount', 'function mount') + '\nwindow.disposePanel = mount(document.body, window.pluginContext, "test", "test");'));
await waitFor(() => document.getElementById('plugin-ai-note') !== null);
const noteChoice = document.getElementById('plugin-ai-note') as HTMLSelectElement;
assert.equal(noteChoice.options.length, 2);
@@ -209,7 +209,7 @@ test('cold plugin host reaches the local fixture through the actual main provide
console.error = originalConsoleError;
// JSDOM.close does not deliver the browser pagehide lifecycle. Invalidate
// the sample's pending callbacks before teardown closes its document.
if (panel) { panel.window.dispatchEvent(new panel.window.Event('pagehide')); panel.window.close(); }
if (panel) { panel.window.disposePanel(); panel.window.close(); }
await act(async () => root.unmount());
await service?.manager.shutdown(); aiBridge.cleanup();
publishNotesSnapshot(oldNotes); registerAppAppLockRuntime(oldLock);
+8 -8
View File
@@ -90,8 +90,8 @@ test('installed global Chat UI reuses actual Catty runtime, configured HTTP tran
electron: { ipcMain }, getInteractionWindow: () => mainWindow, isAppLocked: () => false, getTerminalWorkerManager: () => worker });
service.runtimeSupervisor.runtimeFactories.browser = inProcessBrowserRuntime; await service.manager.initialize();
const sample = path.join(rootPath, 'examples/plugins/global-agent'); const staging = path.join(directory, 'package'); fs.mkdirSync(path.join(staging, 'dist'), { recursive: true });
for (const file of ['netcatty.plugin.json', 'panel.html', 'panel.css', 'panel.js', 'README.md']) fs.copyFileSync(path.join(sample, file), path.join(staging, file));
fs.copyFileSync(path.join(sample, 'src/index.js'), path.join(staging, 'dist/index.js'));
for (const file of ['netcatty.plugin.json', 'README.md']) fs.copyFileSync(path.join(sample, file), path.join(staging, file));
fs.copyFileSync(path.join(sample, 'dist/host.js'), path.join(staging, 'dist/host.js'));
const { buildPluginPackage } = await import('@netcatty/plugin-cli'); const archive = path.join(directory, 'global-agent.ncpkg'); await buildPluginPackage(staging, archive);
await service.manager.install(archive, { enable: true });
await act(async () => root.render(<I18nProvider locale="en"><PluginAgentHost sessions={[
@@ -104,14 +104,14 @@ test('installed global Chat UI reuses actual Catty runtime, configured HTTP tran
} };
let viewOpen = true;
const mountPanel = async () => {
panel = new JSDOM(fs.readFileSync(path.join(sample, 'panel.html'), 'utf8'), { runScripts: 'outside-only' });
Object.defineProperty(panel.window, 'netcattyView', { value: { executeCommand: async (name: string, params: unknown) => {
panel = new JSDOM(fs.readFileSync(path.join(sample, 'view.html'), 'utf8'), { runScripts: 'outside-only' });
Object.defineProperty(panel.window, 'pluginContext', { value: { commands: { executeCommand: async (name: string, params: unknown) => {
// PluginViewHost.close removes sender authority before WebContents
// closes. A pagehide RPC cannot be relied on to reach this relay.
if (!viewOpen) throw Error('Untrusted plugin view sender');
const result = structuredClone(await service!.contributionService.executeCommand(name, structuredClone(params))); pluginReplies.push(result); return result;
}, onDidReceiveMessage: () => ({ dispose() {} }), onDidChangeEnvironment: () => ({ dispose() {} }) } });
await act(async () => panel!.window.eval(fs.readFileSync(path.join(sample, 'panel.js'), 'utf8')));
} }, views: { onDidReceiveMessage: () => ({ dispose() {} }) } } });
await act(async () => panel!.window.eval(fs.readFileSync(path.join(sample, 'panel.js'), 'utf8').replace('export function mount', 'function mount') + '\nwindow.disposePanel = mount(document.body, window.pluginContext, "test", "test");'));
};
await mountPanel();
const element = (id: string) => panel!.window.document.getElementById(id)!;
@@ -135,7 +135,7 @@ test('installed global Chat UI reuses actual Catty runtime, configured HTTP tran
const reference = pluginReplies.find((value) => value && typeof value === 'object' && 'conversationRef' in value && 'chatHandle' in value) as { conversationRef: string };
const beforeReopen = counts.requests;
viewOpen = false;
panel!.window.dispatchEvent(new panel!.window.Event('pagehide')); panel!.window.close();
panel!.window.disposePanel(); panel!.window.close();
viewOpen = true; await mountPanel();
await waitFor(() => element('status').textContent!.startsWith('Choose terminals'));
(element('conversation') as HTMLSelectElement).value = reference.conversationRef;
@@ -182,7 +182,7 @@ test('installed global Chat UI reuses actual Catty runtime, configured HTTP tran
assert.deepEqual(errors, []);
} finally {
console.error = originalError;
if (panel) { panel.window.dispatchEvent(new panel.window.Event('pagehide')); panel.window.close(); }
if (panel) { panel.window.disposePanel(); panel.window.close(); }
await act(async () => root.unmount()); await service?.manager.shutdown();
mcp.setPermissionMode('confirm'); mcp.cleanup(); aiBridge.cleanup(); privacy.configure({ enabled: false }); registerAppAppLockRuntime(oldLock);
server.closeAllConnections(); await new Promise<void>((resolve) => server.close(() => resolve())); dom.window.close();
+21 -1
View File
@@ -5,6 +5,27 @@ import { JSDOM } from 'jsdom';
import { createRequire } from 'node:module';
const require = createRequire(import.meta.url);
// Test-only observer preserves the security-surface visibility assertions.
function observePluginHostSecurityVisibility(mount: HTMLElement, publish: (visible: boolean) => void): () => void {
const document = mount.ownerDocument;
let visible = true;
const update = () => {
const blocked = [...document.querySelectorAll('[data-host-security-surface]')].some((surface) => {
const style = document.defaultView!.getComputedStyle(surface);
return style.display !== 'none' && style.visibility !== 'hidden'
&& !surface.closest('[hidden], [inert], [aria-hidden="true"]');
});
if (visible === !blocked) return;
visible = !blocked;
publish(visible);
};
const observer = new document.defaultView!.MutationObserver(update);
observer.observe(document.body, { childList: true, subtree: true, attributes: true,
attributeFilter: ['class', 'style', 'hidden', 'inert', 'aria-hidden', 'data-host-security-surface'] });
update();
return () => observer.disconnect();
}
test('registered Ask uses one in-app dialog; policy controls apply Allow and Deny with no hidden prompt', async () => {
const dom = new JSDOM('<!doctype html><html><body><div id="root"></div></body></html>', { pretendToBeVisual: true, url: 'http://localhost' });
const previous = new Map<string, PropertyDescriptor | undefined>();
@@ -25,7 +46,6 @@ test('registered Ask uses one in-app dialog; policy controls apply Allow and Den
const { I18nProvider } = await import('../application/i18n/I18nProvider');
const { PluginPermissionHost } = await import('./plugins/PluginPermissionHost');
const { PluginPermissionPanel } = await import('./settings/tabs/PluginPermissionPanel');
const { observePluginHostSecurityVisibility } = await import('../application/state/pluginHostSecurityVisibility');
const { PluginPermissionDecisionHost } = require('../electron/plugins/permissionDecisionHost.cjs');
const { PluginPermissionEngine } = require('../electron/plugins/permissionEngine.cjs');
const { PluginHostRpcRegistry } = require('../electron/plugins/hostRpcRegistry.cjs');
@@ -69,8 +69,8 @@ test('registered selected-summary Ask is one host chooser; Allow still selects a
await service.manager.initialize();
const stage = path.join(fixtureRoot, 'package'); fs.mkdirSync(path.join(stage, 'dist'), { recursive: true });
const sample = path.resolve('examples/plugins/assistant');
for (const file of ['netcatty.plugin.json', 'panel.html', 'panel.css', 'panel.js', 'README.md']) fs.copyFileSync(path.join(sample, file), path.join(stage, file));
fs.copyFileSync(path.join(sample, 'src/index.js'), path.join(stage, 'dist/index.js'));
for (const file of ['netcatty.plugin.json', 'README.md']) fs.copyFileSync(path.join(sample, file), path.join(stage, file));
fs.copyFileSync(path.join(sample, 'dist/host.js'), path.join(stage, 'dist/host.js'));
const { buildPluginPackage } = await import('@netcatty/plugin-cli');
const archive = path.join(fixtureRoot, 'assistant.ncpkg'); await buildPluginPackage(stage, archive); await service.manager.install(archive, { enable: true });
await act(async () => root.render(<I18nProvider locale="en"><PluginPermissionHost /><PluginAIContextHost sessions={[]} workspaces={[]} /></I18nProvider>));
+21 -35
View File
@@ -14,11 +14,12 @@ test('terminal extension pages use actual split panes, focus and session ownersh
install('PointerEvent', dom.window.MouseEvent);
install('getComputedStyle', dom.window.getComputedStyle.bind(dom.window));
install('requestAnimationFrame', dom.window.requestAnimationFrame.bind(dom.window)); install('cancelAnimationFrame', dom.window.cancelAnimationFrame.bind(dom.window)); install('IS_REACT_ACT_ENVIRONMENT', true);
let rectX = 200;
const rectX = 200;
dom.window.HTMLElement.prototype.getBoundingClientRect = () => new dom.window.DOMRect(rectX, 100, 400, 300);
const { default: React, act, useCallback, useRef, useState } = await import('react');
const { createRoot } = await import('react-dom/client');
const { I18nProvider } = await import('../application/i18n/I18nProvider');
const { TooltipProvider } = await import('./ui/tooltip');
const { activeTabStore } = await import('../application/state/activeTabStore');
const { sidePanelLiveStore, SIDE_PANEL_INACTIVE_LIVE_SNAPSHOT } = await import('../application/state/sidePanelLiveStore');
const { useTerminalSidePanelLayoutState } = await import('../application/state/useTerminalSidePanelLayoutState');
@@ -30,22 +31,17 @@ test('terminal extension pages use actual split panes, focus and session ownersh
type Tool = import('../domain/sidePanelLayout').SidePanelTool;
let selectedSession = 'synthetic-session-a';
const getSession = () => selectedSession;
const a = { id: 'com.example.sidebar.first', title: 'First page', location: 'terminal-sidebar', entry: 'page.html', visible: true };
const a = { id: 'com.example.sidebar.first', title: 'First page', location: 'terminal-sidebar', visible: true };
const b = { ...a, id: 'com.example.sidebar.second', title: 'Second page' };
let snapshot = { locale: 'en', plugins: [{ id: 'com.example.sidebar', displayName: 'Synthetic plugin', version: '1.0.0', commands: [], keybindings: [], menus: [], settings: [], views: [a, b] }] };
const changed = new Set<() => void>(), focusListeners = new Set<(event: { instanceId: string }) => void>();
const opens: NetcattyPluginViewOpenRequest[] = [], closes: string[] = [], focus: string[] = [], bounds: Array<{ instanceId: string; x: number }> = [];
const visible = new Map<string, boolean>();
const changed = new Set<() => void>();
const opens: NetcattyPluginViewOpenRequest[] = [];
Object.assign(dom.window, { netcatty: {
async getPluginRuntimeStatus() { return { available: true }; }, async getPluginContributions() { return snapshot; },
onPluginContributionsChanged(fn: () => void) { changed.add(fn); return () => { changed.delete(fn); }; },
onPluginViewClosed() { return () => {}; },
onPluginViewFocused(fn: (event: { instanceId: string }) => void) { focusListeners.add(fn); return () => { focusListeners.delete(fn); }; },
async openPluginView(payload: NetcattyPluginViewOpenRequest) { opens.push(payload); return { instanceId: payload.instanceId! }; },
async closePluginView(id: string) { closes.push(id); },
async setPluginViewBounds(id: string, rect: { x: number }) { bounds.push({ instanceId: id, x: rect.x }); },
async setPluginViewVisibility(id: string, value: boolean) { visible.set(id, value); },
async focusPluginView(event: { instanceId: string }) { focus.push(event.instanceId); for (const fn of focusListeners) fn(event); },
onPluginViewClosed() { return () => {}; }, onHostDrawnPluginViewUpdate() { return () => {}; },
async openHostDrawnPluginView(payload: NetcattyPluginViewOpenRequest) { opens.push(payload); return { instanceId: payload.instanceId! }; },
async closeHostDrawnPluginView() {},
} });
let split!: () => void;
function Fixture() {
@@ -75,54 +71,44 @@ test('terminal extension pages use actual split panes, focus and session ownersh
const page = model.pages.find((item) => item.tool === pane.tool); const target = hosts.get(pane.tool) ?? parking.current;
if (!page || !target) return null;
return <PersistentSidePanelPortal key={`${pane.id}:${model.sessionId}`} portalKey={`plugin-${pane.id}`} target={target}>
<PluginTerminalSidebarPageHost page={page} tabId={model.tabId!} sessionId={model.sessionId} getSession={getSession} adapter={model.contributions}
focusOnReveal={layout?.focusedPaneId === pane.id} focused={() => focusPane(pane.id)} unavailable={() => closePane(pane.id)} />
<PluginTerminalSidebarPageHost page={page} tabId={model.tabId!} sessionId={model.sessionId} getSession={getSession} showHeader={panes.length === 1} focused={() => focusPane(pane.id)} unavailable={() => closePane(pane.id)} />
</PersistentSidePanelPortal>;
})}</>;
}
const root = createRoot(document.getElementById('root')!); const settle = () => new Promise((resolve) => setTimeout(resolve, 40));
try {
activeTabStore.setActiveTabId('workspace-synthetic'); sidePanelLiveStore.update({ ...SIDE_PANEL_INACTIVE_LIVE_SNAPSHOT, focusedSessionId: selectedSession });
await act(async () => { root.render(<I18nProvider locale="en"><Fixture /></I18nProvider>); await settle(); });
await act(async () => { document.querySelector<HTMLButtonElement>('button[aria-label="Extension pages"]')!.click(); await settle(); });
const firstButton = [...document.querySelectorAll('button')].find((button) => button.textContent?.includes('First page'))!;
await act(async () => { root.render(<I18nProvider locale="en"><TooltipProvider><Fixture /></TooltipProvider></I18nProvider>); await settle(); });
assert.equal(document.querySelector('button[aria-label="Extension pages"]'), null, 'pages do not require opening a grouped menu');
assert.ok(document.querySelector('button[aria-label="Second page — Synthetic plugin"]'), 'each contributed page has a direct toolbar button');
const firstButton = document.querySelector<HTMLButtonElement>('button[aria-label="First page — Synthetic plugin"]')!;
await act(async () => { firstButton.click(); await settle(); });
assert.equal(document.querySelectorAll('[data-section="terminal-side-panel-pane"]').length, 1);
assert.ok(document.querySelector('[data-pane-tool] [data-plugin-view-slot="terminal-sidebar"]'));
assert.equal(document.querySelectorAll('[data-plugin-terminal-sidebar] header').length, 1);
assert.equal(opens.length, 1); assert.deepEqual(opens[0].context, { 'netcatty.surface': 'terminal-sidebar' });
await act(async () => { split(); await settle(); });
assert.equal(document.querySelectorAll('[data-section="terminal-side-panel-pane"]').length, 2);
assert.equal(document.querySelectorAll('[data-plugin-terminal-sidebar] header').length, 0, 'split pane headers already identify both pages');
assert.equal(opens.length, 2);
const firstId = opens.find((item) => item.viewId === a.id)!.instanceId!;
await act(async () => { for (const fn of focusListeners) fn({ instanceId: firstId }); await settle(); });
await act(async () => { document.querySelector<HTMLElement>(`[data-pane-tool="plugin-view:${a.id}"] [data-plugin-view-slot]`)!.dispatchEvent(new dom.window.FocusEvent('focusin', { bubbles: true })); await settle(); });
assert.equal(document.querySelector(`[data-pane-tool="plugin-view:${a.id}"]`)?.getAttribute('data-focused'), 'true');
const dialog = document.createElement('div'); dialog.setAttribute('role', 'dialog'); dialog.setAttribute('aria-modal', 'true');
await act(async () => { document.body.append(dialog); await settle(); });
assert.ok(opens.every((item) => visible.get(item.instanceId!) === false));
focus.length = 0;
await act(async () => { dialog.remove(); await settle(); });
assert.ok(focus.length > 0); assert.ok(focus.every((id) => id === firstId), 'restoring panes focuses only the host-selected pane');
const old = opens.map((item) => item.instanceId!);
await act(async () => { selectedSession = 'synthetic-session-b'; sidePanelLiveStore.update({ ...SIDE_PANEL_INACTIVE_LIVE_SNAPSHOT, focusedSessionId: selectedSession });
assert.ok(old.every((id) => closes.includes(id)), 'workspace focus cancels old native owners synchronously'); await settle(); });
await act(async () => { selectedSession = 'synthetic-session-b'; sidePanelLiveStore.update({ ...SIDE_PANEL_INACTIVE_LIVE_SNAPSHOT, focusedSessionId: selectedSession }); await settle(); });
assert.equal(opens.length, 4);
assert.notEqual(opens[0].scopeId, opens[2].scopeId);
assert.ok(opens.every((item) => JSON.stringify(item.context) === JSON.stringify({ 'netcatty.surface': 'terminal-sidebar' })));
await act(async () => { rectX = 260; snapshot = { ...snapshot, plugins: [{ ...snapshot.plugins[0], views: [{ ...a, visible: false }, b] }] }; for (const fn of changed) fn(); await settle(); });
await act(async () => { snapshot = { ...snapshot, plugins: [{ ...snapshot.plugins[0], views: [{ ...a, visible: false }, b] }] }; for (const fn of changed) fn(); await settle(); });
assert.equal(document.querySelectorAll('[data-section="terminal-side-panel-pane"]').length, 1);
assert.equal(document.querySelector('[data-pane-tool]')?.getAttribute('data-pane-tool'), `plugin-view:${b.id}`);
assert.ok(bounds.some((item) => item.x === 260), 'portal relocation updates native geometry');
const last = opens.at(-1)!.instanceId!;
await act(async () => { activeTabStore.setActiveTabId('vault'); assert.ok(closes.includes(last)); await settle(); });
await act(async () => { activeTabStore.setActiveTabId('vault'); await settle(); });
assert.equal(document.querySelector('[data-plugin-terminal-sidebar]'), null);
await act(async () => { selectedSession = 'synthetic-local-terminal'; activeTabStore.setActiveTabId(selectedSession); await settle(); });
await act(async () => { document.querySelector<HTMLButtonElement>('button[aria-label="Extension pages"]')!.click(); await settle(); });
const localPage = [...document.querySelectorAll('button')].find((button) => button.textContent?.includes('Second page'))!;
const localPage = document.querySelector<HTMLButtonElement>('button[aria-label="Second page — Synthetic plugin"]')!;
await act(async () => { localPage.click(); await settle(); });
assert.ok(document.querySelector('[data-plugin-view-slot="terminal-sidebar"]'), 'standalone Local Terminal is a valid navigation owner');
assert.deepEqual(opens.at(-1)!.context, { 'netcatty.surface': 'terminal-sidebar' });
} finally {
await act(async () => { root.unmount(); await settle(); }); dom.window.close();
await act(async () => { root.unmount(); (await import('../application/state/pluginViewSession')).pluginViewSessions.dispose(); await settle(); }); dom.window.close();
for (const [key, descriptor] of previous) { if (descriptor) Object.defineProperty(globalThis, key, descriptor); else Reflect.deleteProperty(globalThis, key); }
}
});
+14 -37
View File
@@ -21,24 +21,19 @@ test('Vault navigation mounts a real content slot, yields to host dialogs, and w
const { activeTabStore } = await import('../application/state/activeTabStore');
const { requestOpenPluginView } = await import('../application/state/usePluginViewLifecycle');
const { I18nProvider } = await import('../application/i18n/I18nProvider');
const page = { id: 'com.example.page.main', title: 'Synthetic page', location: 'vault-page', entry: 'page.html', visible: true };
const page = { id: 'com.example.page.main', title: 'Synthetic page', location: 'vault-page', visible: true };
let snapshot = { locale: 'en', plugins: [{ id: 'com.example.page', version: '1.0.0', displayName: 'Synthetic plugin', commands: [], keybindings: [], menus: [], settings: [], views: [page] }] };
let changed: (() => void) | undefined;
const listeners = new Set<(event: NetcattyPluginViewClosedEvent) => void>();
const opens: NetcattyPluginViewOpenRequest[] = [], closes: string[] = [], visibility: boolean[] = [];
let defer: ((value: { instanceId: string }) => void) | undefined;
let deferOpen = false;
let delayLayout = false;
const rejectOldLayout: Array<(error: Error) => void> = [];
const layoutResult = () => delayLayout ? new Promise<void>((_resolve, reject) => { rejectOldLayout.push(reject); }) : Promise.resolve();
const opens: NetcattyPluginViewOpenRequest[] = [], closes: string[] = [];
Object.assign(dom.window, { netcatty: {
async getPluginRuntimeStatus() { return { available: true }; },
async getPluginContributions() { return snapshot; },
onPluginContributionsChanged(fn: () => void) { changed = fn; return () => { changed = undefined; }; },
onPluginViewClosed(fn: (event: NetcattyPluginViewClosedEvent) => void) { listeners.add(fn); return () => { listeners.delete(fn); }; },
async openPluginView(payload: NetcattyPluginViewOpenRequest) { opens.push(payload); return deferOpen ? new Promise<{ instanceId: string }>((resolve) => { defer = resolve; }) : { instanceId: payload.instanceId! }; },
async closePluginView(id: string) { closes.push(id); }, async setPluginViewBounds() { await layoutResult(); },
async setPluginViewVisibility(_id: string, visible: boolean) { visibility.push(visible); await layoutResult(); },
onHostDrawnPluginViewUpdate() { return () => {}; },
async openHostDrawnPluginView(payload: NetcattyPluginViewOpenRequest) { opens.push(payload); return { instanceId: payload.instanceId! }; },
async closeHostDrawnPluginView(id: string) { closes.push(id); },
} });
let selectSection!: (section: 'hosts' | `plugin-vault:${string}`) => void;
function Fixture() {
@@ -58,44 +53,26 @@ test('Vault navigation mounts a real content slot, yields to host dialogs, and w
assert.ok(document.querySelector('[data-section="vault-main"] [data-plugin-view-slot="vault-page"]'));
assert.equal(opens.length, 1);
assert.deepEqual(opens[0].context, { 'netcatty.surface': 'vault-page' }, 'no Vault/session data accompanies navigation');
assert.equal(visibility.at(-1), true);
const dialog = document.createElement('div'); dialog.setAttribute('role', 'dialog'); dialog.setAttribute('aria-modal', 'true');
await act(async () => { document.body.append(dialog); await settle(); });
assert.equal(visibility.at(-1), false, 'native content yields to host security/settings dialogs');
await act(async () => { dialog.remove(); await settle(); });
assert.equal(visibility.at(-1), true);
await act(async () => { activeTabStore.setActiveTabId('terminal:synthetic'); assert.ok(closes.includes(opens[0].instanceId!)); await settle(); });
await act(async () => { activeTabStore.setActiveTabId('terminal:synthetic'); await settle(); });
assert.equal(closes.length, 0, 'hiding keeps the logical view available');
await act(async () => { activeTabStore.setActiveTabId('vault'); await settle(); });
assert.equal(opens.length, 2);
assert.equal(opens.length, 1, 'returning to Vault reuses the same plugin state');
await act(async () => { snapshot = { ...snapshot, plugins: [] }; changed?.(); await settle(); });
await act(async () => { await settle(); });
assert.ok(closes.includes(opens[1].instanceId!));
// The main process owns immediate invalidation on disable/uninstall.
await act(async () => { for (const listener of listeners) listener({ instanceId: opens[0].instanceId!, viewId: page.id, pluginId: 'com.example.page', reason: 'plugin-disabled' }); });
assert.ok(closes.includes(opens[0].instanceId!));
assert.equal(document.querySelector('[data-plugin-vault-page]'), null);
assert.equal(document.activeElement?.textContent, 'Hosts');
await act(async () => { selectSection('plugin-vault:missing'); await settle(); });
assert.match(document.querySelector('main')!.textContent!, /Built-in hosts/);
await act(async () => { snapshot = { ...snapshot, plugins: [{ id: 'com.example.page', version: '1.0.0', displayName: 'Synthetic plugin', commands: [], keybindings: [], menus: [], settings: [], views: [page] }] }; changed?.(); await settle(); });
deferOpen = true;
await act(async () => { requestOpenPluginView({ viewId: page.id }); await settle(); });
assert.equal(opens.length, 3);
assert.equal(opens.length, 2);
await act(async () => { selectSection('hosts'); await settle(); });
assert.ok(closes.includes(opens[2].instanceId!), 'cancel reserved instance before its open response');
await act(async () => { defer!({ instanceId: opens[2].instanceId! }); await settle(); });
assert.equal(document.querySelector('[data-plugin-vault-page]'), null, 'late open cannot restore withdrawn page');
assert.ok(closes.filter((id) => id === opens[2].instanceId).length >= 2, 'late native response is also closed');
deferOpen = false;
const second = { ...page, id: 'com.example.page.second', title: 'Second page' };
await act(async () => { snapshot = { ...snapshot, plugins: [{ ...snapshot.plugins[0], views: [page, second] }] }; changed?.(); await settle(); });
delayLayout = true;
await clickPage();
assert.ok(rejectOldLayout.length >= 2, 'old page has both bounds and visibility IPC in flight');
await act(async () => { selectSection('hosts'); await settle(); });
delayLayout = false;
await act(async () => { document.querySelector<HTMLButtonElement>('button[aria-label="Second page — Synthetic plugin"]')!.click(); await settle(); });
await act(async () => { for (const reject of rejectOldLayout) reject(new Error('old page layout failed')); await settle(); });
assert.equal(document.querySelector('[data-plugin-vault-page]')?.getAttribute('aria-label'), 'Second page', 'old bounds/visibility failures cannot withdraw the newly selected page');
assert.equal(document.querySelector('[data-plugin-vault-page]'), null);
} finally {
await act(async () => { root.unmount(); await settle(); }); dom.window.close();
await act(async () => { root.unmount(); (await import('../application/state/pluginViewSession')).pluginViewSessions.dispose(); await settle(); }); dom.window.close();
for (const [key, descriptor] of previous) { if (descriptor) Object.defineProperty(globalThis, key, descriptor); else Reflect.deleteProperty(globalThis, key); }
}
});
+17 -5
View File
@@ -7,9 +7,12 @@ import {
} from '../../application/state/usePluginViewLifecycle';
import { Button } from '../ui/button';
import { PluginContributionIcon } from './PluginContributionIcon';
import { PluginHostDrawnPage } from './PluginHostDrawnPage';
export { requestOpenPluginView };
const subscribeNoop = () => () => {};
const DEFAULT_KEYBINDING_CONTEXT = Object.freeze({ 'netcatty.surface': 'keybinding' });
export function PluginContributionHost({
@@ -28,7 +31,7 @@ export function PluginContributionHost({
activeView,
close,
effectiveRequested,
mountRef,
viewQueryContext,
} = usePluginViewLifecycle({
locale,
theme,
@@ -38,18 +41,24 @@ export function PluginContributionHost({
if (!effectiveRequested || !activeView) return null;
const location = activeView.view.location;
const content = <PluginHostDrawnPage pluginId={activeView.plugin.id} viewId={activeView.view.id} version={activeView.plugin.version} slot={`view:${activeView.view.id}`} active context={viewQueryContext}
subscribeHidden={subscribeNoop} unavailable={() => void close()} loadingLabel={t('plugins.vault.loading')} />;
const containerClass = location === 'aside'
? 'absolute inset-y-0 right-0 z-40 w-[420px] border-l border-border bg-background shadow-2xl'
: location === 'tab'
? 'absolute inset-0 z-40 flex p-2 pt-0'
: location === 'panel'
? 'absolute inset-x-0 bottom-0 z-40 h-[42%] border-t border-border bg-background shadow-2xl'
: location === 'modal'
? 'fixed left-1/2 top-1/2 z-50 h-[70vh] w-[min(800px,85vw)] -translate-x-1/2 -translate-y-1/2 rounded-xl border border-border bg-background shadow-2xl'
? 'fixed left-1/2 top-1/2 z-40 h-[70vh] w-[min(800px,85vw)] -translate-x-1/2 -translate-y-1/2 rounded-xl border border-border bg-background shadow-2xl'
: 'absolute inset-0 z-40 bg-background';
if (location === 'tab') {
return (
<section className={`${containerClass} flex flex-col`} role="region" aria-label={activeView.view.title}>
<div ref={mountRef} className="min-h-0 flex-1" />
<section className={containerClass} role="region" aria-label={activeView.view.title}>
<div className="flex min-h-0 min-w-0 flex-1 overflow-hidden rounded-xl border border-border/60 bg-background shadow-sm">
{content}
</div>
</section>
);
}
@@ -58,6 +67,7 @@ export function PluginContributionHost({
<section
className={`${containerClass} flex flex-col`}
role={location === 'modal' ? 'dialog' : 'region'}
data-plugin-dialog={location === 'modal' ? 'true' : undefined}
aria-modal={location === 'modal' ? true : undefined}
aria-label={activeView.view.title}
>
@@ -80,7 +90,9 @@ export function PluginContributionHost({
<X size={14} />
</Button>
</header>
<div ref={mountRef} className="min-h-0 flex-1" />
<div className={location === 'modal' ? 'flex min-h-0 flex-1 p-3' : 'flex min-h-0 flex-1'}>
{content}
</div>
</section>
);
}
@@ -0,0 +1,15 @@
import { useHostDrawnPluginView } from '../../application/state/useHostDrawnPluginView';
import { PluginSettingsEntry } from './PluginSettingsEntry';
import { PluginRemoteSurface } from './PluginRemoteSurface';
export function PluginHostDrawnPage(props: Parameters<typeof useHostDrawnPluginView>[0] & { loadingLabel: string; pluginId: string }) {
const view = useHostDrawnPluginView(props);
if (!view.visible) return null;
if (view.loading) return <p data-plugin-view-slot={props.slot.split(':')[0]} role="status" className="p-4 text-sm text-muted-foreground">{props.loadingLabel}</p>;
return <div className="relative isolate h-full min-h-0 w-full flex-1" data-plugin-view-slot={props.slot.split(':')[0]}>
<PluginRemoteSurface key={`${props.viewId}:${props.version}:${props.slot}`} tree={view.tree} css={view.css} settled={view.settled} loadImage={view.loadImage} onEvent={view.sendEvent} />
<div className="absolute right-3 top-3 z-10" data-plugin-settings-entry="">
<PluginSettingsEntry key={`${props.pluginId}:${props.version}`} pluginId={props.pluginId} />
</div>
</div>;
}
@@ -0,0 +1,130 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { JSDOM } from 'jsdom';
test('plugin surface forwards its own input and contains malformed updates and render errors', async () => {
const dom = new JSDOM('<!doctype html><div id="root"></div><input id="host" value="private">', { url: 'http://localhost', pretendToBeVisual: true });
const saved = new Map<string, PropertyDescriptor | undefined>();
for (const key of ['window', 'document', 'navigator', 'HTMLElement', 'Element', 'Node', 'Event', 'MouseEvent', 'ShadowRoot']) {
saved.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
Object.defineProperty(globalThis, key, { configurable: true, writable: true, value: key === 'window' ? dom.window : dom.window[key as keyof typeof dom.window] });
}
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', { configurable: true, writable: true, value: true });
let captured: ShadowRoot | undefined;
const nativeAttach = dom.window.Element.prototype.attachShadow;
dom.window.Element.prototype.attachShadow = function (options) { captured = nativeAttach.call(this, options) as unknown as ShadowRoot; return captured as unknown as globalThis.ShadowRoot; };
const { default: React, act } = await import('react');
const { createRoot } = await import('react-dom/client');
const { PluginRemoteSurface } = await import('./PluginRemoteSurface');
const events: unknown[] = [];
const tree = { children: [{ id: '1', type: 1, element: 'div', attributes: { class: 'card', style: 'display:grid;gap:8px' }, children: [
{ id: '2', type: 1, element: 'button', attributes: { type: 'button', 'data-netcatty-remote-node': 'forged' }, children: [{ id: '3', type: 3, data: '<b>Hello</b>' }] },
{ id: '4', type: 1, element: 'input', attributes: { type: 'text', value: '' }, children: [] },
] }] };
const root = createRoot(document.getElementById('root')!);
try {
await act(async () => { root.render(<PluginRemoteSurface tree={tree} css=".card{color:red}" onEvent={(event) => events.push(event)} />); });
assert.ok(captured); assert.equal(document.querySelector('[data-plugin-remote-surface]')?.querySelector('button'), null);
assert.equal(captured!.querySelector('button')?.textContent, '<b>Hello</b>');
assert.equal(captured!.querySelector('button')?.getAttribute('data-netcatty-remote-node'), '2');
await act(async () => { captured!.querySelector('button')!.click(); });
assert.deepEqual(events, [{ nodeId: '2', type: 'click' }]);
const input = captured!.querySelector('input')!; input.value = 'typed';
await act(async () => { input.dispatchEvent(new dom.window.Event('input', { bubbles: true })); });
assert.deepEqual(events.at(-1), { nodeId: '4', type: 'input', value: 'typed' });
await act(async () => { document.getElementById('host')!.dispatchEvent(new dom.window.Event('input', { bubbles: true })); });
assert.equal(events.length, 2);
await act(async () => { root.render(<PluginRemoteSurface tree={tree} css="@import 'https://bad.example/x';" onEvent={() => {}} />); });
assert.equal(document.querySelector('[role=alert]')?.textContent, 'Unable to display plugin view.');
let clicks = 0;
const renderView = async (nextTree: unknown) => act(async () => root.render(<>
<PluginRemoteSurface key="view" tree={nextTree} css="" onEvent={() => {}} />
<button id="canary" onClick={() => clicks++}>Host canary</button>
<PluginRemoteSurface key="sibling" tree={tree} css="" onEvent={() => {}} />
</>));
await renderView(tree);
await renderView({ children: [{ id: 'bad', type: 1, element: 'input', attributes: {}, children: [{ id: 'text', type: 3, data: 'invalid' }] }] });
assert.equal(document.querySelectorAll('[data-plugin-remote-surface]').length, 1);
assert.ok(document.querySelector('[role=alert]'));
await act(async () => document.getElementById('canary')!.click());
assert.equal(clicks, 1);
// Validated data can still hit a React rendering constraint. Contain that
// descendant throw too, independently of the policy rejection above.
const originalError = console.error;
try {
console.error = () => {};
await renderView({ children: [{ id: 'bad', type: 1, element: 'textarea', attributes: {}, children: [
{ id: 'a', type: 3, data: 'one' }, { id: 'b', type: 3, data: 'two' },
] }] });
} finally { console.error = originalError; }
assert.equal(document.querySelectorAll('[data-plugin-remote-surface]').length, 1);
assert.ok(document.querySelector('[role=alert]'));
await act(async () => document.getElementById('canary')!.click());
assert.equal(clicks, 2);
} finally {
await act(async () => { root.unmount(); });
for (const [key, value] of saved) { if (value) Object.defineProperty(globalThis, key, value); else Reflect.deleteProperty(globalThis, key); }
Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT'); dom.window.close();
}
});
test('closed-shadow editing keeps app close shortcuts in Vault, sidebar, tabs and popovers', async () => {
const dom = new JSDOM('<!doctype html><div id="root"></div>', { url: 'http://localhost', pretendToBeVisual: true });
const saved = new Map<string, PropertyDescriptor | undefined>();
for (const [key, value] of Object.entries({ window: dom.window, document: dom.window.document, navigator: dom.window.navigator,
HTMLElement: dom.window.HTMLElement, Element: dom.window.Element, Node: dom.window.Node, IS_REACT_ACT_ENVIRONMENT: true })) {
saved.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
Object.defineProperty(globalThis, key, { configurable: true, writable: true, value });
}
dom.window.HTMLElement.prototype.getBoundingClientRect = () => ({ x: 20, y: 20, width: 240, height: 30, top: 20, left: 20, right: 260, bottom: 50, toJSON() {} });
Object.assign(dom.window, { ResizeObserver: class { observe() {} disconnect() {} } });
const shadows: ShadowRoot[] = [];
const nativeAttach = dom.window.Element.prototype.attachShadow;
dom.window.Element.prototype.attachShadow = function (options) {
const shadow = nativeAttach.call(this, options); shadows.push(shadow as unknown as ShadowRoot); return shadow;
};
const { act } = await import('react');
const { createRoot } = await import('react-dom/client');
const { PluginRemoteSurface } = await import('./PluginRemoteSurface');
const { handleGlobalHotkeyKeyDownImpl } = await import('../../application/app/AppHandlers');
const { isPluginViewEditingTarget, isPluginShortcutEditableEvent } = await import('../../application/state/pluginKeybindings');
const { matchesKeyBinding } = await import('../../domain/models');
const { DEFAULT_KEY_BINDINGS } = await import('../../domain/models/keyBindings');
const actions: string[] = [];
const capture = (event: KeyboardEvent) => handleGlobalHotkeyKeyDownImpl(() => ({
HOTKEY_DEBUG: false, closeTabKeyStr: 'Ctrl + W', hotkeyScheme: 'pc', keyBindings: DEFAULT_KEY_BINDINGS, matchesKeyBinding,
executeHotkeyAction: (action: string) => actions.push(action),
}), event);
dom.window.addEventListener('keydown', capture as unknown as EventListener, true);
const control = (id: string, element: string, attributes = {}) => ({ id, type: 1, element, attributes, children: [] });
const tree = { children: [control('input', 'input'), control('textarea', 'textarea'), control('anchor', 'button', { id: 'anchor' }),
{ ...control('popover', 'div', { 'data-plugin-popover': 'true', 'data-plugin-anchor': 'anchor', open: '' }), children: [control('pop-input', 'input'), control('pop-textarea', 'textarea')] },
] };
const root = createRoot(document.getElementById('root')!);
try {
for (const slot of ['vault-page', 'terminal-sidebar', 'plugin-tab']) {
shadows.length = 0;
await act(async () => root.render(<div key={slot} data-plugin-view-slot={slot}><PluginRemoteSurface tree={tree} css="" onEvent={() => {}} /></div>));
assert.equal(shadows.length, 2, 'includes the portaled popover surface');
for (const shadow of shadows) {
for (const element of shadow.querySelectorAll<HTMLInputElement | HTMLTextAreaElement>('input,textarea')) {
await act(async () => element.focus());
// Native menu IPC sees this same retargeted document.activeElement.
assert.equal(isPluginViewEditingTarget(document.activeElement), true, `${slot}: ${element.tagName}`);
let editable = false;
const observe = (event: Event) => { editable = isPluginShortcutEditableEvent(event); assert.equal(event.target, shadow.host); };
dom.window.addEventListener('keydown', observe, { once: true });
await act(async () => element.dispatchEvent(new dom.window.KeyboardEvent('keydown', { key: 'w', code: 'KeyW', ctrlKey: true, bubbles: true, composed: true, cancelable: true })));
assert.equal(editable, true);
assert.deepEqual(actions, []);
}
}
await act(async () => (shadows[0].querySelector('button') as HTMLButtonElement).focus());
assert.equal(isPluginViewEditingTarget(document.activeElement), false, 'focus marker clears when editing ends');
}
} finally {
dom.window.removeEventListener('keydown', capture as unknown as EventListener, true);
await act(async () => root.unmount()); dom.window.close();
for (const [key, value] of saved) { if (value) Object.defineProperty(globalThis, key, value); else Reflect.deleteProperty(globalThis, key); }
}
});
+120
View File
@@ -0,0 +1,120 @@
import React, { createContext, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { LazyLoadBoundary } from '../ui/lazy-load-boundary';
import { PluginViewPopover } from './PluginViewPopover';
import { createPortal } from 'react-dom';
import { validatePluginRemoteTree } from '@/domain/pluginRemoteTreePolicy.cjs';
import { validatePluginStylesheet } from '@/domain/pluginViewStylePolicy.cjs';
type RemoteNode = { id: string; type: 3; data: string } | { id: string; type: 8; data: string } | {
id: string; type: 1; element: string; attributes: Record<string, string>; children: RemoteNode[];
};
export type PluginSurfaceEvent = import('@netcatty/plugin-contract').HostDrawnViewEvent;
const ATTRIBUTE_NAMES: Record<string, string> = { class: 'className', for: 'htmlFor', readonly: 'readOnly', tabindex: 'tabIndex', maxlength: 'maxLength', minlength: 'minLength', colspan: 'colSpan', rowspan: 'rowSpan', autocomplete: 'autoComplete' };
const BOOLEAN_ATTRIBUTES = new Set(['checked', 'disabled', 'readonly', 'required', 'multiple', 'selected', 'open']);
const SurfaceContext = createContext<{ css: string; onEvent(event: PluginSurfaceEvent): void; depth: number }>({ css: '', onEvent: () => {}, depth: 0 });
const ResourceLoader = createContext<((path: string) => Promise<string>) | undefined>(undefined);
function RemoteElement({ node, settled }: { node: RemoteNode; settled: boolean }): React.ReactNode {
const attrs = node.type === 1 ? node.attributes : {};
const surface = useContext(SurfaceContext);
const loadImage = useContext(ResourceLoader);
const imagePath = node.type === 1 && node.element === 'img' ? attrs.src?.slice(6) : undefined;
const [imageUrl, setImageUrl] = useState<string | undefined>();
useEffect(() => {
let cancelled = false; setImageUrl(undefined);
if (imagePath && loadImage) void loadImage(imagePath).then((url) => { if (!cancelled) setImageUrl(url); }).catch(() => {});
return () => { cancelled = true; };
}, [imagePath, loadImage]);
const remoteSelection = JSON.stringify(node.type === 1 && node.element === 'select'
? node.children.filter((child) => child.type === 1 && child.element === 'option' && Object.hasOwn(child.attributes, 'selected')).map((child) => child.type === 1 ? child.attributes.value ?? '' : '') : []);
const [localSelection, setLocalSelection] = useState<string[]>(() => JSON.parse(remoteSelection));
useEffect(() => { if (settled) setLocalSelection(JSON.parse(remoteSelection)); }, [remoteSelection, settled]);
const remoteValue = attrs.value;
const remoteChecked = Object.hasOwn(attrs, 'checked');
const [localValue, setLocalValue] = useState(remoteValue ?? '');
const [localChecked, setLocalChecked] = useState(remoteChecked);
useEffect(() => { if (settled && remoteValue !== undefined) setLocalValue(remoteValue); }, [remoteValue, settled]);
useEffect(() => { if (settled) setLocalChecked(remoteChecked); }, [remoteChecked, settled]);
if (node.type === 3) return node.data;
if (node.type === 8) return null;
if (attrs['data-plugin-popover'] === 'true') return surface.depth < 1 ? <PluginViewPopover anchor={attrs['data-plugin-anchor'] ?? ''} open={Object.hasOwn(attrs, 'open')} dismiss={() => surface.onEvent({ nodeId: node.id, type: 'dismiss' })}><PluginRemoteSurface tree={{ children: node.children }} css={surface.css} onEvent={surface.onEvent} settled={settled} loadImage={loadImage} depth={surface.depth + 1} /></PluginViewPopover> : null;
const props: Record<string, unknown> = { 'data-netcatty-remote-node': node.id };
let inlineStyle: string | undefined;
for (const [name, value] of Object.entries(node.attributes)) {
if (name === 'src' || (node.element === 'option' && name === 'selected')) continue;
if (name === 'style') { inlineStyle = value; continue; }
if (name === 'data-netcatty-remote-node') continue;
props[ATTRIBUTE_NAMES[name] ?? name] = BOOLEAN_ATTRIBUTES.has(name) ? true : value;
}
// The policy validated this declaration list before this component rendered.
props.ref = (el: HTMLElement | null) => { if (el) { if (inlineStyle === undefined) el.removeAttribute('style'); else el.setAttribute('style', inlineStyle); } };
if (node.element === 'img') props.src = imageUrl;
if (node.element === 'form') props.onSubmit = (event: React.FormEvent) => event.preventDefault();
if (['input', 'select', 'textarea'].includes(node.element)) {
if (node.element === 'select' && Object.hasOwn(attrs, 'multiple')) props.value = localSelection;
else if (remoteValue !== undefined) props.value = localValue;
props.autoComplete = 'off';
if (node.element === 'input' && ['checkbox', 'radio'].includes(attrs.type ?? '')) props.checked = localChecked;
props.onChange = (event: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => {
setLocalValue(event.currentTarget.value);
if (event.currentTarget instanceof event.currentTarget.ownerDocument.defaultView!.HTMLSelectElement && event.currentTarget.multiple) setLocalSelection([...event.currentTarget.selectedOptions].map((option) => option.value));
if (event.currentTarget instanceof event.currentTarget.ownerDocument.defaultView!.HTMLInputElement) setLocalChecked(event.currentTarget.checked);
};
}
return React.createElement(node.element, props, ...(node.element === 'textarea' && remoteValue !== undefined ? [] : node.children).map((child) => <RemoteElement key={child.id} node={child} settled={settled} />));
}
/** Host-owned DOM, kept inside a closed shadow root and a clipped stacking context. */
export function PluginRemoteSurface({ tree, css, onEvent, settled = true, loadImage, depth = 0 }: { depth?: number; tree: unknown; css: string; settled?: boolean; loadImage?(path: string): Promise<string>; onEvent(event: PluginSurfaceEvent): void }) {
const checked = useMemo(() => {
try {
validatePluginRemoteTree(tree);
return { tree: tree as { children: RemoteNode[] }, css: validatePluginStylesheet(css) };
} catch { return null; }
}, [tree, css]);
if (!checked) return <p role="alert" className="p-4 text-sm text-destructive">Unable to display plugin view.</p>;
return <LazyLoadBoundary name="Plugin view" fallback={<p role="alert" className="p-4 text-sm text-destructive">Unable to display plugin view.</p>}><SurfaceContext.Provider value={{ css: checked.css, onEvent, depth }}><ResourceLoader.Provider value={loadImage}><ValidatedPluginRemoteSurface tree={checked.tree} css={checked.css} onEvent={onEvent} settled={settled} /></ResourceLoader.Provider></SurfaceContext.Provider></LazyLoadBoundary>;
}
function ValidatedPluginRemoteSurface({ tree, css, onEvent, settled }: { settled: boolean; tree: { children: RemoteNode[] }; css: string; onEvent(event: PluginSurfaceEvent): void }) {
const hostRef = useRef<HTMLDivElement>(null);
const onEventRef = useRef(onEvent); onEventRef.current = onEvent;
const shadowRef = useRef<ShadowRoot | null>(null);
const [shadow, setShadow] = useState<ShadowRoot | null>(null);
useLayoutEffect(() => {
const host = hostRef.current;
if (!host) return;
// The same DOM node can be mounted twice in React Strict Mode.
const root = shadowRef.current ?? host.attachShadow({ mode: 'closed' });
shadowRef.current = root;
setShadow(root);
}, []);
useEffect(() => {
if (!shadow) return;
const listener = (event: Event) => {
const target = event.target;
const view = shadow.ownerDocument.defaultView;
if (!view || !(target instanceof view.HTMLElement) || !['click', 'input', 'change', 'keydown', 'keyup', 'focusin', 'focusout', 'compositionstart', 'compositionupdate', 'compositionend', 'submit', 'reset'].includes(event.type)) return;
// Closed-shadow events hide their editable target from window capture.
if (event.type === 'focusin') hostRef.current?.toggleAttribute('data-plugin-editing', target.matches('input, textarea, select, [contenteditable]'));
if (event.type === 'focusout') hostRef.current?.removeAttribute('data-plugin-editing');
if (event.type === 'submit') event.preventDefault();
const nodeId = target.dataset.netcattyRemoteNode;
if (!nodeId) return;
const value = target instanceof view.HTMLInputElement || target instanceof view.HTMLTextAreaElement || target instanceof view.HTMLSelectElement ? target.value : undefined;
const values = target instanceof view.HTMLSelectElement && target.multiple ? [...target.selectedOptions].map((option) => option.value) : undefined;
const checkedValue = target instanceof view.HTMLInputElement && ['checkbox', 'radio'].includes(target.type) ? target.checked : undefined;
if (value !== undefined && value.length > 8192) return;
const keyboard = event instanceof view.KeyboardEvent && event.key.length <= 64
? { key: event.key, ctrlKey: event.ctrlKey, metaKey: event.metaKey, altKey: event.altKey, shiftKey: event.shiftKey } : {};
onEventRef.current({ nodeId, type: event.type as PluginSurfaceEvent['type'], ...(value === undefined ? {} : { value }), ...(checkedValue === undefined ? {} : { checked: checkedValue }), ...keyboard, ...(values ? { values } : {}), ...(event instanceof view.CompositionEvent ? { data: event.data.slice(0, 8192) } : {}) });
};
for (const event of ['click', 'input', 'change', 'keydown', 'keyup', 'focusin', 'focusout', 'compositionstart', 'compositionupdate', 'compositionend', 'submit', 'reset']) shadow.addEventListener(event, listener, true);
return () => { for (const event of ['click', 'input', 'change', 'keydown', 'keyup', 'focusin', 'focusout', 'compositionstart', 'compositionupdate', 'compositionend', 'submit', 'reset']) shadow.removeEventListener(event, listener, true); };
}, [shadow]);
return <div className="h-full w-full min-h-0 min-w-0 overflow-hidden [contain:paint] isolate relative" data-plugin-remote-surface>
<div ref={hostRef} className="h-full min-h-0 overflow-auto [container-type:inline-size]" />
{shadow && createPortal(<><style>{css}</style>{tree.children.map((node) => <RemoteElement key={node.id} node={node} settled={settled} />)}</>, shadow)}
</div>;
}
@@ -0,0 +1,85 @@
import { Settings } from 'lucide-react';
import { useEffect, useRef, useState, type RefObject } from 'react';
import { useI18n } from '../../application/i18n/I18nProvider';
import { usePluginContributions } from '../../application/state/usePluginContributions';
import { usePluginSettingsModel } from '../../application/state/usePluginSettingsModel';
import { useAvailableFonts } from '../../application/state/fontStore';
import { Button } from '../ui/button';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '../ui/dialog';
import { PluginSettingGroups } from './PluginSettingsForm';
export const pluginSettingsButtonClass = 'h-8 w-8 shrink-0 rounded-full border border-border/60 bg-background/95 shadow-sm hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2';
export function PluginSettingsDialog({ pluginId, onClose, restoreFocusTo }: {
pluginId: string;
onClose: () => void;
restoreFocusTo: RefObject<HTMLElement | null>;
}) {
const { t } = useI18n();
const model = usePluginSettingsModel(pluginId);
const fonts = useAvailableFonts();
useEffect(() => {
if (!model.loading && !model.error && !model.settings.length) onClose();
}, [model.loading, model.error, model.settings.length, onClose]);
return <Dialog open onOpenChange={open => { if (!open) onClose(); }}>
<DialogContent data-plugin-settings-dialog={pluginId}
className="app-no-drag flex max-h-[min(80dvh,760px)] w-[calc(100vw-2rem)] max-w-[720px] flex-col gap-0 overflow-hidden rounded-xl p-0"
overlayClassName="bg-black/40"
onEscapeKeyDown={event => event.stopPropagation()}
onCloseAutoFocus={event => {
const element = restoreFocusTo.current;
if (element?.isConnected && !element.matches(':disabled')) {
event.preventDefault(); element.focus({ preventScroll: true });
}
}}>
<DialogHeader className="shrink-0 border-b border-border/60 px-6 py-4 pr-14">
<DialogTitle className="flex min-w-0 items-center gap-2.5 text-base">
<Settings size={18} aria-hidden="true" className="shrink-0 text-muted-foreground" />
<span className="truncate">{model.plugin?.displayName ?? t('settings.plugins.configuration')}</span>
</DialogTitle>
</DialogHeader>
<div className="min-h-0 overflow-y-auto p-6" data-plugin-settings-form="">
{model.loading && <p role="status" className="text-sm text-muted-foreground">{t('settings.plugins.loading')}</p>}
{model.error && <p role="alert" className="text-sm text-destructive">{model.error.message}</p>}
{model.contextualScopes.length > 0 && <section className="mb-4 grid gap-3 sm:grid-cols-2" aria-label={t('settings.plugins.scopeTargets')}>
{model.contextualScopes.map(kind => <label key={kind} className="space-y-1 text-xs font-medium">
<span>{t('settings.plugins.scopeTarget').replace('{scope}', kind)}</span>
<select value={model.scopeIds[kind] ?? ''}
onChange={event => model.setScopeIds(current => ({ ...current, [kind]: event.target.value || undefined }))}
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm font-normal">
{!model.catalog[kind].length && <option value="">{t('settings.plugins.noScopeTargets')}</option>}
{model.catalog[kind].map(entry => <option key={entry.id} value={entry.id}>{entry.label}</option>)}
</select>
</label>)}
</section>}
<div className="space-y-4">
<PluginSettingGroups pluginId={pluginId} settings={model.settings} updateSetting={model.updateSetting}
resetSetting={model.resetSetting} selectSettingPath={model.selectSettingPath} availableFonts={fonts} />
</div>
</div>
</DialogContent>
</Dialog>;
}
function AvailableSettingsEntry({ pluginId, name }: { pluginId: string; name: string }) {
const { t } = useI18n();
const [open, setOpen] = useState(false);
const opener = useRef<HTMLButtonElement>(null);
return <>
<div className="flex items-center gap-3">
<Button ref={opener} type="button" variant="ghost" size="icon" className={pluginSettingsButtonClass}
aria-label={t('settings.plugins.landing.settingsFor').replace('{name}', name)} title={t('common.settings')}
onClick={() => setOpen(true)}><Settings size={16} /></Button>
</div>
{open && <PluginSettingsDialog pluginId={pluginId} onClose={() => setOpen(false)} restoreFocusTo={opener} />}
</>;
}
export function PluginSettingsEntry({ pluginId }: { pluginId: string }) {
const { locale } = useI18n();
const contributions = usePluginContributions({ locale, context: { 'netcatty.surface': 'settings' } });
const plugin = contributions.snapshot.plugins.find(item => item.id === pluginId);
if (!plugin?.settings.some(setting => setting.visible)) return null;
// Removing or replacing the owning contribution also discards its open dialog.
return <AvailableSettingsEntry key={`${plugin.id}:${plugin.version}`} pluginId={plugin.id} name={plugin.displayName} />;
}
+275
View File
@@ -0,0 +1,275 @@
import React, { useEffect, useState } from 'react';
import { FolderOpen, RotateCcw } from 'lucide-react';
import { normalizePluginKeyboardEvent } from '../../application/state/pluginKeybindings';
import { usePluginContributions } from '../../application/state/usePluginContributions';
import { useI18n } from '../../application/i18n/I18nProvider';
import { useAvailableFonts } from '../../application/state/fontStore';
import { Button } from '../ui/button';
import { Input } from '../ui/input';
import { Toggle } from '../settings/settings-ui';
import { parsePluginStructuredSettingValue } from '../settings/tabs/pluginSettingValues';
import { PluginStructuredSettingEditor } from '../settings/tabs/PluginStructuredSettingEditor';
import { TerminalFontSelect } from '../settings/TerminalFontSelect';
export function PluginSettingField({
pluginId,
setting,
updateSetting,
resetSetting,
selectSettingPath,
availableFonts,
}: {
pluginId: string;
setting: NetcattyPluginSettingContribution;
updateSetting: ReturnType<typeof usePluginContributions>['updateSetting'];
resetSetting: ReturnType<typeof usePluginContributions>['resetSetting'];
selectSettingPath: ReturnType<typeof usePluginContributions>['selectSettingPath'];
availableFonts: ReturnType<typeof useAvailableFonts>;
}) {
const { t } = useI18n();
const [value, setValue] = useState<unknown>(setting.secret ? '' : setting.value ?? '');
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const itemLabel = (key: 'settings.plugins.moveItemUp' | 'settings.plugins.moveItemDown', index: number) => (
t(key).replace('{label}', setting.label).replace('{index}', String(index + 1))
);
useEffect(() => {
setValue(setting.secret ? '' : setting.value ?? '');
}, [setting.secret, setting.value]);
const save = async (nextValue: unknown) => {
setSaving(true);
setError(null);
try {
await updateSetting(pluginId, setting.id, nextValue, setting.scopeId ?? undefined);
if (setting.secret) setValue('');
} catch (cause) {
setError(cause instanceof Error ? cause.message : String(cause));
} finally {
setSaving(false);
}
};
const control = (() => {
if (setting.scopeId == null) {
return <div className="text-xs text-muted-foreground">{t('settings.plugins.scopeContext').replace('{scope}', setting.scope)}</div>;
}
if (setting.control === 'switch') {
return <Toggle ariaLabel={setting.label} checked={Boolean(value)} disabled={saving}
onChange={(next) => { setValue(next); void save(next); }} />;
}
if (setting.control === 'radio') {
return (
<fieldset className="space-y-2" disabled={saving}>
<legend className="sr-only">{setting.label}</legend>
{setting.options?.map((option) => (
<label key={option.value} className="flex items-start gap-2 text-sm">
<input
type="radio"
name={setting.id}
value={option.value}
checked={value === option.value}
onChange={() => { setValue(option.value); void save(option.value); }}
className="mt-0.5 accent-primary"
/>
<span>{option.label}{option.description && <span className="block text-xs text-muted-foreground">{option.description}</span>}</span>
</label>
))}
</fieldset>
);
}
if (setting.control === 'select') {
return (
<select
aria-label={setting.label}
value={String(value)}
disabled={saving}
onChange={(event) => { setValue(event.target.value); void save(event.target.value); }}
className="h-9 min-w-52 rounded-md border border-input bg-background px-3 text-sm"
>
{setting.options?.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
</select>
);
}
if (setting.control === 'multiselect') {
const selected = Array.isArray(value) ? value.map(String) : [];
return (
<select
aria-label={setting.label}
multiple
value={selected}
disabled={saving}
onChange={(event) => {
const next = [...event.currentTarget.selectedOptions].map((option) => option.value);
setValue(next);
void save(next);
}}
className="min-h-24 min-w-52 rounded-md border border-input bg-background px-3 py-2 text-sm"
>
{setting.options?.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
</select>
);
}
if (setting.control === 'textarea') {
return (
<textarea
aria-label={setting.label}
value={String(value)}
placeholder={setting.placeholder}
disabled={saving}
onChange={(event) => setValue(event.target.value)}
onBlur={(event) => void save(event.currentTarget.value)}
className="min-h-24 w-full max-w-xl rounded-md border border-input bg-background px-3 py-2 text-sm"
/>
);
}
if (setting.control === 'number' || setting.control === 'slider') {
return (
<Input
aria-label={setting.label}
type={setting.control === 'slider' ? 'range' : 'number'}
value={typeof value === 'number' ? value : Number(value) || 0}
min={setting.minimum}
max={setting.maximum}
step={setting.step}
disabled={saving}
onChange={(event) => setValue(Number(event.target.value))}
onBlur={(event) => void save(Number(event.currentTarget.value))}
className="max-w-sm"
/>
);
}
if (setting.control === 'list' || setting.control === 'table') {
return (
<PluginStructuredSettingEditor
setting={setting}
value={typeof value === 'string' ? parsePluginStructuredSettingValue(value) : value}
disabled={saving}
onChange={setValue}
onCommit={(next) => void save(next)}
labels={{
add: t('settings.plugins.addItem'),
remove: t('settings.plugins.removeItem'),
moveUp: (index) => itemLabel('settings.plugins.moveItemUp', index),
moveDown: (index) => itemLabel('settings.plugins.moveItemDown', index),
}}
/>
);
}
if (setting.control === 'font') {
return (
<TerminalFontSelect
value={String(value)}
fonts={availableFonts}
disabled={saving}
onChange={(next) => { setValue(next); void save(next); }}
className="w-full max-w-xl"
ariaLabel={setting.label}
/>
);
}
if (setting.control === 'file' || setting.control === 'directory') {
return (
<div className="flex max-w-xl gap-2">
<Input aria-label={setting.label} value={String(value)} readOnly disabled={saving} className="min-w-0 flex-1" />
<Button
type="button"
variant="outline"
disabled={saving}
onClick={() => void selectSettingPath(setting.control as 'file' | 'directory', setting.label, String(value || '')).then((selected) => {
if (!selected) return;
setValue(selected);
void save(selected);
}).catch((cause) => setError(cause instanceof Error ? cause.message : String(cause)))}
>
<FolderOpen size={14} className="mr-2" /> {t('settings.plugins.browse')}
</Button>
</div>
);
}
if (setting.control === 'keybinding') {
return (
<Input
aria-label={setting.label}
value={String(value)}
placeholder={setting.placeholder ?? t('settings.plugins.pressKeybinding')}
readOnly
disabled={saving}
onKeyDown={(event) => {
event.preventDefault();
const key = normalizePluginKeyboardEvent(event.nativeEvent);
if (!key) return;
setValue(key);
void save(key);
}}
className="max-w-xl"
/>
);
}
return (
<Input
aria-label={setting.label}
type={setting.secret || setting.control === 'password' ? 'password' : setting.control === 'color' ? 'color' : 'text'}
value={String(value)}
placeholder={setting.secret && setting.configured ? t('settings.plugins.configuredReplacement') : setting.placeholder}
disabled={saving}
onChange={(event) => setValue(event.target.value)}
onBlur={(event) => {
const next = event.currentTarget.value;
if (!setting.secret || next.length > 0) void save(next);
}}
className="max-w-xl"
/>
);
})();
const expanded = ['textarea', 'list', 'table', 'radio', 'multiselect'].includes(setting.control);
const reset = !setting.required && setting.scopeId != null && <Button
type="button" variant="ghost" size="icon" className="h-7 w-7 shrink-0 text-muted-foreground"
title={t('common.reset')} aria-label={t('common.reset')} disabled={saving}
onClick={() => {
setSaving(true); setError(null);
void resetSetting(pluginId, setting.id, setting.scopeId ?? undefined)
.catch((cause) => setError(cause instanceof Error ? cause.message : String(cause)))
.finally(() => setSaving(false));
}}><RotateCcw size={14} /></Button>;
return <div className="space-y-2 py-3" data-plugin-setting={setting.id}>
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="min-w-0 flex-1 basis-40">
<div className="text-sm font-medium">{setting.label}</div>
{setting.description && <p className="mt-1 max-w-2xl text-xs leading-relaxed text-muted-foreground">{setting.description}</p>}
{setting.restartRequired && <span className="text-xs text-amber-700 dark:text-amber-300">{t('settings.plugins.restartRequired')}</span>}
</div>
<div className="flex max-w-full items-center justify-end gap-2">
{!expanded && <div className={setting.control === 'switch' ? 'shrink-0' : 'w-56 max-w-full'}>{control}</div>}
{reset}
</div>
</div>
{expanded && control}
{(saving || (setting.secret && setting.configured) || error) && <div className="flex flex-wrap items-center gap-2">
{saving && <span role="status" className="text-xs text-muted-foreground">{t('settings.plugins.saving')}</span>}
{setting.secret && setting.configured && <span className="text-xs text-emerald-600">{t('settings.plugins.storedSecurely')}</span>}
{error && <span role="alert" className="text-xs text-destructive">{error}</span>}
</div>}
</div>;
}
export function PluginSettingGroups({ settings, ...props }: Omit<Parameters<typeof PluginSettingField>[0], 'setting'> & {
settings: ReadonlyArray<NetcattyPluginSettingContribution>;
}) {
const groups = new Map<string, NetcattyPluginSettingContribution[]>();
for (const setting of settings) {
if (!setting.visible) continue;
const group = setting.group ?? '';
groups.set(group, [...(groups.get(group) ?? []), setting]);
}
return <>{[...groups].map(([group, fields]) => <div key={group} className="space-y-3">
{group && <h4 className="pt-2 text-sm font-medium text-muted-foreground">{group}</h4>}
<div className="divide-y divide-border/50 rounded-xl bg-muted/30 px-4">
{fields.map((setting) => <PluginSettingField key={setting.id} {...props} setting={setting} />)}
</div>
</div>)}</>;
}
+17 -24
View File
@@ -1,40 +1,40 @@
import { useMemo } from 'react';
import { Blocks } from 'lucide-react';
import { useI18n } from '../../application/i18n/I18nProvider';
import { activeTabStore } from '../../application/state/activeTabStore';
import { subscribeSidePanelLiveSnapshot } from '../../application/state/sidePanelLiveStore';
import { useEmbeddedPluginView } from '../../application/state/useEmbeddedPluginView';
import { usePluginContributions } from '../../application/state/usePluginContributions';
import { collectPluginTerminalSidebarPages, PLUGIN_TERMINAL_SIDEBAR_CONTEXT, requestOpenPluginTerminalSidebar,
type PluginTerminalSidebarPage } from '../../application/state/usePluginTerminalSidebar';
import { PluginContributionIcon } from './PluginContributionIcon';
import { Button } from '../ui/button';
import { Popover, PopoverClose, PopoverContent, PopoverTrigger } from '../ui/popover';
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
import { PluginHostDrawnPage } from './PluginHostDrawnPage';
export function PluginTerminalSidebarLauncher({ sessionId }: { sessionId: string }) {
const { locale, t } = useI18n();
export function PluginTerminalSidebarLauncher({ sessionId, buttonClassName = 'h-6 w-6' }: { sessionId: string; buttonClassName?: string }) {
const { locale } = useI18n();
const contributions = usePluginContributions({ locale, context: PLUGIN_TERMINAL_SIDEBAR_CONTEXT });
const pages = collectPluginTerminalSidebarPages(contributions.snapshot.plugins);
if (!pages.length) return null;
return <Popover><PopoverTrigger asChild><Button variant="ghost" size="icon" className="h-6 w-6" aria-label={t('plugins.sidebar.extensions')}><Blocks size={14} /></Button></PopoverTrigger>
<PopoverContent align="end" className="max-h-80 w-64 overflow-y-auto p-1">
<p className="px-2 py-1 text-xs text-muted-foreground">{t('plugins.sidebar.extensions')}</p>
{pages.map((page) => <PopoverClose asChild key={page.tool}><button type="button" className="flex w-full items-center gap-2 rounded px-2 py-2 text-left text-sm hover:bg-accent"
return <>{pages.map((page) => <Tooltip key={page.tool}>
<TooltipTrigger asChild>
<Button type="button" variant="ghost" size="icon" className={buttonClassName}
aria-label={`${page.title} — ${page.pluginName}`}
onClick={() => requestOpenPluginTerminalSidebar(page.viewId, sessionId)}>
<PluginContributionIcon pluginId={page.pluginId} icon={page.icon} /><span className="min-w-0"><span className="block truncate">{page.title}</span><span className="block truncate text-xs text-muted-foreground">{page.pluginName}</span></span>
</button></PopoverClose>)}
</PopoverContent></Popover>;
<PluginContributionIcon pluginId={page.pluginId} icon={page.icon} size={14} />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">{page.title} — {page.pluginName}</TooltipContent>
</Tooltip>)}</>;
}
export function PluginTerminalSidebarPageHost({ page, tabId, sessionId, adapter, getSession, unavailable, focused, focusOnReveal }: {
export function PluginTerminalSidebarPageHost({ page, tabId, sessionId, getSession, unavailable, focused, showHeader = true }: {
page: PluginTerminalSidebarPage;
tabId: string;
sessionId: string;
adapter: ReturnType<typeof usePluginContributions>;
getSession(): string | null;
unavailable(): void;
focused(): void;
focusOnReveal: boolean;
showHeader?: boolean;
}) {
const { t } = useI18n();
const subscribeHidden = useMemo(() => (hide: () => void) => {
@@ -42,15 +42,8 @@ export function PluginTerminalSidebarPageHost({ page, tabId, sessionId, adapter,
const stopSession = subscribeSidePanelLiveSnapshot(true, () => { if (getSession() !== sessionId) hide(); });
return () => { stopTab(); stopSession(); };
}, [getSession, sessionId, tabId]);
const { mountRef, loading } = useEmbeddedPluginView({ viewId: page.viewId, version: page.version,
slot: `terminal-sidebar:${tabId}:${sessionId}`, active: true, context: PLUGIN_TERMINAL_SIDEBAR_CONTEXT,
adapter, subscribeHidden, unavailable, focused, focusOnReveal });
// The native page never receives these internal ownership IDs. Its state is
// scoped by the host; this does not authorize reading the terminal session.
return <section className="flex h-full min-h-0 flex-col overflow-hidden" aria-label={`${page.title} — ${page.pluginName}`} data-plugin-terminal-sidebar>
<header className="flex h-7 shrink-0 items-center gap-2 border-b border-border px-2 text-xs"><PluginContributionIcon pluginId={page.pluginId} icon={page.icon} size={12} /><span className="truncate">{page.title} · {page.pluginName}</span></header>
<div ref={mountRef} className="relative min-h-0 flex-1 overflow-hidden" data-plugin-view-slot="terminal-sidebar">
{loading && <p role="status" className="p-3 text-xs text-muted-foreground">{t('plugins.sidebar.loading')}</p>}
</div>
{showHeader && <header className="flex h-7 shrink-0 items-center gap-2 border-b border-border px-2 text-xs"><PluginContributionIcon pluginId={page.pluginId} icon={page.icon} size={12} /><span className="truncate">{page.title}</span></header>}
<div className="min-h-0 flex-1" onFocusCapture={focused}><PluginHostDrawnPage pluginId={page.pluginId} viewId={page.viewId} version={page.version} slot={`terminal-sidebar:${tabId}:${sessionId}`} active context={PLUGIN_TERMINAL_SIDEBAR_CONTEXT} subscribeHidden={subscribeHidden} unavailable={unavailable} loadingLabel={t('plugins.sidebar.loading')} /></div>
</section>;
}
+7 -17
View File
@@ -1,44 +1,34 @@
import { activeTabStore } from '../../application/state/activeTabStore';
import { useI18n } from '../../application/i18n/I18nProvider';
import { useEmbeddedPluginView } from '../../application/state/useEmbeddedPluginView';
import type { usePluginVaultPages } from '../../application/state/usePluginVaultPages';
import { PluginContributionIcon } from './PluginContributionIcon';
import { Button } from '../ui/button';
import { PluginHostDrawnPage } from './PluginHostDrawnPage';
type VaultPages = ReturnType<typeof usePluginVaultPages>;
const subscribeHidden = (hide: () => void) => activeTabStore.subscribeSync((id) => { if (id !== 'vault') hide(); });
export function PluginVaultNavigation({ model, collapsed }: { model: VaultPages; collapsed: boolean }) {
const { t } = useI18n();
if (!model.pages.length) return null;
return <div role="group" aria-label={t('plugins.vault.extensions')} className="mt-3 space-y-1 border-t border-border/50 pt-3">
{!collapsed && <p className="px-2 text-xs text-muted-foreground">{t('plugins.vault.extensions')}</p>}
return <>
{model.pages.map((page) => <Button key={page.key} type="button"
variant={model.selected?.key === page.key ? 'secondary' : 'ghost'}
className={`w-full ${collapsed ? 'justify-center px-0' : 'justify-start gap-3'}`}
className={`w-full h-10 ${collapsed ? 'justify-center p-0' : 'justify-start gap-3'} ${model.selected?.key === page.key ? 'bg-foreground/10 text-foreground hover:bg-foreground/15 border-border/40' : ''}`}
title={`${page.title} — ${page.pluginName}`}
aria-label={`${page.title} — ${page.pluginName}`}
aria-current={model.selected?.key === page.key ? 'page' : undefined}
onClick={() => model.select(page.key)}>
<PluginContributionIcon pluginId={page.pluginId} icon={page.icon} className="shrink-0" />
<PluginContributionIcon pluginId={page.pluginId} icon={page.icon} size={16} className="shrink-0" />
{!collapsed && <span className="truncate">{page.title}</span>}
</Button>)}
</div>;
</>;
}
export function PluginVaultPage({ model }: { model: VaultPages }) {
const { t } = useI18n();
const page = model.selected!;
const { mountRef, loading } = useEmbeddedPluginView({ viewId: page.viewId, version: page.version, slot: 'vault-page',
active: model.active, context: model.context, adapter: model.contributions, subscribeHidden, unavailable: model.fallback });
return <section className="flex min-h-0 flex-1 flex-col" aria-label={page.title} data-plugin-vault-page={page.key}>
<header className="app-no-drag flex h-12 shrink-0 items-center justify-between gap-3 border-b border-border px-4">
<div className="min-w-0"><h2 className="truncate text-sm font-medium">{page.title}</h2>
<p className="truncate text-xs text-muted-foreground">{page.pluginName} · {page.version}</p></div>
<Button variant="ghost" onClick={model.fallback}>{t('plugins.vault.back')}</Button>
</header>
<div ref={mountRef} className="relative min-h-0 flex-1 overflow-hidden" data-plugin-view-slot="vault-page">
{loading && <p role="status" className="p-4 text-sm text-muted-foreground">{t('plugins.vault.loading')}</p>}
</div>
<PluginHostDrawnPage pluginId={page.pluginId} viewId={page.viewId} version={page.version} slot="vault-page" active={model.active} context={model.context}
subscribeHidden={subscribeHidden} unavailable={model.fallback} loadingLabel={t('plugins.vault.loading')} />
</section>;
}
+41
View File
@@ -0,0 +1,41 @@
import React, { useLayoutEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
/** Geometry and dismissal belong to the host; content belongs to the plugin. */
export function PluginViewPopover({ anchor, open, dismiss, children }: {
anchor: string; open: boolean; dismiss(): void; children: React.ReactNode;
}) {
const marker = useRef<HTMLSpanElement>(null);
const panel = useRef<HTMLDivElement>(null);
const dismissRef = useRef(dismiss); dismissRef.current = dismiss;
const [position, setPosition] = useState<{ left: number; top: number; width: number } | null>(null);
useLayoutEffect(() => {
if (!open || !marker.current) { setPosition(null); return; }
const root = marker.current.getRootNode() as ShadowRoot;
const target = root.getElementById(anchor);
if (!(target instanceof marker.current.ownerDocument.defaultView!.HTMLElement)) return;
const doc = target.ownerDocument; const win = doc.defaultView!;
const update = () => {
const bounds = target.getBoundingClientRect();
if (!bounds.width || !bounds.height) { setPosition(null); dismissRef.current(); return; }
const width = Math.min(360, Math.max(220, bounds.width), win.innerWidth - 16);
setPosition({ left: Math.max(8, Math.min(bounds.left, win.innerWidth - width - 8)), top: Math.max(8, Math.min(bounds.bottom + 4, win.innerHeight - 248)), width });
};
const dismissNow = () => { setPosition(null); dismissRef.current(); };
const key = (event: KeyboardEvent) => { if (event.key === 'Escape') { event.preventDefault(); event.stopPropagation(); dismissNow(); target.focus(); } };
const outside = (event: PointerEvent) => { if (!event.composedPath().includes(target) && (!panel.current || !event.composedPath().includes(panel.current))) dismissNow(); };
const blocked = () => [...doc.querySelectorAll('[role=dialog][aria-modal=true]:not([data-plugin-dialog])')].some((dialog) => {
const rect = dialog.getBoundingClientRect(); return rect.width > 0 && rect.height > 0;
});
if (blocked()) { dismissNow(); return; }
const dialogs = new win.MutationObserver(() => { if (blocked()) dismissNow(); });
dialogs.observe(doc.body, { subtree: true, childList: true, attributes: true, attributeFilter: ['aria-modal', 'hidden', 'data-state'] });
const resize = new win.ResizeObserver(update); resize.observe(target); update();
win.addEventListener('resize', update); doc.addEventListener('scroll', update, true);
doc.addEventListener('keydown', key, true); doc.addEventListener('pointerdown', outside, true);
return () => { dialogs.disconnect(); resize.disconnect(); win.removeEventListener('resize', update); doc.removeEventListener('scroll', update, true); doc.removeEventListener('keydown', key, true); doc.removeEventListener('pointerdown', outside, true); };
}, [anchor, open]);
return <><span ref={marker} hidden />{open && position && marker.current && createPortal(
<div ref={panel} role="region" aria-label="Plugin menu" data-plugin-view-popover className="fixed z-[45] overflow-hidden rounded-lg border border-border bg-background shadow-xl" style={{ ...position, height: 240, maxHeight: 'calc(100vh - 16px)', contain: 'paint', isolation: 'isolate' }}>{children}</div>, marker.current.ownerDocument.body,
)}</>;
}
+6 -6
View File
@@ -1,11 +1,11 @@
import React, { useRef, useState } from 'react';
import { Info } from 'lucide-react';
import { CircleAlert, Info } from 'lucide-react';
import { Button } from '../../ui/button';
import { Popover, PopoverContent, PopoverTrigger } from '../../ui/popover';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../../ui/tooltip';
/** Hover/focus previews explanatory text; click pins it in the existing accessible popover. */
export function PluginInfo({ label, children, badgeCount, previewContent }: { label: string; children: React.ReactNode; badgeCount?: number; previewContent?: React.ReactNode }) {
export function PluginInfo({ label, children, badgeCount, previewContent, variant = 'info' }: { label: string; children: React.ReactNode; badgeCount?: number; previewContent?: React.ReactNode; variant?: 'info' | 'error' }) {
const [pinned, setPinned] = useState(false);
const [preview, setPreview] = useState(false);
const suppressFocusPreview = useRef(false);
@@ -13,13 +13,13 @@ export function PluginInfo({ label, children, badgeCount, previewContent }: { la
<TooltipProvider delayDuration={200}>
<Tooltip open={!pinned && preview} onOpenChange={(open) => { if (!open || !suppressFocusPreview.current) setPreview(open); }}>
<TooltipTrigger asChild><PopoverTrigger asChild>
<Button type="button" variant="ghost" size="sm" className="relative h-8 w-8 shrink-0 p-0 text-muted-foreground focus-visible:ring-2 focus-visible:ring-ring" aria-label={label} onBlur={() => { suppressFocusPreview.current = false; }} onPointerMove={() => { suppressFocusPreview.current = false; }}>
<Info size={15} />{Boolean(badgeCount) && <span aria-hidden="true" className="absolute right-0 top-0 flex h-3.5 min-w-3.5 items-center justify-center rounded-full bg-muted px-0.5 text-[9px] font-medium leading-none text-foreground">{badgeCount}</span>}
<Button type="button" variant="ghost" size="sm" className={`relative shrink-0 p-0 focus-visible:ring-2 focus-visible:ring-ring ${variant === 'error' ? 'h-6 w-6 text-red-600 hover:bg-red-500/10 hover:text-red-600 dark:text-red-400 dark:hover:text-red-400' : 'h-8 w-8 text-muted-foreground'}`} aria-label={label} onBlur={() => { suppressFocusPreview.current = false; }} onPointerMove={() => { suppressFocusPreview.current = false; }}>
{variant === 'error' ? <CircleAlert size={15} /> : <Info size={15} />}{Boolean(badgeCount) && <span aria-hidden="true" className="absolute right-0 top-0 flex h-3.5 min-w-3.5 items-center justify-center rounded-full bg-muted px-0.5 text-[9px] font-medium leading-none text-foreground">{badgeCount}</span>}
</Button>
</PopoverTrigger></TooltipTrigger>
<TooltipContent className="max-w-xs space-y-2 bg-popover p-3 text-xs leading-5 text-popover-foreground shadow-sm">{previewContent ?? children}</TooltipContent>
<TooltipContent className="max-h-64 max-w-xs space-y-2 overflow-y-auto break-words bg-popover p-3 text-xs leading-5 text-popover-foreground shadow-sm">{previewContent ?? children}</TooltipContent>
</Tooltip>
</TooltipProvider>
<PopoverContent onEscapeKeyDown={() => { suppressFocusPreview.current = true; }} aria-label={label} align="end" className="max-w-xs space-y-2 text-xs leading-5">{children}</PopoverContent>
<PopoverContent onEscapeKeyDown={() => { suppressFocusPreview.current = true; }} aria-label={label} align="end" className="max-h-64 max-w-xs space-y-2 overflow-y-auto break-words text-xs leading-5">{children}</PopoverContent>
</Popover>;
}
@@ -1,5 +1,6 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { JSDOM } from 'jsdom';
import React from 'react';
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
import { I18nProvider } from '../../../application/i18n/I18nProvider';
@@ -7,6 +8,12 @@ import { PluginPackageManager } from './PluginPackageManager';
import type { usePluginManager } from '../../../application/state/usePluginManager';
test('installed grid filters, opens one detail and restores search, scroll and card focus without pill navigation', async () => {
const dom = new JSDOM('<!doctype html><html><body></body></html>', { url: 'http://localhost' });
const previousDom = new Map<string, PropertyDescriptor | undefined>();
for (const [key, value] of Object.entries({ window: dom.window, document: dom.window.document })) {
previousDom.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
Object.defineProperty(globalThis, key, { configurable: true, writable: true, value });
}
const globals = globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean };
const previous = globals.IS_REACT_ACT_ENVIRONMENT; globals.IS_REACT_ACT_ENVIRONMENT = true;
const raf = globalThis.requestAnimationFrame;
@@ -39,7 +46,8 @@ test('installed grid filters, opens one detail and restores search, scroll and c
assert.ok(authorRow.props.className.includes('gap-3'), 'author and source groups have 12px separation');
assert.ok(alphaCard.findAllByType('p').some((node) => node.props.className?.includes('line-clamp-3')), 'card description is bounded');
assert.match(JSON.stringify(renderer.toJSON()), /MIT/);
assert.match(JSON.stringify(renderer.toJSON()), /Incompatible version/);
assert.doesNotMatch(JSON.stringify(renderer.toJSON()), /Incompatible version/, 'error details stay hidden until requested');
assert.ok(renderer.root.findAllByType('button').some((node) => node.props['aria-label']?.includes('Beta') && node.props['aria-haspopup'] === 'dialog'), 'error icon opens details without navigating the card');
assert.ok(renderer.root.findAllByType('div').some((node) => node.props.className?.includes('repeat(auto-fill')));
const pill = renderer.root.findAllByType('button').find((node) => node.props['aria-pressed'] !== undefined)!;
await act(async () => pill.props.onClick());
@@ -63,5 +71,11 @@ test('installed grid filters, opens one detail and restores search, scroll and c
assert.equal(unknown.length, 2);
assert.ok(unknown.every((node) => node.props.disabled === true && node.props['aria-pressed'] === undefined));
await act(async () => renderer.unmount());
} finally { globals.IS_REACT_ACT_ENVIRONMENT = previous; globalThis.requestAnimationFrame = raf; }
} finally {
globals.IS_REACT_ACT_ENVIRONMENT = previous; globalThis.requestAnimationFrame = raf;
dom.window.close();
for (const [key, descriptor] of previousDom) {
if (descriptor) Object.defineProperty(globalThis, key, descriptor); else Reflect.deleteProperty(globalThis, key);
}
}
});
@@ -66,20 +66,6 @@ test('plugin manager is single-instance, closes with Escape, restores focus and
assert.equal(document.querySelector('[role="dialog"]'), null);
assert.equal(document.activeElement, trigger);
assert.equal(unmounts, 2);
let handedOff = false;
await act(async () => root.render(<I18nProvider locale="en"><PluginManagementDialog>{(handoff) => <button onClick={() => handoff(() => {
handedOff = true;
assert.equal(document.querySelector('[role="dialog"]'), null);
assert.notEqual(document.body.style.pointerEvents, 'none');
})}>Open contributed settings view</button>}</PluginManagementDialog></I18nProvider>));
await click(trigger);
await click(document.querySelector<HTMLButtonElement>('[role="dialog"] button:not([data-dialog-close])')!);
// Radix schedules close-focus cleanup after the React close commit. The
// settle inside click's act() can precede that commit; observe the actual
// handoff instead of assuming the cleanup timer already ran.
const handoffDeadline = Date.now() + 2_000;
while (!handedOff && Date.now() < handoffDeadline) await act(async () => { await settle(); });
assert.equal(handedOff, true, 'external view opens only after modal ownership is released');
function ControlledEntry() {
const [open, setOpen] = useState(false);
const browse = useRef<HTMLButtonElement | null>(null);
@@ -1,4 +1,4 @@
import React, { useRef, useState } from 'react';
import React, { useState } from 'react';
import { useI18n } from '../../../application/i18n/I18nProvider';
import { Button } from '../../ui/button';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '../../ui/dialog';
@@ -10,41 +10,30 @@ export function PluginManagementDialog({ children, trigger, open: controlledOpen
onOpenChange?: (open: boolean) => void;
restoreFocusTo?: React.RefObject<HTMLElement | null>;
fallbackFocusTo?: React.RefObject<HTMLElement | null>;
children: React.ReactNode | ((handoff: (action: () => void) => void) => React.ReactNode);
children: React.ReactNode;
}) {
const { t } = useI18n();
const [internalOpen, setInternalOpen] = useState(false);
const open = controlledOpen ?? internalOpen;
const setOpen = (value: boolean) => { setInternalOpen(value); onOpenChange?.(value); };
const pendingHandoff = useRef<(() => void) | null>(null);
const handoff = (action: () => void) => { pendingHandoff.current = action; setOpen(false); };
return (
<Dialog open={open} onOpenChange={setOpen}>
{trigger !== null && <DialogTrigger asChild>
{trigger ?? <Button variant="outline">{t('settings.plugins.openManager')}</Button>}
</DialogTrigger>}
<DialogContent onCloseAutoFocus={(event) => {
const action = pendingHandoff.current;
pendingHandoff.current = null;
if (action) {
const previous = restoreFocusTo?.current;
const target = previous?.isConnected && !previous.matches(':disabled') ? previous : fallbackFocusTo?.current;
if (target?.isConnected) {
event.preventDefault();
// Wait for Radix to release modal pointer/focus ownership before opening
// the existing external plugin view host (including exit animations).
queueMicrotask(action);
} else {
const previous = restoreFocusTo?.current;
const target = previous?.isConnected && !previous.matches(':disabled') ? previous : fallbackFocusTo?.current;
if (target?.isConnected) {
event.preventDefault();
target.focus({ preventScroll: true });
}
target.focus({ preventScroll: true });
}
}} className="app-no-drag flex h-[min(88dvh,900px)] w-[calc(100vw-2rem)] max-w-[1180px] flex-col gap-0 overflow-hidden p-0">
<DialogHeader className="shrink-0 border-b px-6 py-4 pr-12 text-left">
<DialogTitle>{t('settings.plugins.title')}</DialogTitle>
</DialogHeader>
<div data-settings-scroll-pane className="min-h-0 flex-1 overflow-y-auto overscroll-contain p-4 sm:p-6">
{typeof children === 'function' ? children(handoff) : children}
{children}
</div>
</DialogContent>
</Dialog>
@@ -21,7 +21,8 @@ test('installed cards and selected details keep author before compact source met
assert.equal(row.querySelector('[data-plugin-source]')?.textContent, 'Local');
assert.match(row.textContent!, /PublisherLocal/);
assert.doesNotMatch(article.textContent!, /Source:/);
assert.match(article.textContent!, /Diagnostic remains visible/);
assert.doesNotMatch(article.textContent!, /Diagnostic remains visible/);
assert.ok(article.querySelector('button[aria-haspopup="dialog"][aria-label$="Synthetic"]'), 'recorded errors are available from the name-adjacent icon');
dom.window.close();
}
});
@@ -9,6 +9,8 @@ import { usePluginManager } from '../../../application/state/usePluginManager';
import { useI18n } from '../../../application/i18n/I18nProvider';
import { Button } from '../../ui/button';
import { Input } from '../../ui/input';
import { PluginInfo } from './PluginInfo';
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from '../../ui/dialog';
export function PluginPackageDetails({ plugin }: { plugin: NetcattyInstalledPlugin }) {
const { t, resolvedLocale } = useI18n();
@@ -50,7 +52,7 @@ export function PluginPackageDetails({ plugin }: { plugin: NetcattyInstalledPlug
</div>;
}
export function PluginPackageManager({ manager, onSelectionChange, initialSelectedId }: { initialSelectedId?: string | null; manager: ReturnType<typeof usePluginManager>; onSelectionChange?: (id: string | null) => void }) {
export function PluginPackageManager({ manager, onSelectionChange, initialSelectedId, detailActions }: { initialSelectedId?: string | null; manager: ReturnType<typeof usePluginManager>; onSelectionChange?: (id: string | null) => void; detailActions?: React.ReactNode }) {
const { t, resolvedLocale } = useI18n();
const [selected, setSelected] = useState<string | null>(initialSelectedId ?? null);
const [search, setSearch] = useState('');
@@ -77,6 +79,7 @@ export function PluginPackageManager({ manager, onSelectionChange, initialSelect
const visible = selectedPlugin ? [selectedPlugin] : manager.plugins.filter((plugin) => (!sourceFilter || (plugin.source?.id ?? 'local') === sourceFilter) && [nameOf(plugin), plugin.manifest?.publisher, plugin.manifest?.description, plugin.id].some((value) => JSON.stringify(value ?? '').toLocaleLowerCase().includes(search.trim().toLocaleLowerCase())));
const [removing, setRemoving] = useState<string | null>(null);
const [clearSource, setClearSource] = useState(false);
const [uninstallError, setUninstallError] = useState<string | null>(null);
if (!manager.available) return null;
return <section ref={rootRef} className="space-y-4" aria-label={t('settings.plugins.manager.installed')}>
{selectedPlugin && <Button variant="ghost" size="sm" onClick={() => choose(null)}><ArrowLeft size={14} className="mr-2" />{t('settings.plugins.manager.installed')}</Button>}
@@ -89,10 +92,18 @@ export function PluginPackageManager({ manager, onSelectionChange, initialSelect
{visible.map((plugin) => <article key={plugin.id} data-plugin-detail={selectedPlugin ? true : undefined} tabIndex={selectedPlugin ? -1 : undefined} aria-label={nameOf(plugin)} className={`min-w-0 rounded-lg focus:outline-none ${selectedPlugin ? 'space-y-4' : 'plugin-card flex min-h-[200px] flex-col gap-3 bg-card p-4'}`}>
<div className="flex flex-wrap items-center gap-3">
<div className="flex h-12 w-12 shrink-0 items-center justify-center"><PluginBrandIcon plugin={plugin} size={32} /></div>
<button type="button" data-plugin-card={plugin.id} className="min-w-0 flex-1 rounded text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" aria-expanded={selected === plugin.id} onClick={() => choose(selected === plugin.id ? null : plugin.id)}>
<div className="flex min-w-0 flex-1 items-center gap-1">
<button type="button" data-plugin-card={plugin.id} className="min-w-0 rounded text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" aria-expanded={selected === plugin.id} onClick={() => choose(selected === plugin.id ? null : plugin.id)}>
<div className="break-words text-sm font-medium">{nameOf(plugin)}</div>
</button>
{plugin.runtime.lastError && <PluginInfo variant="error" label={`${t('settings.plugins.manager.lastError')} — ${nameOf(plugin)}`}>
<p className="font-medium">{t('settings.plugins.manager.lastError')}</p>
<p className="whitespace-pre-wrap [overflow-wrap:anywhere]">{plugin.runtime.lastError}</p>
</PluginInfo>}
</div>
<div className="ml-auto flex shrink-0 items-center gap-2" data-plugin-header-actions="">
{selected === plugin.id && detailActions}
<Button variant="ghost" size="sm" type="button"
className={`h-7 shrink-0 rounded-full border-0 px-3 text-xs focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background ${manager.statusKnown !== false && plugin.enabled ? 'bg-primary/10 hover:bg-primary/15' : 'bg-muted/60 text-muted-foreground hover:bg-muted hover:text-foreground'}`}
style={manager.statusKnown !== false && plugin.enabled ? { color: 'color-mix(in srgb, hsl(var(--primary)) 40%, hsl(var(--foreground)))' } : undefined}
@@ -102,6 +113,7 @@ export function PluginPackageManager({ manager, onSelectionChange, initialSelect
disabled={manager.busy || manager.statusKnown === false} onClick={() => void manager.setEnabled(plugin.id, !plugin.enabled)}>
{t(manager.statusKnown === false ? 'settings.plugins.permissions.statusUnknown' : plugin.enabled ? 'settings.plugins.manager.enabled' : 'settings.plugins.manager.disabled')}
</Button>
</div>
</div>
<PluginAuthorRow plugin={plugin} showSource />
<p className={`break-words text-sm leading-5 text-muted-foreground ${selectedPlugin ? 'whitespace-pre-wrap' : 'line-clamp-3'}`}>{descriptionOf(plugin) || t('settings.plugins.manager.noDescription')}</p>
@@ -109,16 +121,31 @@ export function PluginPackageManager({ manager, onSelectionChange, initialSelect
<p className="break-words text-xs text-muted-foreground">{plugin.activeVersion}{plugin.manifest?.license ? ` · ${plugin.manifest.license}` : ''}</p>
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs" onClick={() => choose(plugin.id)} aria-label={`${t('settings.plugins.manager.details')} ${nameOf(plugin)}`}>{t('settings.plugins.manager.details')}</Button>
</div> : <p className="text-xs text-muted-foreground">{plugin.activeVersion}{plugin.manifest?.license ? ` · ${plugin.manifest.license}` : ''}</p>}
{plugin.runtime.lastError && <p role="status" className="break-words text-xs text-destructive">{t('settings.plugins.manager.lastError')}: {plugin.runtime.lastError}</p>}
{selected === plugin.id && <>
<PluginPackageDetails plugin={plugin} />
<PluginPermissionPanel key={plugin.id} pluginId={plugin.id} onChanged={async () => { await manager.refresh(); }} />
{removing === plugin.id ? <div className="space-y-2 rounded-lg border border-destructive/30 p-3">
<p className="text-xs">{t('settings.plugins.manager.removeNote')}</p>
<label className="flex items-start gap-2 text-xs"><input type="checkbox" checked={clearSource} onChange={(event) => setClearSource(event.target.checked)} />{t('settings.plugins.sources.clearPrivate')}</label>
<Button variant="destructive" size="sm" disabled={manager.busy} onClick={() => { void manager.uninstall(plugin.id, clearSource); setRemoving(null); }}>{t('settings.plugins.manager.remove')}</Button>
<Button variant="ghost" size="sm" onClick={() => setRemoving(null)}>{t('common.cancel')}</Button>
</div> : <Button variant="ghost" size="sm" disabled={manager.busy} onClick={() => { setClearSource(false); setRemoving(plugin.id); }}>{t('settings.plugins.manager.remove')}</Button>}
<PluginPermissionPanel key={plugin.id} pluginId={plugin.id} onChanged={async () => { await manager.refresh(); }}
actions={<Dialog open={removing === plugin.id} onOpenChange={(open) => {
if (manager.busy) return;
setRemoving(open ? plugin.id : null);
if (open) { setClearSource(false); setUninstallError(null); }
}}>
<DialogTrigger asChild><Button variant="destructive" size="sm" disabled={manager.busy}>{t('settings.plugins.manager.remove')}</Button></DialogTrigger>
<DialogContent hideCloseButton={manager.busy} className="max-h-[85dvh] w-[calc(100vw-2rem)] max-w-md overflow-y-auto" data-plugin-uninstall-confirm="">
<DialogHeader className="pr-6"><DialogTitle>{t('settings.plugins.manager.remove')} · {nameOf(plugin)}</DialogTitle></DialogHeader>
<p className="text-sm text-muted-foreground">{t('settings.plugins.manager.removeNote')}</p>
<label className="flex items-start gap-2 text-sm"><input className="mt-1" type="checkbox" checked={clearSource} disabled={manager.busy} onChange={(event) => setClearSource(event.target.checked)} />{t('settings.plugins.sources.clearPrivate')}</label>
{uninstallError && <p role="alert" className="break-words text-sm text-destructive">{uninstallError}</p>}
<DialogFooter>
<Button variant="outline" disabled={manager.busy} onClick={() => setRemoving(null)}>{t('common.cancel')}</Button>
<Button variant="destructive" disabled={manager.busy} aria-busy={manager.busy} onClick={async () => {
setUninstallError(null);
try { await manager.uninstall(plugin.id, clearSource); choose(null); }
catch (cause) { setUninstallError(cause instanceof Error ? cause.message : String(cause)); }
}}>{t('settings.plugins.manager.remove')}</Button>
</DialogFooter>
</DialogContent>
</Dialog>}
/>
</>}
</article>)}
</div>
@@ -7,9 +7,10 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '.
import { SettingRow } from '../settings-ui';
/** Host UI only. Plugin views cannot query or mutate this security state. */
export function PluginPermissionPanel({ pluginId, onChanged }: {
export function PluginPermissionPanel({ pluginId, onChanged, actions }: {
pluginId: string;
onChanged: () => Promise<void>;
actions?: React.ReactNode;
}) {
const { t, resolvedLocale } = useI18n();
const [snapshot, setSnapshot] = useState<NetcattyPluginPermissionSnapshot | null>(null);
@@ -102,7 +103,10 @@ export function PluginPermissionPanel({ pluginId, onChanged }: {
</li>)}</ol>
</details>
<p className="text-xs text-muted-foreground">{t('settings.plugins.permissions.revokeNote')}</p>
<Button variant="destructive" size="sm" disabled={busy} onClick={() => void revoke()}>{t('settings.plugins.permissions.revoke')}</Button>
</>}
<div className="flex flex-wrap items-center gap-2">
{snapshot && <Button variant="secondary" size="sm" className="bg-amber-500 text-amber-950 hover:bg-amber-400" disabled={busy} onClick={() => void revoke()}>{t('settings.plugins.permissions.revoke')}</Button>}
{actions}
</div>
</section>;
}
@@ -12,21 +12,23 @@ async function fixture() {
for (const key of ['window', 'document', 'navigator', 'HTMLElement', 'HTMLInputElement', 'Element', 'Node', 'NodeFilter', 'MutationObserver', 'DocumentFragment', 'CustomEvent', 'Event', 'KeyboardEvent', 'MouseEvent']) install(key, key === 'window' ? dom.window : dom.window[key as keyof typeof dom.window]);
install('getComputedStyle', dom.window.getComputedStyle.bind(dom.window));
install('IS_REACT_ACT_ENVIRONMENT', true);
const frames = new Map<number, FrameRequestCallback>();
let frameId = 0;
install('requestAnimationFrame', (callback: FrameRequestCallback) => { frames.set(++frameId, callback); return frameId; });
install('cancelAnimationFrame', (id: number) => frames.delete(id));
const scrolled: HTMLElement[] = [];
dom.window.HTMLElement.prototype.scrollIntoView = function () { scrolled.push(this); };
install('requestAnimationFrame', dom.window.requestAnimationFrame.bind(dom.window));
install('cancelAnimationFrame', dom.window.cancelAnimationFrame.bind(dom.window));
dom.window.HTMLElement.prototype.scrollIntoView = () => {};
const { default: React, act } = await import('react');
const { createRoot } = await import('react-dom/client');
const { I18nProvider } = await import('../../../application/i18n/I18nProvider');
const { Tabs } = await import('../../ui/tabs');
const { default: SettingsPluginsTab } = await import('./SettingsPluginsTab');
const { OPEN_PLUGIN_VIEW_EVENT } = await import('../../../application/state/usePluginViewLifecycle');
const viewRequests: unknown[] = [];
dom.window.addEventListener(OPEN_PLUGIN_VIEW_EVENT, (event) => viewRequests.push((event as CustomEvent).detail));
const plugin = { id: 'com.example.alpha', enabled: true, activeVersion: '1.0.0', archiveSha256: 'a'.repeat(64),
source: { id: 'local', kind: 'local', name: 'Local import', origin: null },
manifest: { manifestVersion: 1, id: 'com.example.alpha', name: 'Alpha', version: '1.0.0', publisher: 'example', engines: { netcatty: '>=0.0.0', api: '>=0.1.0-internal <0.2.0' }, main: { browser: 'index.js' }, contributes: { settings: [{ id: 'com.example.alpha.setting', label: 'Alpha setting', control: 'text', scope: 'application' }] } },
runtime: { status: 'stopped', kind: null, lastError: null, quarantinedAt: null } };
let savedValue = 'synthetic';
let rejectSave = false;
let installed = plugin;
let delayed = false;
const pending: (() => void)[] = [];
@@ -38,12 +40,18 @@ async function fixture() {
async getPluginLogStatus() { return { saveToDisk: false, verbose: false, droppedRecords: 0, writeErrors: 0, pendingBytes: 0, maxBytes: 3145728, retentionDays: 7 }; },
async getPluginContributions(query: NetcattyPluginContributionQuery) {
if (delayed && query.scopeIds) await new Promise<void>((resolve) => pending.push(resolve));
return { locale: 'en', plugins: [{ id: plugin.id, version: '1.0.0', displayName: 'Alpha', commands: [], keybindings: [], menus: [], views: [], settings: [{ id: 'com.example.alpha.setting', label: 'Alpha setting', control: 'text', scope: 'application', scopeId: 'application', value: 'synthetic', visible: true, configured: true }] }] };
if (!installed.enabled) return { locale: 'en', plugins: [] };
return { locale: 'en', plugins: [{ id: plugin.id, version: '1.0.0', displayName: 'Alpha', commands: [], keybindings: [], menus: [], views: [{ id: 'com.example.alpha.page', title: 'Ordinary page', location: 'tab', visible: true }], settings: [{ id: 'com.example.alpha.setting', label: 'Alpha setting', control: 'text', scope: 'application', scopeId: 'application', value: savedValue, group: 'General', visible: true, configured: true }] }] };
},
async getPluginScopeCatalog() { return { workspace: [], host: [], session: [], device: [] }; },
async listPluginSources() { return []; },
async listPluginCatalog() { return { entries: [], source: 'builtin', error: null }; },
async getPluginPermissions() { return { grants: [], audit: [] }; },
async updatePluginSetting(_pluginId: string, _settingId: string, value: string) {
if (rejectSave) throw new Error('Could not save');
savedValue = value; return { restartRequired: false };
},
async resetPluginSetting() { savedValue = 'synthetic'; return { restartRequired: false }; },
} });
const root = createRoot(document.getElementById('root')!);
const settle = () => new Promise((resolve) => setTimeout(resolve, 25));
@@ -51,99 +59,107 @@ async function fixture() {
await perform(() => root.render(<I18nProvider locale="en"><Tabs defaultValue="plugins"><SettingsPluginsTab /></Tabs></I18nProvider>));
const gear = document.querySelector<HTMLButtonElement>('button[aria-label="Settings for Alpha"]')!;
assert.ok(gear && !gear.disabled);
const flushFrames = async (count = 3) => {
for (let i = 0; i < count; i++) await act(async () => {
const current = [...frames.values()]; frames.clear();
for (const callback of current) callback(i * 16);
});
};
return {
gear, scrolled, perform, flushFrames,
section: () => document.querySelector<HTMLElement>('section[aria-labelledby="plugin-settings-com.example.alpha"]'),
gear, perform, viewRequests,
rejectSave: (value: boolean) => { rejectSave = value; },
section: () => document.querySelector<HTMLElement>('[data-plugin-settings-form]'),
open: async (slow = false) => { delayed = slow; await perform(() => { gear.focus(); gear.click(); }); },
release: async () => { delayed = false; await perform(() => { for (const resolve of pending.splice(0)) resolve(); }); },
selectTab: async (name: string) => {
const tab = [...document.querySelectorAll<HTMLElement>('[role=dialog] [role=tab]')].find((element) => element.textContent?.startsWith(name))!;
await perform(() => { tab.focus(); tab.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); });
},
close: async () => { await perform(() => document.activeElement!.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))); },
replaceOwner: async () => { installed = { ...plugin, source: { ...plugin.source, id: 'third-party:replacement', kind: 'third-party' } }; await perform(() => window.dispatchEvent(new Event('netcatty:plugin-packages-changed'))); },
disable: async () => { installed = { ...installed, enabled: false }; await perform(() => { for (const callback of [...listeners]) callback(); }); },
refresh: async () => { await perform(() => { for (const callback of [...listeners]) callback(); }); },
dispose: async () => {
await act(async () => root.unmount()); dom.window.close();
await act(async () => { root.unmount(); }); dom.window.close();
for (const [key, descriptor] of previous) { if (descriptor) Object.defineProperty(globalThis, key, descriptor); else Reflect.deleteProperty(globalThis, key); }
},
};
}
test('gear entry scrolls and focuses its settings once, after dialog autofocus', async () => {
test('gear opens a dedicated settings modal without management chrome and restores focus', async () => {
const f = await fixture();
try {
await f.open();
assert.notEqual(document.activeElement, f.section());
await f.flushFrames();
assert.deepEqual(f.scrolled, [f.section()]);
assert.equal(document.activeElement, f.section());
assert.ok(document.querySelector('[data-plugin-settings-dialog="com.example.alpha"]'));
assert.equal(document.querySelector('[data-plugin-detail]'), null);
assert.equal(document.querySelector('[role=dialog] [role=tablist]'), null);
const input = document.querySelector<HTMLInputElement>('input[aria-label="Alpha setting"]')!;
input.focus();
await f.refresh(); await f.flushFrames();
assert.equal(document.activeElement, input, 'later contribution refresh cannot steal focus');
assert.equal(f.scrolled.length, 1);
input.focus(); await f.refresh();
assert.equal(document.activeElement, input, 'refresh cannot steal focus');
await f.close();
assert.equal(document.querySelector('[role=dialog]'), null);
assert.equal(document.activeElement, f.gear);
assert.deepEqual(f.viewRequests, [], 'settings never open custom plugin pages');
} finally { await f.dispose(); }
});
test('gear entry waits for slow contributions before targeting the existing settings section', async () => {
test('settings modal saves across reopening, reports failures and resets', async () => {
const f = await fixture();
try {
await f.open(true); await f.flushFrames();
assert.equal(f.section(), null); assert.equal(f.scrolled.length, 0);
await f.release(); await f.flushFrames();
assert.equal(f.scrolled.length, 1); assert.equal(document.activeElement, f.section());
await f.open();
const section = f.section()!;
assert.equal(document.querySelector('[data-host-security-surface]'), null);
assert.equal(section.querySelector('h4')?.textContent, 'General');
assert.doesNotMatch(section.textContent!, /com\.example\.alpha|Ordinary page/);
assert.equal(section.querySelector('[data-plugin-remote-surface]'), null);
const input = () => document.querySelector<HTMLInputElement>('input[aria-label="Alpha setting"]')!;
const edit = async (value: string) => {
await f.perform(() => {
input().focus();
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!.call(input(), value);
input().dispatchEvent(new Event('input', { bubbles: true }));
input().blur();
});
};
await edit('Saved preference');
await f.close(); await f.open();
assert.equal(input().value, 'Saved preference');
f.rejectSave(true); await edit('Rejected preference');
assert.match(f.section()!.querySelector('[role=alert]')!.textContent!, /Could not save/);
f.rejectSave(false);
await f.close(); await f.open();
assert.equal(input().value, 'Saved preference', 'failed save did not replace the stored value');
await f.perform(() => [...f.section()!.querySelectorAll<HTMLButtonElement>('button')].find(button => button.getAttribute('aria-label') === 'Reset')!.click());
assert.equal(input().value, 'synthetic');
assert.deepEqual(f.viewRequests, [], 'settings never open custom plugin pages');
} finally { await f.dispose(); }
});
test('closing between animation frames cancels an already scheduled gear focus', async () => {
test('a slow settings response renders in the modal and never reopens a dismissed modal', async () => {
const f = await fixture();
try {
await f.open(); await f.flushFrames(1);
await f.close(); await f.flushFrames();
assert.equal(f.scrolled.length, 0);
await f.open(true);
assert.ok(document.querySelector('[role=dialog] [role=status]'));
await f.release();
assert.ok(document.querySelector('input[aria-label="Alpha setting"]'));
await f.close(); await f.open(true); await f.close(); await f.release();
assert.equal(document.querySelector('[role=dialog]'), null);
assert.equal(document.activeElement, f.gear);
} finally { await f.dispose(); }
});
for (const cancellation of ['tab', 'plugin', 'owner', 'close']) test(`pending gear entry cancels on ${cancellation} change`, async () => {
test('removing settings contributions closes the open modal', async () => {
const f = await fixture();
try {
await f.open(true);
if (cancellation === 'tab') await f.selectTab('Official');
if (cancellation === 'plugin') await f.perform(() => document.querySelector<HTMLButtonElement>('[data-plugin-card="com.example.alpha"]')!.click());
if (cancellation === 'owner') await f.replaceOwner();
if (cancellation === 'close') await f.close();
await f.release();
if (cancellation === 'tab') await f.selectTab('Installed');
if (cancellation === 'tab' || cancellation === 'plugin') await f.perform(() => document.querySelector<HTMLButtonElement>('[data-plugin-card="com.example.alpha"]')!.click());
await f.flushFrames();
assert.equal(f.scrolled.length, 0, 'late data or returning to the plugin cannot revive the cancelled intent');
if (cancellation === 'close') {
assert.equal(document.querySelector('[role=dialog]'), null);
assert.equal(document.activeElement, f.gear);
}
await f.open(); await f.disable();
assert.equal(document.querySelector('[data-plugin-settings-dialog]'), null);
} finally { await f.dispose(); }
});
test('normal Browse and detail entry retain their existing detail focus', async () => {
test('plugin details offer the same modal and Escape returns to the detail first', async () => {
const f = await fixture();
try {
const browse = [...document.querySelectorAll<HTMLButtonElement>('button')].find((button) => button.textContent === 'Browse')!;
const browse = [...document.querySelectorAll<HTMLButtonElement>('button')].find(button => button.textContent === 'Browse')!;
await f.perform(() => { browse.focus(); browse.click(); });
await f.perform(() => document.querySelector<HTMLButtonElement>('[data-plugin-card="com.example.alpha"]')!.click());
await f.flushFrames();
assert.ok(f.section()); assert.equal(f.scrolled.length, 0);
assert.equal(document.activeElement, document.querySelector('[data-plugin-detail]'));
assert.equal(f.section(), null, 'settings are not embedded below management content');
const gear = document.querySelector<HTMLButtonElement>('[data-plugin-detail] button[aria-label="Settings for Alpha"]')!;
assert.ok(gear);
await f.perform(() => { gear.focus(); gear.click(); });
assert.ok(f.section());
await f.close();
assert.equal(f.section(), null);
assert.ok(document.querySelector('[data-plugin-detail]'));
assert.equal(document.activeElement, gear);
await f.close(); assert.equal(document.activeElement, browse);
} finally { await f.dispose(); }
});
@@ -47,7 +47,7 @@ test('landing rows expose real sources/status/settings and quick toggles preserv
const sourceRows = [...document.querySelectorAll('[data-plugin-author-row]')];
assert.equal(sourceRows[0].querySelector('[data-plugin-source]')?.textContent, 'Official');
const content = sourceRows[0].parentElement!;
assert.ok(content.classList.contains('gap-1.5'), 'title, metadata and description have 6px vertical gaps');
assert.ok(content.classList.contains('gap-1'), 'title, metadata and description have 4px vertical gaps');
assert.ok(content.classList.contains('flex-col'));
assert.ok(content.classList.contains('min-w-0'));
assert.equal(content.firstElementChild?.querySelector('[title="Alpha"]')?.textContent, 'Alpha');
@@ -1,5 +1,5 @@
import React, { useCallback, useMemo, useState } from 'react';
import { RefreshCw, Settings2 } from 'lucide-react';
import { RefreshCw, Settings } from 'lucide-react';
import { useI18n } from '../../../application/i18n/I18nProvider';
import type { usePluginUpdateCheck } from '../../../application/state/usePluginUpdateCheck';
import type { usePluginManager } from '../../../application/state/usePluginManager';
@@ -62,20 +62,21 @@ export function PluginSettingsLanding({ manager, settingsAvailable, settingsErro
const name = nameOf(plugin);
const description = localized(plugin.manifest?.description);
const status = statusOf(plugin);
const hasError = manager.statusKnown && ['error', 'quarantined'].includes(plugin.runtime.status);
const availableUpdate = updates?.result?.updates.find((item) => item.pluginId === plugin.id && item.sourceId === plugin.source?.id);
const declaredSettings = Boolean(plugin.manifest?.contributes?.settings?.length || plugin.manifest?.contributes?.views?.some((view) => view.location === 'settings'));
return <SettingRow key={plugin.id} leading={<PluginBrandIcon plugin={plugin} size={24} />} description={<div className="flex min-w-0 flex-col gap-1.5 text-xs text-muted-foreground">
<span className="flex min-w-0 items-baseline gap-2 text-sm font-medium text-foreground">
const declaredSettings = Boolean(plugin.manifest?.contributes?.settings?.length);
return <SettingRow key={plugin.id} leading={<PluginBrandIcon plugin={plugin} size={40} />} description={<div className="flex min-w-0 flex-col gap-1 text-xs text-muted-foreground">
<span className="flex min-w-0 items-center gap-2 text-sm font-medium text-foreground">
<span className="truncate" title={name}>{name}</span>
{status && <span role="status" className={`inline-flex h-5 shrink-0 items-center justify-center rounded-full px-2 text-[10px] font-medium leading-4 ${hasError ? 'bg-red-500/15 text-red-600 dark:text-red-400' : 'bg-muted text-muted-foreground'}`}>{status}</span>}
{plugin.activeVersion && <span className="shrink-0 text-xs font-normal text-muted-foreground">{plugin.activeVersion}</span>}
</span>
<PluginAuthorRow plugin={plugin} showSource />
{status && <span role="status" className="block break-words">{status}</span>}
{availableUpdate && <span className="block text-primary">{t('settings.plugins.landing.updateAvailable').replace('{version}', availableUpdate.availableVersion)}</span>}
{description && <span className="line-clamp-2 break-words">{description}</span>}
</div>}>
<div className="flex items-center gap-3">
{declaredSettings && <Button className="focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2" variant="ghost" size="icon" disabled={manager.busy || !manager.statusKnown || !settingsAvailable.has(plugin.id)} aria-label={t('settings.plugins.landing.settingsFor').replace('{name}', name)} title={t(settingsAvailable.has(plugin.id) ? 'common.settings' : !plugin.enabled ? 'settings.plugins.landing.enableSettings' : 'settings.plugins.landing.settingsUnavailable')} onClick={(event) => onManage(plugin.id, event.currentTarget)}><Settings2 size={16} /></Button>}
<div className="flex shrink-0 items-center gap-2">
{declaredSettings && <Button className="h-8 w-8 rounded-full text-muted-foreground hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2" variant="ghost" size="icon" disabled={manager.busy || !manager.statusKnown || !settingsAvailable.has(plugin.id)} aria-label={t('settings.plugins.landing.settingsFor').replace('{name}', name)} title={t(settingsAvailable.has(plugin.id) ? 'common.settings' : !plugin.enabled ? 'settings.plugins.landing.enableSettings' : 'settings.plugins.landing.settingsUnavailable')} onClick={(event) => onManage(plugin.id, event.currentTarget)}><Settings size={15} /></Button>}
<Toggle checked={plugin.enabled} disabled={manager.busy || !manager.available || !manager.statusKnown} ariaLabel={`${t(plugin.enabled ? 'settings.plugins.manager.disable' : 'settings.plugins.manager.enable')} ${name}`} onChange={(enabled) => void manager.setEnabled(plugin.id, enabled)} />
</div>
</SettingRow>;
@@ -5,8 +5,8 @@ import { createRequire } from 'node:module';
import { JSDOM } from 'jsdom';
const require = createRequire(import.meta.url);
test('real Settings landing leaves views visible while idle; log and Ask dialogs suspend and restore them', async () => {
const dom = new JSDOM('<!doctype html><html><body><div id="root"></div><div id="native-view-mount"></div></body></html>', { pretendToBeVisual: true, url: 'http://localhost' });
test('real Settings logging and Ask dialogs open, nest and dismiss correctly', async () => {
const dom = new JSDOM('<!doctype html><html><body><div id="root"></div></body></html>', { pretendToBeVisual: true, url: 'http://localhost' });
const previous = new Map<string, PropertyDescriptor | undefined>();
const install = (key: string, value: unknown) => {
previous.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
@@ -22,7 +22,6 @@ test('real Settings landing leaves views visible while idle; log and Ask dialogs
const { I18nProvider } = await import('../../../application/i18n/I18nProvider');
const { PluginSettingsLanding } = await import('./PluginSettingsLanding');
const { PluginPermissionHost } = await import('../../plugins/PluginPermissionHost');
const { observePluginHostSecurityVisibility } = await import('../../../application/state/pluginHostSecurityVisibility');
const { PluginPermissionDecisionHost } = require('../../../electron/plugins/permissionDecisionHost.cjs');
type Prompt = NetcattyPluginPermissionPrompt | { requestId: string; closed: true };
const listeners = new Set<(event: Prompt) => void>();
@@ -39,9 +38,6 @@ test('real Settings landing leaves views visible while idle; log and Ask dialogs
} });
const manager = { plugins: [], available: true, statusKnown: true, busy: false, error: null, credentialStorageAvailable: true,
async refresh() {}, async setEnabled() {}, async importPackage() {}, async uninstall() {} };
let visible = true;
const transitions: boolean[] = [];
const stopVisibility = observePluginHostSecurityVisibility(document.getElementById('native-view-mount')!, (next) => { visible = next; transitions.push(next); });
const root = createRoot(document.getElementById('root')!);
const button = (label: string) => [...document.querySelectorAll<HTMLButtonElement>('button')].find((element) => element.textContent === label)!;
const flushVisibility = () => new Promise<void>((resolve) => dom.window.queueMicrotask(resolve));
@@ -50,24 +46,19 @@ test('real Settings landing leaves views visible while idle; log and Ask dialogs
await flushVisibility();
assert.equal(document.querySelector('[role="dialog"]'), null);
assert.ok(button('Configure'), 'real idle logging row is present');
assert.equal(visible, true, 'idle Settings landing must not hide the native child');
assert.deepEqual(transitions, []);
await act(async () => button('Configure').click());
await flushVisibility();
assert.ok(document.querySelector('[role="dialog"][data-host-security-surface]'));
assert.equal(visible, false, 'actual logging security dialog must hide the child');
await act(async () => document.querySelectorAll<HTMLButtonElement>('[role="switch"]')[0].click());
await act(async () => document.querySelectorAll<HTMLButtonElement>('[role="switch"]')[1].click());
await flushVisibility();
assert.equal(document.querySelectorAll('[role="dialog"]').length, 2);
assert.equal(visible, false, 'the verbose security confirmation must also keep the child hidden');
await act(async () => button('Cancel').click());
await flushVisibility();
assert.equal(visible, false, 'cancelling verbose returns to the protected logging dialog');
assert.equal(document.querySelectorAll('[role="dialog"]').length, 1);
await act(async () => button('Close').click());
await flushVisibility();
assert.equal(document.querySelector('[role="dialog"]'), null);
assert.equal(visible, true, 'closing logs must restore the child while its idle row remains');
let pending!: Promise<{ decision: string }>;
await act(async () => {
pending = host.request({ requestId: 'settings-visibility-ask', pluginId: 'com.example.settings-visibility', pluginName: 'Settings visibility fixture', pluginVersion: '1.0.0',
@@ -75,14 +66,12 @@ test('real Settings landing leaves views visible while idle; log and Ask dialogs
});
await flushVisibility();
assert.ok(document.querySelector('[role="dialog"][data-host-security-surface]'));
assert.equal(visible, false, 'Ask must still hide the child above the real landing');
await act(async () => { button('Deny').click(); await pending; });
await flushVisibility();
assert.equal((await pending).decision, 'deny');
assert.equal(visible, true, 'dismissing Ask must restore the same child');
assert.deepEqual(transitions, [false, true, false, true]);
assert.equal(document.querySelector('[role="dialog"]'), null);
} finally {
await act(async () => root.unmount()); stopVisibility(); host.shutdown(); dom.window.close();
await act(async () => root.unmount()); host.shutdown(); dom.window.close();
for (const [key, descriptor] of previous) { if (descriptor) Object.defineProperty(globalThis, key, descriptor); else Reflect.deleteProperty(globalThis, key); }
}
});
@@ -0,0 +1,86 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { JSDOM } from 'jsdom';
test('uninstall requires a visible confirmation, reports failures, and removes the installed card after success', async () => {
const dom = new JSDOM('<!doctype html><html><body><div id="root"></div></body></html>', { pretendToBeVisual: true, url: 'http://localhost' });
const previous = new Map<string, PropertyDescriptor | undefined>();
const install = (key: string, value: unknown) => {
previous.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
Object.defineProperty(globalThis, key, { configurable: true, writable: true, value });
};
for (const key of ['window', 'document', 'navigator', 'HTMLElement', 'HTMLInputElement', 'Element', 'Node', 'NodeFilter', 'Event', 'MouseEvent', 'KeyboardEvent', 'MutationObserver', 'DocumentFragment', 'CustomEvent']) {
install(key, key === 'window' ? dom.window : dom.window[key as keyof typeof dom.window]);
}
install('getComputedStyle', dom.window.getComputedStyle.bind(dom.window));
install('requestAnimationFrame', dom.window.requestAnimationFrame.bind(dom.window));
install('cancelAnimationFrame', dom.window.cancelAnimationFrame.bind(dom.window));
install('IS_REACT_ACT_ENVIRONMENT', true);
const { default: React, act } = await import('react');
const { createRoot } = await import('react-dom/client');
const { I18nProvider } = await import('../../../application/i18n/I18nProvider');
const { usePluginManager } = await import('../../../application/state/usePluginManager');
const { PluginManagementDialog } = await import('./PluginManagementDialog');
const { PluginPackageManager } = await import('./PluginPackageManager');
const plugin = {
id: 'com.example.disposable', enabled: false, activeVersion: '1.0.0',
manifest: { manifestVersion: 1, id: 'com.example.disposable', name: 'Disposable', version: '1.0.0', publisher: 'Example', engines: { netcatty: '*', api: '*' }, main: { browser: 'index.js' } },
runtime: { status: 'stopped', kind: null, lastError: null, quarantinedAt: null },
} as NetcattyInstalledPlugin;
let installed = [plugin];
const removed: string[] = [];
let finish!: () => void;
let fail!: (error: Error) => void;
Object.assign(dom.window, { netcatty: {
async getPluginRuntimeStatus() { return { available: true }; },
async listPlugins() { return installed; },
async getPluginPermissions() { return { policies: [], grants: [], audit: [] }; },
async uninstallPlugin(id: string) {
removed.push(id);
await new Promise<void>((resolve, reject) => { finish = resolve; fail = reject; });
installed = installed.filter((entry) => entry.id !== id);
},
} });
function Fixture() {
const manager = usePluginManager();
return <PluginManagementDialog open trigger={null}><PluginPackageManager manager={manager} initialSelectedId={plugin.id} /></PluginManagementDialog>;
}
const root = createRoot(document.getElementById('root')!);
const settle = () => new Promise((resolve) => setTimeout(resolve, 30));
const confirmation = () => document.querySelector<HTMLElement>('[data-plugin-uninstall-confirm]');
const button = (label: string, within: ParentNode = document) => [...within.querySelectorAll<HTMLButtonElement>('button')].find((item) => item.textContent === label)!;
try {
await act(async () => { root.render(<I18nProvider locale="en"><Fixture /></I18nProvider>); await settle(); });
const trigger = button('Uninstall');
await act(async () => { trigger.click(); await settle(); });
assert.ok(confirmation(), 'confirmation is a separate dialog, not below the scroll fold');
assert.equal(document.querySelectorAll('[role="dialog"]').length, 2);
assert.deepEqual(removed, [], 'opening the confirmation never deletes the plugin');
const checkbox = confirmation()!.querySelector<HTMLInputElement>('input[type="checkbox"]')!;
assert.equal(checkbox.checked, false);
await act(async () => { checkbox.click(); button('Cancel', confirmation()!).click(); await settle(); });
assert.equal(confirmation(), null);
assert.deepEqual(removed, []);
assert.equal(document.activeElement, trigger);
await act(async () => { trigger.click(); await settle(); });
assert.equal(confirmation()!.querySelector<HTMLInputElement>('input')!.checked, false, 'private data clearing is reset for a new confirmation');
await act(async () => { button('Uninstall', confirmation()!).click(); await settle(); });
assert.deepEqual(removed, [plugin.id]);
assert.equal(button('Uninstall', confirmation()!).disabled, true);
await act(async () => { fail(new Error('Synthetic removal failed')); await settle(); });
assert.match(confirmation()!.querySelector('[role="alert"]')!.textContent!, /Synthetic removal failed/);
assert.equal(installed.length, 1);
await act(async () => { button('Uninstall', confirmation()!).click(); await settle(); });
await act(async () => { finish(); await settle(); });
assert.equal(confirmation(), null);
assert.equal(installed.length, 0);
assert.equal(document.querySelector('article'), null, 'the removed plugin no longer has an installed card or detail');
assert.equal(document.querySelectorAll('[role="dialog"]').length, 1, 'the plugin manager remains open');
} finally {
await act(async () => { root.unmount(); await settle(); });
dom.window.close();
for (const [key, descriptor] of previous) {
if (descriptor) Object.defineProperty(globalThis, key, descriptor); else Reflect.deleteProperty(globalThis, key);
}
}
});
@@ -48,18 +48,21 @@ test('landing settings entry targets the declared plugin and a manager tab round
assert.ok(settings);
assert.equal(settings.disabled, false);
await act(async () => { settings.focus(); settings.click(); await settle(); });
assert.ok(document.querySelector('[data-plugin-detail]'));
assert.ok(document.querySelector('[data-plugin-settings-dialog]'));
assert.ok(document.querySelector('input[aria-label="Alpha setting"]'));
await act(async () => { document.activeElement!.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); await settle(); });
const browse = [...document.querySelectorAll<HTMLButtonElement>('button')].find(button => button.textContent === 'Browse')!;
await act(async () => { browse.focus(); browse.click(); await settle(); });
await selectTab('Official');
await selectTab('Installed');
assert.equal(document.querySelector('[data-plugin-detail]'), null, 'returning to Installed shows the grid, not a stale deep-link detail');
assert.equal(document.querySelector('input[aria-label="Alpha setting"]'), null);
const card = document.querySelector<HTMLButtonElement>('[data-plugin-card="com.example.alpha"]')!;
await act(async () => { card.click(); await settle(); });
assert.ok(document.querySelector('input[aria-label="Alpha setting"]'), 'choosing the plugin again restores its real settings');
assert.ok(document.querySelector('[data-plugin-detail] button[aria-label="Settings for Alpha"]'), 'details offer the same settings entry');
await act(async () => { document.activeElement!.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); await settle(); });
assert.equal(document.querySelector('[role=dialog]'), null);
assert.equal(document.activeElement, settings);
assert.equal(document.activeElement, browse);
const toggle = document.querySelector<HTMLButtonElement>('button[aria-label="Disable Alpha"]')!;
await act(async () => { toggle.click(); await settle(); });
assert.match(document.querySelector('[role=alert]')!.textContent!, /Synthetic management failure/);
@@ -4,7 +4,7 @@ import React from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { I18nProvider } from '../../../application/i18n/I18nProvider';
import { PluginSettingField } from './SettingsPluginsTab';
import { PluginSettingField } from '../../plugins/PluginSettingsForm';
test('plugin setting cards retain localized descriptions alongside structured controls', () => {
const setting = {
+14 -387
View File
@@ -7,342 +7,25 @@ import { PluginPackageManager } from './PluginPackageManager';
import { FolderOpen } from 'lucide-react';
import { PluginStatus } from './PluginStatus';
import { PluginSettingsLanding } from './PluginSettingsLanding';
import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { useMemo, useRef, useState } from 'react';
import { normalizePluginKeyboardEvent } from '../../../application/state/pluginKeybindings';
import { usePluginContributions } from '../../../application/state/usePluginContributions';
import { useI18n } from '../../../application/i18n/I18nProvider';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '../../ui/tabs';
import { Button } from '../../ui/button';
import { Input } from '../../ui/input';
import { SettingsAnchor, SettingsTabContent } from '../settings-ui';
import { requestOpenPluginView } from '../../plugins/PluginContributionHost';
import { parsePluginStructuredSettingValue } from './pluginSettingValues';
import { PluginStructuredSettingEditor } from './PluginStructuredSettingEditor';
import { useAvailableFonts } from '../../../application/state/fontStore';
import { TerminalFontSelect } from '../TerminalFontSelect';
import {
resolvePluginSettingScopeSelection,
usePluginSettingScopeCatalog,
} from '../../../application/state/usePluginSettingScopeCatalog';
import { PluginContributionIcon } from '../../plugins/PluginContributionIcon';
import { PluginSettingsEntry, PluginSettingsDialog } from '../../plugins/PluginSettingsEntry';
export function PluginSettingField({
pluginId,
setting,
updateSetting,
resetSetting,
selectSettingPath,
availableFonts,
}: {
pluginId: string;
setting: NetcattyPluginSettingContribution;
updateSetting: ReturnType<typeof usePluginContributions>['updateSetting'];
resetSetting: ReturnType<typeof usePluginContributions>['resetSetting'];
selectSettingPath: ReturnType<typeof usePluginContributions>['selectSettingPath'];
availableFonts: ReturnType<typeof useAvailableFonts>;
}) {
const { t } = useI18n();
const [value, setValue] = useState<unknown>(setting.secret ? '' : setting.value ?? '');
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const itemLabel = (key: 'settings.plugins.moveItemUp' | 'settings.plugins.moveItemDown', index: number) => (
t(key).replace('{label}', setting.label).replace('{index}', String(index + 1))
);
useEffect(() => {
setValue(setting.secret ? '' : setting.value ?? '');
}, [setting.secret, setting.value]);
const save = async (nextValue: unknown) => {
setSaving(true);
setError(null);
try {
await updateSetting(pluginId, setting.id, nextValue, setting.scopeId ?? undefined);
if (setting.secret) setValue('');
} catch (cause) {
setError(cause instanceof Error ? cause.message : String(cause));
} finally {
setSaving(false);
}
};
const control = (() => {
if (setting.scopeId == null) {
return <div className="text-xs text-muted-foreground">{t('settings.plugins.scopeContext').replace('{scope}', setting.scope)}</div>;
}
if (setting.control === 'switch') {
return (
<input
aria-label={setting.label}
type="checkbox"
checked={Boolean(value)}
disabled={saving}
onChange={(event) => { setValue(event.target.checked); void save(event.target.checked); }}
className="h-4 w-4 accent-primary"
/>
);
}
if (setting.control === 'radio') {
return (
<fieldset className="space-y-2" disabled={saving}>
<legend className="sr-only">{setting.label}</legend>
{setting.options?.map((option) => (
<label key={option.value} className="flex items-start gap-2 text-sm">
<input
type="radio"
name={setting.id}
value={option.value}
checked={value === option.value}
onChange={() => { setValue(option.value); void save(option.value); }}
className="mt-0.5 accent-primary"
/>
<span>{option.label}{option.description && <span className="block text-xs text-muted-foreground">{option.description}</span>}</span>
</label>
))}
</fieldset>
);
}
if (setting.control === 'select') {
return (
<select
aria-label={setting.label}
value={String(value)}
disabled={saving}
onChange={(event) => { setValue(event.target.value); void save(event.target.value); }}
className="h-9 min-w-52 rounded-md border border-input bg-background px-3 text-sm"
>
{setting.options?.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
</select>
);
}
if (setting.control === 'multiselect') {
const selected = Array.isArray(value) ? value.map(String) : [];
return (
<select
aria-label={setting.label}
multiple
value={selected}
disabled={saving}
onChange={(event) => {
const next = [...event.currentTarget.selectedOptions].map((option) => option.value);
setValue(next);
void save(next);
}}
className="min-h-24 min-w-52 rounded-md border border-input bg-background px-3 py-2 text-sm"
>
{setting.options?.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
</select>
);
}
if (setting.control === 'textarea') {
return (
<textarea
aria-label={setting.label}
value={String(value)}
placeholder={setting.placeholder}
disabled={saving}
onChange={(event) => setValue(event.target.value)}
onBlur={(event) => void save(event.currentTarget.value)}
className="min-h-24 w-full max-w-xl rounded-md border border-input bg-background px-3 py-2 text-sm"
/>
);
}
if (setting.control === 'number' || setting.control === 'slider') {
return (
<Input
aria-label={setting.label}
type={setting.control === 'slider' ? 'range' : 'number'}
value={typeof value === 'number' ? value : Number(value) || 0}
min={setting.minimum}
max={setting.maximum}
step={setting.step}
disabled={saving}
onChange={(event) => setValue(Number(event.target.value))}
onBlur={(event) => void save(Number(event.currentTarget.value))}
className="max-w-sm"
/>
);
}
if (setting.control === 'list' || setting.control === 'table') {
return (
<PluginStructuredSettingEditor
setting={setting}
value={typeof value === 'string' ? parsePluginStructuredSettingValue(value) : value}
disabled={saving}
onChange={setValue}
onCommit={(next) => void save(next)}
labels={{
add: t('settings.plugins.addItem'),
remove: t('settings.plugins.removeItem'),
moveUp: (index) => itemLabel('settings.plugins.moveItemUp', index),
moveDown: (index) => itemLabel('settings.plugins.moveItemDown', index),
}}
/>
);
}
if (setting.control === 'font') {
return (
<TerminalFontSelect
value={String(value)}
fonts={availableFonts}
disabled={saving}
onChange={(next) => { setValue(next); void save(next); }}
className="w-full max-w-xl"
ariaLabel={setting.label}
/>
);
}
if (setting.control === 'file' || setting.control === 'directory') {
return (
<div className="flex max-w-xl gap-2">
<Input aria-label={setting.label} value={String(value)} readOnly disabled={saving} className="min-w-0 flex-1" />
<Button
type="button"
variant="outline"
disabled={saving}
onClick={() => void selectSettingPath(setting.control as 'file' | 'directory', setting.label, String(value || '')).then((selected) => {
if (!selected) return;
setValue(selected);
void save(selected);
}).catch((cause) => setError(cause instanceof Error ? cause.message : String(cause)))}
>
<FolderOpen size={14} className="mr-2" /> {t('settings.plugins.browse')}
</Button>
</div>
);
}
if (setting.control === 'keybinding') {
return (
<Input
aria-label={setting.label}
value={String(value)}
placeholder={setting.placeholder ?? t('settings.plugins.pressKeybinding')}
readOnly
disabled={saving}
onKeyDown={(event) => {
event.preventDefault();
const key = normalizePluginKeyboardEvent(event.nativeEvent);
if (!key) return;
setValue(key);
void save(key);
}}
className="max-w-xl"
/>
);
}
return (
<Input
aria-label={setting.label}
type={setting.secret || setting.control === 'password' ? 'password' : setting.control === 'color' ? 'color' : 'text'}
value={String(value)}
placeholder={setting.secret && setting.configured ? t('settings.plugins.configuredReplacement') : setting.placeholder}
disabled={saving}
onChange={(event) => setValue(event.target.value)}
onBlur={(event) => {
const next = event.currentTarget.value;
if (!setting.secret || next.length > 0) void save(next);
}}
className="max-w-xl"
/>
);
})();
return (
<div className="rounded-lg border border-border/70 bg-background p-4 space-y-3">
<div className="flex items-start justify-between gap-4">
<div>
<label className="text-sm font-medium">{setting.label}</label>
{setting.description && (
<p className="mt-1 max-w-2xl text-xs leading-relaxed text-muted-foreground">{setting.description}</p>
)}
<p className="mt-1 font-mono text-[10px] text-muted-foreground">{setting.id} · {setting.scope}</p>
</div>
{setting.restartRequired && <span className="rounded bg-amber-500/15 px-2 py-1 text-[10px] text-amber-700 dark:text-amber-300">{t('settings.plugins.restartRequired')}</span>}
</div>
{control}
<div className="flex items-center gap-2">
{!setting.required && setting.scopeId != null && (
<Button
type="button"
variant="outline"
size="sm"
disabled={saving}
onClick={() => {
setSaving(true);
setError(null);
void resetSetting(pluginId, setting.id, setting.scopeId ?? undefined)
.catch((cause) => setError(cause instanceof Error ? cause.message : String(cause)))
.finally(() => setSaving(false));
}}
>
{t('common.reset')}
</Button>
)}
{saving && <span className="text-xs text-muted-foreground">{t('settings.plugins.saving')}</span>}
{setting.secret && setting.configured && <span className="text-xs text-emerald-600">{t('settings.plugins.storedSecurely')}</span>}
{error && <span role="alert" className="text-xs text-destructive">{error}</span>}
</div>
</div>
);
}
function PluginManagementContent({ handoff, manager, initialSelectedId, active }: {
handoff: (action: () => void) => void;
function PluginManagementContent({ manager }: {
manager: ReturnType<typeof usePluginManager>;
initialSelectedId: string | null;
active: boolean;
}) {
const { t } = useI18n();
const [catalogToolbar, setCatalogToolbar] = useState<HTMLDivElement | null>(null);
const [selectedInstalled, setSelectedInstalled] = useState<string | null>(initialSelectedId);
const [selectedInstalled, setSelectedInstalled] = useState<string | null>(null);
const [pluginView, setPluginView] = useState<'installed' | 'marketplace' | 'thirdparty'>('installed');
const availableFonts = useAvailableFonts();
const scopeCatalog = usePluginSettingScopeCatalog();
const [scopeIds, setScopeIds] = useState<Partial<Record<NetcattyPluginSettingScopeKind, string>>>({});
useEffect(() => {
setScopeIds((current) => resolvePluginSettingScopeSelection(scopeCatalog, current));
}, [scopeCatalog]);
const query = useMemo<NetcattyPluginContributionQuery>(() => ({
context: { 'netcatty.surface': 'settings' },
scopeIds,
}), [scopeIds]);
const contributions = usePluginContributions(query);
const settingsSection = useRef<HTMLElement | null>(null);
const installed = manager.plugins.find((plugin) => plugin.id === selectedInstalled);
const ownerKey = JSON.stringify(installed && [installed.id, installed.activeVersion, installed.archiveSha256,
installed.source?.id, installed.source?.kind, installed.source?.origin]);
const gearEntry = useRef(initialSelectedId ? { pluginId: initialSelectedId, ownerKey } : null);
useLayoutEffect(() => {
const intent = gearEntry.current;
if (!intent) return;
if (!active || pluginView !== 'installed' || selectedInstalled !== intent.pluginId || ownerKey !== intent.ownerKey) {
gearEntry.current = null;
return;
}
const section = settingsSection.current;
if (contributions.loading || contributions.error || !contributions.available || !section) return;
// Let dialog autofocus settle first, including when contributions arrive late.
let frame = requestAnimationFrame(() => {
frame = requestAnimationFrame(() => {
if (gearEntry.current !== intent || !section.isConnected) return;
gearEntry.current = null;
section.scrollIntoView({ block: 'start' });
section.focus({ preventScroll: true });
});
});
return () => cancelAnimationFrame(frame);
}, [active, pluginView, selectedInstalled, ownerKey, contributions.loading, contributions.error, contributions.available, contributions.snapshot]);
const contextualScopes = useMemo(() => new Set(contributions.snapshot.plugins
.filter((plugin) => plugin.id === selectedInstalled)
.flatMap((plugin) => plugin.settings.map((setting) => setting.scope))
.filter((scope): scope is NetcattyPluginSettingScopeKind => scope !== 'application')),
[contributions.snapshot.plugins, selectedInstalled]);
return (
<div className="space-y-5">
<Tabs value={pluginView} onValueChange={(value) => { gearEntry.current = null; setSelectedInstalled(null); setPluginView(value as 'installed' | 'marketplace' | 'thirdparty'); }} className="space-y-5">
<Tabs value={pluginView} onValueChange={(value) => { setSelectedInstalled(null); setPluginView(value as 'installed' | 'marketplace' | 'thirdparty'); }} className="space-y-5">
<div className="flex flex-wrap items-center justify-between gap-3">
<TabsList className="h-auto flex-wrap justify-start bg-muted/50">
<TabsTrigger value="installed">{t('settings.plugins.manager.installed')} <span className="ml-2 text-muted-foreground">{manager.plugins.length}</span></TabsTrigger>
@@ -358,65 +41,7 @@ function PluginManagementContent({ handoff, manager, initialSelectedId, active }
<TabsContent forceMount hidden={pluginView !== 'marketplace'} value="marketplace" className="m-0 space-y-6"><PluginMarketplace installed={manager.plugins} toolbarContainer={pluginView === 'marketplace' ? catalogToolbar : undefined} noVault={manager.credentialStorageAvailable === false} /></TabsContent>
<TabsContent forceMount hidden={pluginView !== 'thirdparty'} value="thirdparty" className="m-0 space-y-6"><PluginThirdPartyMarket installed={manager.plugins} toolbarContainer={pluginView === 'thirdparty' ? catalogToolbar : undefined} noVault={manager.credentialStorageAvailable === false} /></TabsContent>
<TabsContent value="installed" className="m-0 space-y-6">
<PluginPackageManager manager={manager} initialSelectedId={selectedInstalled} onSelectionChange={(id) => { gearEntry.current = null; setSelectedInstalled(id); }} />
{contextualScopes.size > 0 && (
<section className="grid gap-3 rounded-lg border border-border/70 bg-muted/10 p-4 sm:grid-cols-2" aria-label={t('settings.plugins.scopeTargets')}>
{[...contextualScopes].map((kind) => (
<label key={kind} className="space-y-1 text-xs font-medium">
<span>{t('settings.plugins.scopeTarget').replace('{scope}', kind)}</span>
<select
value={scopeIds[kind] ?? ''}
onChange={(event) => setScopeIds((current) => ({ ...current, [kind]: event.target.value || undefined }))}
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm font-normal"
>
{!scopeCatalog[kind].length && <option value="">{t('settings.plugins.noScopeTargets')}</option>}
{scopeCatalog[kind].map((entry) => <option key={entry.id} value={entry.id}>{entry.label}</option>)}
</select>
</label>
))}
</section>
)}
{contributions.loading && <p className="text-sm text-muted-foreground">{t('settings.plugins.loading')}</p>}
{contributions.error && <p role="alert" className="text-sm text-destructive">{contributions.error.message}</p>}
{contributions.snapshot.plugins.filter((plugin) => plugin.id === selectedInstalled).map((plugin) => {
const settings = plugin.settings.filter((setting) => setting.visible);
const views = plugin.views.filter((view) => view.visible && view.location === 'settings');
if (!settings.length && !views.length) return null;
return (
<section key={plugin.id} ref={settingsSection} tabIndex={-1} className="space-y-3 scroll-mt-4 focus:outline-none" aria-labelledby={`plugin-settings-${plugin.id}`}>
<div>
<h3 id={`plugin-settings-${plugin.id}`} className="text-base font-semibold">{plugin.displayName}</h3>
{plugin.description && <p className="text-xs text-muted-foreground">{plugin.description}</p>}
</div>
{settings.map((setting) => (
<PluginSettingField
key={setting.id}
pluginId={plugin.id}
setting={setting}
updateSetting={contributions.updateSetting}
resetSetting={contributions.resetSetting}
selectSettingPath={contributions.selectSettingPath}
availableFonts={availableFonts}
/>
))}
{views.map((view) => (
<div key={view.id} className="flex items-center justify-between rounded-lg border border-border/70 bg-background p-4">
<div className="flex min-w-0 items-center gap-3">
<PluginContributionIcon pluginId={plugin.id} icon={view.icon} size={18} className="shrink-0" />
<div className="min-w-0">
<div className="text-sm font-medium">{view.title}</div>
<div className="font-mono text-[10px] text-muted-foreground">{view.id}</div>
</div>
</div>
<Button type="button" variant="outline" size="sm" onClick={() => handoff(() => requestOpenPluginView({
viewId: view.id,
context: { 'netcatty.surface': 'settings' },
}))}>{t('settings.plugins.openView')}</Button>
</div>
))}
</section>
);
})}
<PluginPackageManager manager={manager} initialSelectedId={selectedInstalled} onSelectionChange={(id) => { setSelectedInstalled(id); }} detailActions={selectedInstalled && <PluginSettingsEntry key={`settings:${selectedInstalled}`} pluginId={selectedInstalled} />} />
</TabsContent>
</Tabs>
</div>
@@ -425,27 +50,29 @@ function PluginManagementContent({ handoff, manager, initialSelectedId, active }
export default function SettingsPluginsTab() {
const { locale } = useI18n();
const manager = usePluginManager();
const updates = usePluginUpdateCheck(manager.plugins);
const [open, setOpen] = useState(false);
const [initialSelectedId, setInitialSelectedId] = useState<string | null>(null);
const [settingsPluginId, setSettingsPluginId] = useState<string | null>(null);
const opener = useRef<HTMLElement | null>(null);
const managerButton = useRef<HTMLButtonElement | null>(null);
const contributions = usePluginContributions({ context: { 'netcatty.surface': 'settings' } });
const contributions = usePluginContributions({ locale, context: { 'netcatty.surface': 'settings' } });
const settingsAvailable = useMemo(() => new Set(contributions.snapshot.plugins
.filter((plugin) => plugin.settings.some((setting) => setting.visible) || plugin.views.some((view) => view.location === 'settings' && view.visible))
.filter((plugin) => plugin.settings.some((setting) => setting.visible))
.map((plugin) => plugin.id)), [contributions.snapshot]);
return (
<SettingsTabContent value="plugins">
<SettingsAnchor anchorId="plugins-root" className="space-y-6">
<PluginSettingsLanding manager={manager} updates={updates} managerButtonRef={managerButton} settingsAvailable={settingsAvailable} settingsError={contributions.error?.message} onManage={(pluginId, element) => {
opener.current = element;
setInitialSelectedId(pluginId);
setOpen(true);
if (pluginId) setSettingsPluginId(pluginId);
else setOpen(true);
}} />
<PluginManagementDialog open={open} onOpenChange={setOpen} trigger={null} restoreFocusTo={opener} fallbackFocusTo={managerButton}>
{(handoff) => <PluginManagementContent handoff={handoff} manager={manager} initialSelectedId={initialSelectedId} active={open} />}
<PluginManagementContent manager={manager} />
</PluginManagementDialog>
{settingsPluginId && <PluginSettingsDialog key={settingsPluginId} pluginId={settingsPluginId} onClose={() => setSettingsPluginId(null)} restoreFocusTo={opener} />}
</SettingsAnchor>
</SettingsTabContent>
);
+1 -1
View File
@@ -1159,7 +1159,7 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
)}
</ToolbarCustomizeContextMenu>
{!compactToolbar && <PluginTerminalSidebarLauncher sessionId={sessionId} />}
<PluginTerminalSidebarLauncher sessionId={sessionId} buttonClassName={buttonBase} />
{pluginToolbarMenus.map((menu) => (
<Tooltip key={menu.id}>
<TooltipTrigger asChild>
@@ -18,7 +18,6 @@ import {
reorderTerminalSidePanelTab,
fitTerminalSidePanelTabs,
TERMINAL_SIDE_PANEL_TAB_DEFAULT_ORDER,
TERMINAL_SIDE_PANEL_TAB_IDS,
type TerminalSidePanelTabId,
useTerminalSidePanelTabOrder,
} from '../../application/state/terminalSidePanelTabs';
@@ -821,7 +820,11 @@ function TerminalLayerSidePanelInner({ ctx, pluginSidebar }: { ctx: SidePanelCon
move: moveSidePanelTab,
resetLayout: resetSidePanelTabLayout,
partition: partitionSidePanelTabs,
} = useTerminalSidePanelTabOrder();
} = useTerminalSidePanelTabOrder(pluginSidebar.pages.map((page) => page.tool));
const availableSidePanelTabs = useMemo(() => [
...TERMINAL_SIDE_PANEL_TAB_DEFAULT_ORDER, ...pluginSidebar.pages.map((page) => page.tool),
], [pluginSidebar.pages]);
const availableSidePanelTabSet = useMemo(() => new Set<SidePanelTab>(availableSidePanelTabs), [availableSidePanelTabs]);
const resolvedSidePanelTerminalTheme = useMemo(() => (
followAppTerminalTheme
? terminalTheme
@@ -1019,7 +1022,7 @@ function TerminalLayerSidePanelInner({ ctx, pluginSidebar }: { ctx: SidePanelCon
const draggedTab = draggedSidePanelTabRef.current ?? transferredTab;
draggedSidePanelTabRef.current = null;
setDragOverSidePanelTab(null);
if (!TERMINAL_SIDE_PANEL_TAB_IDS.has(draggedTab)) return;
if (!availableSidePanelTabSet.has(draggedTab)) return;
const nextOrder = reorderTerminalSidePanelTab(
sidePanelTabOrder,
@@ -1030,7 +1033,7 @@ function TerminalLayerSidePanelInner({ ctx, pluginSidebar }: { ctx: SidePanelCon
if (nextOrder !== sidePanelTabOrder) {
setSidePanelTabOrder(nextOrder);
}
}, [dragOverSidePanelTab, setSidePanelTabOrder, sidePanelTabOrder]);
}, [availableSidePanelTabSet, dragOverSidePanelTab, setSidePanelTabOrder, sidePanelTabOrder]);
const handleSidePanelTabDragOver = useCallback((event: React.DragEvent, targetTab: TerminalSidePanelTabId) => {
if (!Array.from(event.dataTransfer.types).includes(SIDE_PANEL_TAB_DRAG_MIME)) return;
@@ -1163,7 +1166,12 @@ function TerminalLayerSidePanelInner({ ctx, pluginSidebar }: { ctx: SidePanelCon
}, [focusedPaneHost, handleSplitSidePanelPane]);
const { shown: configuredShownSidePanelTabs, collapsed: configuredCollapsedSidePanelTabs } = useMemo(() => {
const parts = partitionSidePanelTabs(TERMINAL_SIDE_PANEL_TAB_DEFAULT_ORDER);
const partition = partitionSidePanelTabs(availableSidePanelTabs);
const parts = {
shown: partition.shown as SidePanelTab[],
collapsed: partition.collapsed as SidePanelTab[],
hidden: partition.hidden as SidePanelTab[],
};
// If an external path opens a hidden tab, still show its chip while active.
if (
activeSidePanelTab &&
@@ -1177,7 +1185,7 @@ function TerminalLayerSidePanelInner({ ctx, pluginSidebar }: { ctx: SidePanelCon
};
}
return parts;
}, [activeSidePanelTab, partitionSidePanelTabs]);
}, [activeSidePanelTab, availableSidePanelTabs, partitionSidePanelTabs]);
// Bottom dock spans the full layer width (below the workspace focus
// sidebar row), so measure tab overflow against the terminal layer itself.
const sidePanelTabFitWidth = isBottomDock ? terminalLayerWidth : shellWidth;
@@ -1198,7 +1206,7 @@ function TerminalLayerSidePanelInner({ ctx, pluginSidebar }: { ctx: SidePanelCon
const sidePanelCustomizeItems = useMemo(
() =>
sidePanelTabOrder.map((tabId) => {
sidePanelTabOrder.filter((tabId) => availableSidePanelTabSet.has(tabId)).map((tabId) => {
const item = sidePanelTabItemById.get(tabId);
return {
id: tabId,
@@ -1206,7 +1214,7 @@ function TerminalLayerSidePanelInner({ ctx, pluginSidebar }: { ctx: SidePanelCon
icon: item?.icon,
};
}),
[sidePanelTabItemById, sidePanelTabOrder],
[availableSidePanelTabSet, sidePanelTabItemById, sidePanelTabOrder],
);
return (
@@ -1288,7 +1296,7 @@ function TerminalLayerSidePanelInner({ ctx, pluginSidebar }: { ctx: SidePanelCon
const next = setSidePanelTabPlacement(
id,
placement,
TERMINAL_SIDE_PANEL_TAB_DEFAULT_ORDER,
availableSidePanelTabs,
);
// Only close when hide actually stuck (not reverted by requireReachable).
if (activeSidePanelTab === id && (next.placement[id] ?? 'show') === 'hide') {
@@ -1296,7 +1304,7 @@ function TerminalLayerSidePanelInner({ ctx, pluginSidebar }: { ctx: SidePanelCon
}
}}
onMove={(id, direction) =>
moveSidePanelTab(id, direction, TERMINAL_SIDE_PANEL_TAB_DEFAULT_ORDER)
moveSidePanelTab(id, direction, availableSidePanelTabs)
}
onReset={resetSidePanelTabLayout}
t={t}
@@ -1517,8 +1525,8 @@ function TerminalLayerSidePanelInner({ ctx, pluginSidebar }: { ctx: SidePanelCon
const sessionId = pluginSidebar.sessionId!;
return <PersistentSidePanelPortal key={`${activeTabId}:${pane.id}:${sessionId}`} portalKey={`plugin-${pane.id}`} target={target}>
<PluginTerminalSidebarPageHost page={page} tabId={activeTabId} sessionId={sessionId}
focusOnReveal={activeSidePanelLayout?.focusedPaneId === pane.id}
adapter={pluginSidebar.contributions} getSession={ctx.getActiveTerminalSessionId}
showHeader={activePaneCount === 1}
getSession={ctx.getActiveTerminalSessionId}
unavailable={() => ctx.handleWithdrawPluginSidebarPane?.(activeTabId, pane.id, page.tool)}
focused={() => { if (activeTabStore.getActiveTabId() === activeTabId) ctx.handleFocusSidePanelPane?.(pane.id); }} />
</PersistentSidePanelPortal>;
@@ -0,0 +1,64 @@
# Embedded plugin UI feasibility
> Research only. The user subsequently clarified that plugin UI must be drawn
> by Netcatty, not displayed in an embedded frame. See
> [host-rendered React UI research](./native-react-ui-research.md).
## Goal
Allow ordinary React, HTML and CSS inside the application's normal stacking
order while keeping the existing plugin permissions and offline-by-default
execution boundary. The plugin must not gain access to the host document,
privileged preload, other plugins, files, the network, or navigation simply
because it can render a view.
## Evidence from the current runtime
`electron/plugins/embeddedFrameSecurity.live.test.cjs` is an isolated Electron
proof, not a product implementation. Run it with:
```
./node_modules/.bin/electron electron/plugins/embeddedFrameSecurity.live.test.cjs
```
On Electron 42.11.12 (Chromium 148.0.7778.280), a frame with `sandbox=allow-scripts`,
a restrictive CSP and no Node integration could not read its parent or local
storage. A host overlay appeared above it. Fetch and image requests were blocked,
and `will-frame-navigate` stopped an attempted navigation. The same frame could
still send UDP packets to a local STUN endpoint with `RTCPeerConnection`: in
one run three packets arrived. This proves that the prototype's CSP and frame
sandbox do not reproduce the existing offline-session boundary.
`Connection-Allowlist: (); webrtc=block` did not prevent those packets on this
Chromium build, even when the experimental `ConnectionAllowlists` switch was
supplied. Chrome's official announcement says the header is introduced in Chrome
152; it must not be assumed to protect the current Electron version. A future
upgrade still requires adversarial verification for every supported platform.
- https://developer.chrome.com/blog/connection-allowlist-announcement
- https://www.electronjs.org/docs/latest/tutorial/web-embeds
- https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/iframe
## Required work before adoption
1. Enforce and independently test per-plugin network isolation, including
WebRTC, WebTransport, navigation, redirects, downloads, workers, DNS and
browser APIs. A JavaScript shim or CSP alone is not a security boundary.
2. Register packaged assets in the actual owner session without exposing host
assets or another plugin's package. Test opaque-origin module loading, CSS,
local images and fonts, and reject navigation away from the registered view.
3. Ensure no child frame receives privileged preloads or can call trusted IPC;
validate both the actual sender frame and ownership on every operation.
4. Use a dedicated bounded message channel tied to a live instance and owner;
reject cross-window, stale, replayed and unauthorized requests, and close on
lock, disable, unload, crash, navigation and window shutdown.
5. Preserve view state, themes, accessibility, focus, shortcuts and layout
across Vault, terminal sidebar, tab, modal, panel, aside and settings.
6. Add a real React example and verify host menus and dialogs overlap it without
hiding the page. Run isolation tests and interaction tests on all supported OSes.
Do not enable this as a general plugin surface until these conditions pass.
The current `WebContentsView` path remains active meanwhile. Alternative designs
include a host-rendered protocol with isolated React logic; that can retain the
existing offline runtime, but needs a substantial compatibility layer for
ordinary React DOM libraries and truly free-form CSS.
+163
View File
@@ -0,0 +1,163 @@
# Host-rendered plugin UI
This is the only custom-view rendering model. The platform is unpublished:
there is no HTML-entry fallback, native child view, migration, or parallel UI
contract. Browser plugins can use it without `NETCATTY_PLUGIN_DEV`. Advanced
Node runtimes retain their separate development admission policy.
## Authoring
Declare `id`, `title`, and `location` in `contributes.views`, plus the `views`
permission and an `onView:<id>` activation event. The six custom-page locations are
`vault-page`, `terminal-sidebar`, `tab`, `aside`, `panel`, and `modal`.
Settings use host-owned forms described in [plugin-settings.md](plugin-settings.md);
`settings` is not a custom view location.
The view declaration has no `entry`, `rendering`, or retention flag.
Use ordinary React and a stylesheet:
```tsx
import { registerHostReactView, PluginPopover } from '@netcatty/plugin-sdk/host-react';
export default {
activate(context) {
context.subscriptions.add(registerHostReactView(
context.views, 'com.example.plugin.page', () => <MyPage />, css,
));
},
};
```
Bundle the `host-react` subpath, React, and React DOM into the browser entry.
The root SDK may be bundled or resolved by the runtime's SDK mapping. For plain
JavaScript, `registerHostDomView(views, id, (root, stateScope) => cleanup, css)`
accepts ordinary DOM authoring. The root exists in the isolated runtime;
Netcatty receives validated data and creates the visible elements itself.
Never hand a function, live DOM node, or Electron object to the host.
There is no required component catalogue. Use Grid/Flex, container queries,
custom colors, typography, borders, gradients, transforms and animation.
Seven optional semantic HSL variables follow the host theme: `--background`,
`--foreground`, `--muted`, `--muted-foreground`, `--border`, `--primary`, and
`--primary-foreground`. Author-defined variables use the `--plugin-` prefix.
Controls support text, textarea, checkbox/radio, select/multiselect, buttons,
forms, focus, keys and composition events. Host values stay local while an
interaction awaits its ordered acknowledgement, preventing older updates from
overwriting newer typing. Only events inside the owning surface are forwarded.
Forms cannot navigate or send requests. Clipboard reads are not exposed by this
UI interface; a normal paste into the plugin's own input is user-provided data.
`<img src="asset:images/mark.svg" alt="Mark" />` loads an image only from that
installed plugin's verified package. The host checks path containment, size and
format, rejects SVG resource references, and converts the image to PNG in an
isolated decoder. Raw SVG never enters the host document. Source limit: 512 KiB;
output edge: 2048 pixels; output size: 4 MiB. Per instance: 32 resource paths and
8 MiB of returned data. Network URLs, arbitrary data URLs and CSS URL loads are
rejected. Use installed system fonts; packaged font files and canvas are not
part of the current supported surface.
For a menu that extends beyond a pane, use `PluginPopover` with an anchor element
ID, `open`, and `onClose`. Its children are freely authored UI. Netcatty positions
and clips it, handles outside click and Escape, restores anchor focus, and keeps
it below trusted host dialogs. Only the originating view's anchor can be used.
Nested popovers are not supported. Ordinary menus contained inside the surface
can be styled without this helper. Libraries that depend on real host DOM
measurements, arbitrary portals, browser navigation, canvas or active elements
need adaptation; ordinary React support does not mean every browser library works.
## Lifecycle and navigation
Enabled contributions remain registered independently of visible panes.
Terminal sidebar pages participate in built-in ordering, overflow, customization,
focus and splits. Closing the whole sidebar does not delete their entries.
Vault and tabs use the same rounded content surface as built-in pages.
Each window retains at most 16 UI instances. Hiding or switching a page reuses
its instance and React state for up to five idle minutes; unused instances may
be evicted sooner to admit a new view. Explicit tab/overlay close disposes its
instance. Persistent state uses `context.views.getState/setState(viewId,
stateScope, ...)`, with the opaque `stateScope` supplied to a DOM mount. This
stores only the plugin's own data, not host records or credentials. State scopes
separate placements and terminal sessions but are not an authorization boundary
against other code in the same plugin.
The main process binds each instance to its actual owner window and exact
plugin/runtime version. Close during activation, owner navigation/crash, plugin
disable/replacement/uninstall, runtime failure and App Lock invalidate it.
Late updates, image results and events cannot revive a closed instance.
Another window cannot send events, load resources or close it. The renderer's
cache is not the authority for these checks.
## Security boundaries
Plugin JavaScript runs only in the existing offline sandbox runtime, without
Node or the host application bridge. Its private session permits its own package
and SDK modules; browser networking, WebRTC direct UDP, navigation, downloads,
frames and device permissions are blocked. Privileged operations still pass
through the existing permission services. The `views` permission grants no
host, terminal, file, network, secret or model access.
Incoming updates pass schema, sequencing, rate, mutation and CSS checks in the
main process, then a complete data-tree validation before rendering. Limits:
1024 nodes, depth 24, 128 KiB text/attributes, 2048 mutations and 256 KiB per
batch, 64 KiB CSS, 120 updates per second. Invalid data closes the view.
Scripts, handlers, executable/custom elements, unsafe attributes, URL loads,
imports, host selectors and escaping positioning are denied. Closed Shadow DOM
separates styling; clipping and stacking contain output. Neither is relied on
as a JavaScript isolation boundary.
No system can promise that every arbitrary plugin is harmless. User-supplied
text in a plugin belongs to that plugin, and explicitly authorized capabilities
can provide additional data. Isolation and quotas reduce the available attack
surface; they do not certify third-party code or all future browser versions.
## Examples and checks
`hello-react-host` exercises all six custom-page locations, text and Chinese input,
checkbox/select/textarea updates, package images and a custom menu. Build with
`npm run build:plugin-view-examples`. Plain DOM examples, including Hello World 2,
starter, scratchpads and assistants, use the same host renderer.
- `npm run test:plugin-contract`: package and public contract.
- `npm run test:plugin-host-drawn`: tree/style policy, ownership, state retention,
resource checks and real Vault/sidebar navigation components.
- `npm run test:plugin-runtime`: runtime, permissions, package and host services.
- `NETCATTY_PLUGIN_DEV=1 npm run test:plugin-runtime:electron`: browser and advanced
runtime smoke tests, including local HTTP/UDP isolation probes.
- Start Vite on port 5182, then run `npm run test:plugin-host-drawn:electron` and
`electron electron/plugins/helloWorldV2.live.test.cjs`: installed examples and
actual host DOM interactions. These use dedicated disposable profiles.
- `npm run pack:dir`: verify runtime dependencies and production resources.
The custom-page fixture exercises the shared rendering implementation;
Vault/sidebar component tests exercise their actual routing and split logic.
This is not a claim that every manual navigation flow, third-party component
library or malicious plugin has been exhaustively verified.
## Verification record (2026-10-29)
- Installed and enabled the locally built `hello-react-host.ncpkg` through the
actual development application's Settings UI. Vault showed the shared rounded
surface; reset, greeting, image and theme rendering worked.
- In a local terminal, opened the contributed sidebar, filled its controls,
closed the whole sidebar, then reopened it through the built-in shortcut.
The tool entry and previously entered values remained. The custom menu action
updated the greeting and closed the menu.
- The real Electron rendering fixture passed the then-seven view declarations (settings is now host-owned),
controls and Chinese composition, package images, Escape dismissal, retained
state after unmount/remount, trusted-dialog dismissal and a host-only input
canary that was never forwarded. The DOM-example fixture passed 12 views.
- Runtime suite: 752 passed, 6 environment-specific tests skipped; focused
host-rendered UI suite: 23 passed. Public-contract suite: 91 passed, generated
contract check passed. Additional assistant command/adapter checks and the
global-agent layout fixture passed. The real sandbox suite passed its HTTP
and UDP probes.
- `npm run lint`: zero errors and eight existing warnings. `npm run pack:dir`
passed; the produced archive contains the new host, validators and CSS parser,
and excludes the removed native plugin view host.
- Whole-project `tsc --noEmit` still fails on existing unrelated errors. In the
changed production files, the remaining reported error is the unchanged SFTP
context assignment in `TerminalLayerSidePanelSection.tsx`; the newly added
host-rendering modules have no reported errors. This does not turn the whole
project check into a passing check.
@@ -0,0 +1,199 @@
# Host-rendered React plugin UI: research and direction
## Goal
Render plugin UI inside Netcatty's own interface, with React authoring and
flexible layout and styling. Plugin code must not execute in the host renderer
or obtain host state or user data merely by rendering a view. Only data entered
in the plugin's own UI, explicitly granted capabilities, and host-provided
non-sensitive environment information may reach a plugin.
## Design status and continuity
This is an unpublished feature. There are no released plugin UI contracts to
preserve. Design one host-drawn UI model: no legacy view fallback, migration
scheme, or dual rendering mode is required. The existing prototype, examples,
and test evidence are retained as the starting point. The replacement is now implemented as a single
host-drawn path. The attached native view, its preload, geometry/focus bridge,
and renderer-selection fields have been removed. Current authoring and limits
are documented in [host-rendered UI](./host-rendered-ui.md).
Existing evidence from the preceding implementation work:
- React state, a controlled text input, custom styles and clicks passed through
the isolated plugin runtime into a host-owned UI. A test overlay remained above
the plugin, with no iframe or native child view in the visible host window.
- Seven declared views were exercised through the shared rendering fixture.
This is not full product verification of seven navigation/lifecycle flows.
- Focused policy tests covered unsafe content, URLs, CSS escapes, bounded data,
ownership and update ordering. They do not constitute a complete security audit.
- Plugin runtime tests passed (764 passed, 6 skipped), the Electron smoke suite
passed with its development mode enabled, and the directory package build passed.
- Whole-project TypeScript checking did not pass; the scoped recheck reported no
errors in the newly introduced rendering files. Do not describe all checks as green.
The evidence above is historical. The current implementation and verification commands are in the host-rendered UI document; proposed capabilities below are not automatically shipped features.
## Prior art
- Obsidian lets plugins construct HTML in application-owned view containers.
This gives natural stacking and broad flexibility, but plugins can access the
file system; embedding their code directly in Netcatty's renderer would not
retain Netcatty's existing isolation.
https://docs.obsidian.md/Plugins/Getting%20started/Use%20React%20in%20your%20plugin
https://docs.obsidian.md/Plugins/Vault
- Shopify's MIT-licensed Remote DOM mirrors an isolated UI tree into a host DOM.
It supports a remote DOM environment and examples using React. Its maintainers
explicitly explain that a direct receiver is not by itself an adequate
boundary: untrusted mutations need a separate validation and projection layer.
The latest core release has configurable element/member restrictions and
security fixes; they do not remove the need for a host threat model.
https://github.com/Shopify/remote-dom
https://github.com/Shopify/remote-dom/discussions/587
https://github.com/Shopify/remote-dom/releases
- VS Code web extensions use a separate worker with access to workspace files
through explicit host APIs; their custom webviews are not equivalent to an
application-owned native view.
https://code.visualstudio.com/api/extension-guides/web-extensions
https://code.visualstudio.com/api/extension-guides/webview
## Recommended architecture
1. Keep plugin React execution inside Netcatty's existing isolated, offline
browser runtime (not an attached or visible webview). A plugin uses familiar
React/JSX and produces a remote interface tree. The host owns the real DOM and
controls lifecycle, sizing, focus, navigation and overlays.
2. Validate every incoming mutation against a host-owned schema and a
per-instance capability, then project it into a dedicated host-controlled
surface. Do not forward arbitrary element properties, methods, event handlers,
HTML or URLs. Reject oversized trees, pathological depth, stale instances and
high-rate updates. Do not treat a third-party receiver as the security boundary.
3. Provide ordinary layout/text/form HTML elements and scoped styling rather
than a fixed component palette. The application must own the containing
stacking context and clip output. Stylesheets must be parsed and scoped; deny
unsafe URL loads, external imports, dangerous element types, navigation and
escape from the plugin surface. Built-in theme tokens are available, not
mandatory. Give errors for unsupported constructs instead of silently making
a view appear successful.
4. Route explicitly supported input, focus and keyboard events back to the same
live view instance. Events carry only that view's own interaction data; never
forward unrelated input, terminal output, host secrets or global DOM objects.
Race, ordering, cancellation, multi-window and cleanup must be verified.
5. Define one host-drawn view contract for every placement. Do not keep the
attached-browser view as a compatibility path. UI registration grants no data
capability; host-owned security dialogs must always remain above plugin UI.
## Limitations to make explicit
Fully unrestricted HTML/CSS is incompatible with a strong isolation claim in a
shared DOM: URL-bearing resources can send information out; active elements can
execute or navigate; global selectors and positioning can interfere with trusted
UI. Safe styling can be broad, but is not literally unrestricted. Ordinary React
component libraries that expect direct access to the real DOM, canvas, portals,
measurements or browser globals may need adapters. The initial support matrix must
be tested and published rather than promising full browser compatibility.
The local research package `@remote-dom/react` 1.2.2 declares React 17/18 peer
support, while Netcatty uses React 19. The prototype has since verified a core-only DOM bridge with React 19 for its
basic text input and button flow. Keep that evidence; do not restart the adapter
selection merely because the separate React adapter declares different peers.
Broader third-party component support remains a separate verification task.
## Verification gates
A working end-to-end example must use normal React state, a custom CSS layout,
controlled input and buttons, render in Vault and terminal sidebar, and survive
open/close, splits, tab switching and host dialogs. Security tests must exercise
scripts, unsafe attributes and URLs, CSS imports and escape attempts, global
positioning, host/other-plugin DOM access, network, view and window ownership,
permissions, lock/disable/uninstall, message quotas, crash and late replies.
These are requirements, not claims that the implementation is complete.
## Proposed decisions for the complete design
### Authoring freedom
Expose ordinary layout, text, form and styling primitives. Optional Netcatty
components and theme values are conveniences, never the only way to build a UI.
Do not constrain authors to a fixed collection of cards, forms or buttons.
Support flexible layout, responsive sizing, custom typography, local imagery,
icons and animation as design requirements. The prototype's element allowlist
is an initial safety boundary, not the final product scope. Package images and
fonts need host-validated, package-bound resource handles with type/size limits;
SVG needs an explicit inert subset or sanitized conversion before display.
Never permit an arbitrary resource URL as a shortcut around network permissions.
Use the mounted surface's size for responsive layouts, not just the app window.
Keep plugin CSS inside its surface. Contain positioning and stacking; sanitize
styles before insertion. Shadow DOM helps isolate styles but is not a security
boundary for JavaScript running in the same renderer. Never execute plugin code
in the host renderer or expose host DOM references to it.
### Permanent registration, independent visibility and state
Registration lasts while the plugin is enabled. Closing a sidebar changes its
visibility/layout; it does not unregister the plugin's entry. Users can discover,
select, pin, reorder and split plugin views through the same controls as built-ins.
Separate three concepts: the registered entry, an open view instance, and the
instance's current visibility. Tab switching and hiding should retain instance
state within a bounded suspension policy. Explicitly closing a pane can dispose
its instance without removing the registered entry. Persist layout and the
plugin's own declared state, not arbitrary DOM or snapshots of host user data.
Each instance is bound to its own window, placement and session context. Moving
or reopening a view must not expose another terminal's data. Disabled/uninstalled
plugins lose their entries and active capabilities; locking clears sensitive
rendered content and revokes in-flight operations.
### Dropdowns, dialogs and interaction
In-surface dropdowns can use ordinary plugin styles. For a popup that must extend
beyond its pane, offer a host-owned overlay slot bound to that instance. The plugin
still describes its own content and styling. Netcatty owns anchoring, clipping,
focus return, Escape handling, lifetime and maximum stacking order. No plugin
can overlay trusted permission prompts or supply its own permission approval UI.
Input state cannot be implemented as text-attribute mirroring alone. Define and
test value/checked/selection updates, input composition, keyboard defaults, focus,
labels, accessibility and paste. Send only the target instance's own events;
never global keystrokes, clipboard reads, terminal text or the host document.
Do not expose arbitrary synchronous DOM queries to make a component library work.
Narrow measurements may describe the plugin's own layout only.
### Data protection
Default view creation exposes theme, locale, surface size, and the plugin's own
state and interactions. It grants no access to Vault records, terminal output,
files, passwords, tokens, other plugins or the host clipboard. Gate any optional
data API independently and recheck grants at execution and delivery time.
Scope grants to concrete resources/actions. Prefer host-performed operations and
opaque handles over disclosing secrets. Network authority is separate from data
read authority: granting both can permit exfiltration. User input typed into a
plugin is already disclosed to that plugin; no sandbox can undo that disclosure.
Audit message origin and instance identity, resource loaders, protocol requests,
network egress including WebRTC, host callbacks and package replacement. Bound
CPU, memory, updates and image decoding; isolate failures and permit termination.
A hidden browser runtime is the prototype's execution container, not the visible
UI. Retain it for now rather than replacing a tested path speculatively. Its
full offline and process isolation claims must pass adversarial tests before
settling the execution-container choice. An ordinary Node utility process or
Worker alone is not a sufficient security argument.
### Next validation, without repeating completed work
1. Extend the existing Hello React sample with responsive narrow panes, local
imagery, a dropdown, a checkbox, a select, Chinese input and focus navigation.
2. Exercise actual Vault/sidebar/tab containers: close/reopen, tab switches,
split/reorder, restart restoration and separate windows. Verify registration
persistence separately from live view state.
3. Add hostile-plugin cases for resource URLs, network bypass, stale identities,
cross-instance access, app lock, disable/uninstall and resource exhaustion.
4. Finalize the public contract from these results, then remove the obsolete
attached-browser UI path and prototype-only dual-mode routing together.
Steps above are proposed follow-on work, not completed checks. No compatibility
migration or parallel legacy renderer is part of the target design.
+76
View File
@@ -0,0 +1,76 @@
# Host-owned plugin settings
## Product boundary
Ordinary plugin pages retain freely authored layouts and styles, subject to the
existing isolated runtime and host rendering checks. Settings are exclusively
host-owned forms. There is no `settings` custom view location and no render,
HTML, CSS, or callback field in setting declarations. The unpublished platform
has no compatibility path for custom settings pages.
Plugins declare `contributes.settings`: stable ID, localized label/description,
control, scope, default, options, constraints and optionally a localized `group`.
The host groups rows by label in first-appearance order and keeps field order
within each group. Labels/descriptions are plain text. Hidden settings are
filtered before grouping; empty groups are not rendered. Ungrouped fields do not
receive a redundant heading. Layout, controls, focus, error display, reset and
persistence belong to Netcatty. Internal setting IDs are not displayed as help
text. Permissions and uninstall remain separate host-owned controls.
Each of the six ordinary page placements offers the same circular settings
button in its upper-right corner, only when the owning plugin has visible
settings. The plugin list and package detail offer the same entry. Opening it
uses a bounded, centered host dialog above the page; the page remains mounted.
Escape closes only the settings dialog and returns focus to its entry. Forms
always query the settings context, even when opened from Vault or the terminal.
Disabling/removing the owning contribution closes the page entry's dialog.
Package management and permission controls remain in the separate manager.
Text commits on leaving the field; switches and choices commit immediately.
Writes go through the host's existing ownership, scope and validation checks.
Rejected writes show an inline error and do not replace persisted values. Reset
uses the declared default. Secret fields keep their existing secure storage and
never expose their stored plaintext in renderer snapshots. Grouping grants no
extra data access. Plugins use `settings.get` and `settings.onDidChange` for their
own values; no plugin script needs to run to draw or edit this form.
Complex editors and previews belong in ordinary pages. A future settings link
must be a host-rendered, validated reference to that plugin's own declared page;
this change does not add an arbitrary callback/link escape hatch.
## Reference products (reviewed 2026-10-29)
- [Obsidian settings migration guide](https://docs.obsidian.md/plugins/guides/migrate-declarative-settings):
its 1.13 declarative controls let the app handle rendering, validation,
persistence and search. We adopt host-owned ordinary controls. We do not adopt
executable custom render callbacks inside settings or its legacy fallback.
- [VS Code configuration contributions](https://code.visualstudio.com/api/references/contribution-points#contributes.configuration):
declarations provide types, defaults, descriptions, enums and scopes for a
shared settings editor. We retain our existing setting ownership and scopes.
- [Joplin setting items](https://joplinapp.org/api/references/plugin_api/interfaces/settingitem.html):
labels, sections, choices, ranges and secure storage are declared separately
from plugin page UI. We similarly keep storage and layout separate.
This is a UI restriction as well as a consistency decision. It does not replace
runtime isolation or permission enforcement. We are not adding search, nested
settings pages, or third-party custom setting controls in this change.
## Example and verification
`hello-react-host` 0.2.0 has six custom pages and four persistent host-owned
settings: default name, greeting enabled, accent color and default note.
Grouping is localized; changes propagate to its open pages. The example asks
for access to its own settings only, not host records, files or credentials.
Contract tests reject custom settings placement/markup while accepting ordinary
pages. UI tests cover placement, grouping, saving/reopening, failed writes and
reset. Contribution tests cover localization and persistence without starting
plugin UI. Real Electron coverage checks all six ordinary pages plus persisted
preferences and live setting changes.
Validation for this change: 753 runtime checks passed (6 environment-specific
skips), 92 contract/SDK/CLI checks passed, 23 host-drawn checks passed, and the
real Electron fixture passed all six pages, form editing/reload, rejected writes,
and live preference delivery. The macOS directory package build succeeded.
The repository-wide TypeScript check still reports errors in other files; no
diagnostics were reported for the files changed in this settings work.
@@ -0,0 +1,42 @@
# Plugin UI prior art
Research date: 2026-10-29. Supplement to the host-drawn UI design. Preserve the
existing prototype and its test evidence. This is a new, unpublished feature;
legacy view compatibility and migration are not requirements.
## Source comparison
| Reference | Verified design | What Netcatty can use |
| --- | --- | --- |
| [Shopify Remote DOM](https://github.com/Shopify/remote-dom) | Synchronizes a UI tree from a separate JavaScript environment into host-owned DOM. Includes worker/polyfill and React examples. Its current receiver supports explicit element/member restrictions. | The existing prototype's host-drawn direction. Treat Remote DOM as transport/rendering machinery, not a complete plugin sandbox. |
| [VS Code web extensions](https://code.visualstudio.com/api/extension-guides/web-extensions) | Web extensions run in the browser extension host, lack Node APIs, and use VS Code APIs. They still have broad VS Code API access. | Separation of extension execution from application UI and explicit host APIs. The absence of Node alone does not imply no access to user files. |
| [Joplin plugin architecture](https://joplinapp.org/help/dev/spec/plugins/) | Desktop plugin scripts run in a separate process with a proxy carrying API calls and events to the host. Its [custom panels](https://joplinapp.org/api/references/plugin_api/classes/joplinviewspanels.html) display HTML in webviews. | Independent plugin lifecycle and message-based APIs. Its panel display approach does not meet the requested host-drawn UI design. Process separation primarily helps containment and stability; permission safety depends on exposed APIs. |
| [Obsidian plugin security](https://help.obsidian.md/Extending+Obsidian/Plugin+security) | Official documentation explicitly says plugins inherit the app's access levels and cannot reliably be restricted to individual permissions. Plugins can access files and the internet. | A reference for integration experience, not the requested data-isolation policy. This comparison concerns published plugin documentation, not an audit of open-source application internals. Obsidian's application core must not be presented as an open-source reference merely because its plugin API and examples are public. |
| [Logseq official samples](https://github.com/logseq/logseq-plugin-samples) | Public examples cover custom themes, toolbar/editor interactions and custom pages. The [Hello World entry](https://github.com/logseq/logseq-plugin-samples/blob/master/logseq-hello-world/index.html) is an HTML document loading a plugin SDK and script. | Useful extension-point examples. These samples alone do not establish a security boundary or prove plugins cannot access user data; no such assurance is inferred here. |
## Important security distinction
In the [Remote DOM security discussion](https://github.com/Shopify/remote-dom/discussions/587),
the implementation explanation describes an intermediate data tree, property
allowlists and reprojection before trusted DOM changes. It explicitly warns
against directly applying arbitrary remote patches. This is consistent with the
existing Netcatty prototype's validation-before-rendering approach; it is not a
reason to discard or rebuild that prototype.
The framework supports host-controlled UI elements, but its common examples use
restricted custom components. Netcatty's broader HTML/CSS freedom is an additional
product/security design responsibility, not a guarantee supplied by Shopify.
## Design consequence
Continue from the implemented host-drawn prototype. Keep developer freedom over
ordinary layout, typography, colors and component composition. Independently
control executable content, external resources, global positioning, host data
access and event payloads. UI permission must not implicitly grant file,
terminal, credential, clipboard or network access.
Retain the completed prototype tests as evidence of their specific scenarios.
They are not proof of unrestricted React-library compatibility, complete UI
lifecycle correctness or immunity to malicious plugins. The remaining work is
to finish these boundaries and interaction semantics, with one intended UI
architecture and no compatibility path for the unpublished alternatives.
+22 -42
View File
@@ -7,8 +7,12 @@ Provider subscription, terminal history reader or command execution API.
## Host-owned navigation
An Extension pages menu in the normal terminal toolbar lists visible pages
with their plugin source. Selecting one switches the existing focused pane;
Visible plugin pages also have persistent entries in the side-panel toolbar,
alongside built-in tools. They support the same ordering, overflow and
show/hide customization, and reappear after closing and reopening the panel.
Unavailable contributions are hidden without discarding their saved placement.
The Extension pages menu in the terminal toolbar remains another entry point.
Selecting a page switches the existing focused pane;
selecting an already-open tool focuses that pane. Existing split menus include
the same contributions and enforce unique tools and the eight-pane limit.
Host-generated `plugin-view:<namespaced-view-id>` keys cannot replace built-in
@@ -30,45 +34,21 @@ the view context. Internal ownership IDs are used for host state scoping, not
returned to the page or treated as resource grants. A future selected-session
data API must establish its own live endpoint authority and permissions.
Changing terminal tabs or workspace focus closes the old native view before
the new one can receive focus. Session removal, pane close, withdrawal of a
visible contribution, disable/update/uninstall and runtime loss remove the
affected page. Unknown saved routes are pruned without opening them; restoring
a layout never grants the page or any session-data permission. State buckets
are for the plugin's own UI data, not an isolation boundary against that same
plugin's already-authorized own-state API. Ordinary KV does not become synced.
Changing terminal tabs or focused sessions releases the visible surface and
retains its own logical instance within the bounded cache. Reopening uses the
matching window/placement/session scope. Pane close changes layout; enabled
plugin entries remain available. Disable/update/uninstall or runtime loss
invalidate the affected instances immediately.
PersistentSidePanelPortal moves a stable mount between the actual pane,
magnification host and hidden parking host. There is no fallback native view
over the entire terminal layer. Native geometry follows host DOM relocation,
resize and scrolling; duplicate geometry updates are suppressed. Hidden/inert
or parked mounts cannot reveal a native view.
PersistentSidePanelPortal moves the host DOM surface between its actual pane,
magnification host and hidden parking host. Layout and focus follow the existing
host pane system; no native geometry or focus bridge is needed. Host dialogs
appear above the content. Plugin menus use host-owned overlay placement and
are dismissed when a trusted modal appears.
## Focus and security surfaces
The existing sandbox ViewHost, immutable package/protocol checks, own-command
and view-state boundaries are reused. Embedded pages start hidden. Host
dialogs and overlapping portals hide them, and main-process App Lock closes
them. Escape is host-owned and closes the plugin pane.
Native WebContents focus is reported only to its actual owner's trusted
renderer using a canonical instance-ID event. It updates the selected pane;
it does not expose an Electron object to the plugin. Revealing multiple panes
does not focus all of them: only the host-selected pane can request focus.
The main process verifies actual owner, visibility, foreground and unlocked
state before honoring that request. A plugin has no SDK method to invoke the
host focus bridge. No session or page text is added to focus/audit logs.
## Example and evidence
`com.netcatty.session-scratchpad` 0.1.0 asks only for its own `views`. It saves,
reopens and clears explicitly typed synthetic text. Package instructions and
minimal native actions are in its README. Repository tests use real split
tree, layout state, persistent portal, launcher and embedded-view components;
host tests cover owner-bound focus, hidden/locked refusal and withdrawal.
Feature and permission admission, limits, old APIs and previous Vault/command
fixes are checked on the same stack. Independent review, exact-SHA CI and
parent native QA remain separate acceptance gates recorded on the Draft PR.
ThemePack, selected context/terminal drafts and SFTP handles remain subsequent
bounded stages. Adding a sidebar page does not count as implementing them.
See [host-rendered UI](./host-rendered-ui.md) for authoring, state limits and
security boundaries. The `session-scratchpad`, `hello-world-v2` and
`hello-react-host` examples use this placement. Component tests exercise the
real split tree, toolbar ordering, portal relocation, session switching and
withdrawal. The Electron examples exercise visible controls through the shared
host surface. Neither registration nor state retention grants session access.
+15 -52
View File
@@ -1,6 +1,6 @@
# Plugin UI contributions
Status: internal preview (`0.1.0-internal`), development gate only
Status: unpublished plugin platform; host-rendered browser views enabled in normal Settings
Phase 4 of [#2269](https://github.com/binaricat/Netcatty/issues/2269)
connects the manifest contribution contract to Netcatty's native UI and to a
@@ -31,6 +31,10 @@ otherwise active plugin.
## Native settings
Settings are always host-rendered forms. Plugins cannot contribute custom HTML,
CSS or executable renderers here. Optional localized `group` labels organize
fields in declaration order. See [plugin-settings.md](plugin-settings.md).
Netcatty renders setting declarations with host-owned controls. The supported
controls are switches, radio/select/multiselect, text and password fields,
textarea, number and slider, color, font, file and directory paths, keybindings,
@@ -110,58 +114,17 @@ contexts rather than sharing one renderer snapshot context. This gives the PR 5
Terminal Provider layer a stable session identity without exposing xterm or
allowing renderer-supplied plugin keys to override runtime-owned Context Keys.
## Sandboxed custom views
## Host-rendered custom views
Each open custom view is a lazily created `WebContentsView` with:
All custom views use the [host-rendered UI contract](./host-rendered-ui.md).
Plugins author ordinary React or DOM and CSS in the isolated runtime. Netcatty
validates and draws their content in the same DOM as its own UI. There is no
visible browser surface, HTML entry document, or legacy renderer selection.
- `sandbox: true`, context isolation, web security, and no Node integration;
- a private ephemeral session and a fixed black-hole proxy;
- browser permissions, downloads, popups, webviews, drag navigation, and
navigation away from the registered entry document denied;
- a protocol token scoped to one plugin package;
- a CSP with `connect-src 'none'`, no frames, workers, objects, media, forms, or
base-URL changes; and
- a Permissions Policy denying camera, microphone, location, display capture,
USB, serial, HID, payment, fullscreen, and clipboard capabilities.
The protocol serves package files only. A view cannot load host runtime modules
or another plugin's package. The view instance is bound to the Netcatty window
that created it; another renderer window cannot resize, message, or close it.
Owner closure, plugin disable, setup failure, and host shutdown all dispose the
view and its protocol/session registrations.
Opening is generation-bound: the host revalidates the exact plugin version and
runtime identity after package preparation and again after document loading,
before attaching the `WebContentsView`. Runtime stop, crash, quarantine,
disable, uninstall, or replacement therefore cancels an in-flight open. Every
host-side close is also broadcast to the owning renderer so retained and active
view state, including native tabs, is withdrawn immediately.
Views declaring `retainContextWhenHidden` are hidden without destroying their
owner-bound `WebContentsView` and are restored with fresh bounds when reopened.
Retained views are still disposed on owner shutdown, plugin disable, runtime
quarantine, or host shutdown; the flag never extends ownership or permissions.
Views declaring `location: "tab"` participate in Netcatty's draggable native
top-tab model, including neighbor activation, middle-click and Cmd/Ctrl+W close,
and close-others/right/all actions. They do not fall through to the overlay
surface used by non-tab locations.
The application-state lifecycle module combines a directly tested controller
for active and retained instances, in-flight open tokens, early-close tombstones,
and explicit-close handling with a hook that owns tab-catalog reconciliation,
bounds, keyboard dispatch, and environment publication. The React component is
rendering glue only. Contribution query changes expose a fail-closed empty
snapshot while loading, but cannot mutate the native tab catalog until the
matching query completes; opening or switching a tab therefore cannot withdraw
an unconditional plugin tab during its own context refresh.
The preload exposes only `postMessage`, same-plugin `executeCommand`, state
get/set, runtime messages, and environment changes. It caches environment
updates before view code subscribes and uses an owner-checked getter as a
fallback, so late subscribers still receive their initial environment. Messages,
state, command arguments, and Context Key values use the exact bounded JSON
value boundary rather than relying on `JSON.stringify` coercion.
The host owns placement, registration, retention, overlays and window identity.
Commands, settings and privileged capabilities keep their existing permission
boundaries. The linked contract documents authoring, supported controls,
resources, quotas, cleanup and current limitations.
## Themes, localization, and accessibility
@@ -177,7 +140,7 @@ preference, and subsequent environment changes. Theme-token mutations and
accessibility media-query changes are observed while the host is open, rather
than only at initial view creation. Netcatty retains the accessible
name and close control around every custom view; modal placements use dialog
semantics, while aside, panel, tab, and settings placements use named regions.
semantics, while aside, panel, and tab placements use named regions.
## SDK surface
+15 -75
View File
@@ -1,80 +1,20 @@
# Vault extension pages
`views.vault-pages` adds the `vault-page` view location. A package must require
the feature and declare the existing `views` permission. Hosts without the
feature reject the package deterministically. Existing view locations and API
versions keep their behavior; there is no public plugin router or new Vault
read/write permission.
Declare `location: "vault-page"`, require `views.vault-pages`, and request the
`views` permission. Enabled entries appear in Vault's Extensions navigation,
with localized labels and host-resolved icons. Listing does not activate them.
## User workflow
Selecting an entry uses the built-in Vault rounded surface without an extra
plugin title bar. Netcatty draws the content itself; host menus and dialogs use
normal stacking. The plugin receives only the `netcatty.surface: vault-page`
context and its own interactions. Selection shares no Vault or session records.
Visible, enabled contributions appear in Vault's **Extensions** group. The
host generates a namespaced route from the declared view ID, orders by declared
order and ID, localizes the label, and resolves the declared icon through the
existing safe icon pipeline. Built-in navigation and security entry points
remain host-owned. Collapsed navigation retains source-labelled accessible
buttons.
Leaving and returning retains the view within the bounded lifecycle policy.
Missing/disabled entries, invalid context or failed activation return to Hosts.
Late failure from an older selection cannot replace a newer page. App Lock,
disable and runtime termination clear the associated content and authority.
Selecting a page mounts its existing sandbox WebContentsView in the Vault main
content area. It does not create a top tab, use an aside overlay, open a
connection, or share host/session/Vault records. The trusted host context
contains only `netcatty.surface: vault-page`. The manager's existing Open action
can select the same page through an internal host event; this is not exposed
as a generic SDK navigation capability.
The `vault-scratchpad` browser example lets the user type synthetic text, save
it in the plugin's own view state, return to Hosts, reopen, and clear it. It
does not create a Vault Note or enable automatic cloud sync. The fixture asks
for only `views`; granting it does not approve another plugin or data access.
## Ownership and lifecycle
The host owns the route and validates the selected contribution against the
current visible catalog. Missing IDs, disable/uninstall/update, a false `when`,
failed activation or a host close return to Hosts. Focus restoration checks
the current section and active tab so an old fallback cannot steal focus after
a newer selection. Catalog listing does not activate a plugin.
The embedded host reuses PluginViewHost, its ephemeral session, CSP, no-Node
preload, protocol validation, own-command checks, and bounded own view state.
No DOM or Electron object enters the SDK. A window-route-and-slot scope separates
this page's state from other view placements; actual BrowserWindow ownership is
validated separately and does not rely on that state key. This initial embedded slice
closes on navigation away even if `retainContextWhenHidden` is declared.
Each opening reserves an instance ID owned by its actual BrowserWindow before
asynchronous activation. Close can cancel that reservation before native
construction. Owner closure, shutdown and renderer cleanup prevent a late open
from attaching; late responses are also closed by the renderer lifecycle
controller. Current plugin version/runtime and `when` are checked before
attachment and on subsequent contribution changes. Existing owner checks apply
to bounds, visibility, close and view-state IPC.
Host reload/renderer exit and a view renderer crash close the associated native
page. Plain Escape in a Vault page is handled by the host and returns to Hosts;
it does not dispatch a plugin command or expose a general navigation API.
Native child views sit above React portals. A Vault page starts hidden; the
trusted renderer reveals it only after checking the mount and host dialogs,
menus, popovers, overlay panels and lock surfaces. Overlapping host surfaces
hide the page. App Lock additionally closes all hosted plugin views and
cancels pending opens in the main process; showing a view while locked fails.
The shared manifest bound remains 128 views per package. Only one Vault page is
mounted per owner; listing and routing do not create a background view queue.
View-state JSON size limits and existing runtime/permission quotas remain in
force. No page text or state is added to audit logs by this adapter.
## Validation status
Source tests cover normal catalog selection, the actual embedded content slot,
host-modal occlusion, synchronous tab-away cleanup, withdrawn and invalid
routes, internal host selection, delayed open cancellation, feature/permission
admission, host ownership, `when` withdrawal and App Lock. Production build,
aggregate host tests and precise-SHA CI are reported on the accompanying Draft
PR. Parent native acceptance must use the packed browser example through
normal Settings and record its exact source SHA/package hash. Unit tests do
not substitute for that native acceptance.
Terminal sidebar pages and selected-session DTOs remain separate stages. This
page location does not make those capabilities available.
See [host-rendered UI](./host-rendered-ui.md) for the single rendering contract,
resources, state retention and validation. `hello-vault`, `hello-world-v2`,
`hello-react-host` and `vault-scratchpad` demonstrate this placement. The scratchpad
saves only text entered in its own page, without creating a Vault Note.
+81
View File
@@ -0,0 +1,81 @@
"use strict";
/* global TextEncoder */
const { validatePluginInlineStyle } = require("./pluginViewStylePolicy.cjs");
// These are HTML primitives, not a fixed design-system component catalogue.
// Active elements, custom elements and URL attributes need separate authority.
const ELEMENTS = new Set(["form", "img", "div", "span", "p", "h1", "h2", "h3", "h4", "h5", "h6", "section", "article", "header", "footer", "main", "aside", "nav", "ul", "ol", "li", "dl", "dt", "dd", "strong", "em", "small", "b", "i", "code", "pre", "blockquote", "br", "hr", "table", "thead", "tbody", "tfoot", "tr", "th", "td", "button", "input", "textarea", "label", "select", "option", "fieldset", "legend", "progress", "details", "summary"]);
const VOID_ELEMENTS = new Set(["br", "hr", "img", "input"]);
const ATTRIBUTES = new Set(["lang", "dir", "anchor", "src", "alt", "width", "height", "class", "id", "title", "role", "style", "name", "placeholder", "value", "checked", "disabled", "readonly", "required", "multiple", "selected", "type", "for", "tabindex", "min", "max", "step", "minlength", "maxlength", "rows", "cols", "colspan", "rowspan", "scope", "open", "autocomplete"]);
const INPUT_TYPES = new Set(["text", "search", "email", "url", "tel", "number", "password", "checkbox", "radio", "range", "date", "time", "datetime-local", "color", "button"]);
const NAME = /^[a-zA-Z0-9_-]{1,80}$/u;
const MAX_NODES = 1024;
const MAX_DEPTH = 24;
const MAX_VALUE = 8192;
const MAX_RECORDS = 2048;
function validateId(value, root = false) {
if (typeof value !== "string" || (root && value === "~" ? false : !NAME.test(value))) throw new TypeError("Invalid view node ID");
}
function record(value) {
return value !== null && typeof value === "object" && !Array.isArray(value)
&& (Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null);
}
function validateAttribute(element, name, value) {
if (typeof name !== "string" || !/^[a-z][a-z0-9-]{0,79}$/u.test(name) || (!ATTRIBUTES.has(name) && !/^aria-[a-z0-9-]+$/u.test(name) && !/^data-[a-z0-9-]+$/u.test(name))) throw new TypeError("Forbidden view attribute");
if (value === null) return;
if (typeof value !== "string" || value.length > MAX_VALUE || value.includes("\0")) throw new TypeError("Invalid view attribute value");
if (name === "type" && element === "input" && !INPUT_TYPES.has(value.toLowerCase())) throw new TypeError("Forbidden input type");
if (name === "type" && element === "button" && !["button", "reset", "submit"].includes(value.toLowerCase())) throw new TypeError("Forbidden button type");
if (name === "src" && (!/^asset:[a-zA-Z0-9_-][a-zA-Z0-9_./-]{0,255}$/.test(value) || value.slice(6).split("/").some((part) => !part || part === "." || part === ".."))) throw new TypeError("Only package images are permitted");
if (name === "style") validatePluginInlineStyle(value);
}
/** Validate a complete, data-only snapshot before touching a host DOM. */
function validatePluginRemoteTree(root) {
if (!record(root) || !Array.isArray(root.children) || Object.keys(root).some((key) => key !== "children")) throw new TypeError("Invalid view root");
const ids = new Set(); let count = 0; let textSize = 0;
const visit = (node, depth) => {
if (++count > MAX_NODES || depth > MAX_DEPTH || !record(node)) throw new TypeError("View tree exceeds limits");
validateId(node.id); if (ids.has(node.id)) throw new TypeError("Duplicate view node"); ids.add(node.id);
if (node.type === 3 || node.type === 8) {
if (Object.keys(node).some((key) => !["id", "type", "data"].includes(key)) || typeof node.data !== "string" || node.data.length > MAX_VALUE) throw new TypeError("Invalid view text");
textSize += node.data.length; if (textSize > 128 * 1024) throw new TypeError("View text exceeds limits"); return;
}
if (node.type !== 1 || typeof node.element !== "string" || !ELEMENTS.has(node.element)
|| !Array.isArray(node.children) || !record(node.attributes)
|| Object.keys(node).some((key) => !["id", "type", "element", "attributes", "children"].includes(key))) throw new TypeError("Forbidden view element");
if (VOID_ELEMENTS.has(node.element) && node.children.length) throw new TypeError("Void view elements cannot have children");
const attributes = Object.entries(node.attributes);
if (attributes.length > 64) throw new TypeError("Too many view attributes");
for (const [name, value] of attributes) {
validateAttribute(node.element, name, value);
textSize += name.length + (value?.length ?? 0);
if (textSize > 128 * 1024) throw new TypeError("View data exceeds limits");
}
for (const child of node.children) visit(child, depth + 1);
};
for (const child of root.children) visit(child, 1);
return true;
}
/** Preflight mutation size and shape before applying to an isolated data tree. */
function validatePluginMutationBatch(batch) {
if (!Array.isArray(batch) || batch.length > MAX_RECORDS) throw new TypeError("Invalid view mutations");
let size;
try { size = new TextEncoder().encode(JSON.stringify(batch)).byteLength; } catch { throw new TypeError("Invalid view mutations"); }
if (size > 256 * 1024) throw new TypeError("View mutations exceed limits");
for (const item of batch) {
if (!Array.isArray(item) || ![0, 1, 2, 3].includes(item[0])) throw new TypeError("Invalid view mutation");
validateId(item[1], true);
if (item[0] === 0) {
if (item.length !== 4 || !Number.isSafeInteger(item[3]) || item[3] < 0 || item[3] > MAX_NODES) throw new TypeError("Invalid insertion");
validatePluginRemoteTree({ children: [item[2]] });
} else if (item[0] === 1) {
if (item.length !== 3 || !Number.isSafeInteger(item[2]) || item[2] < 0 || item[2] > MAX_NODES) throw new TypeError("Invalid removal");
} else if (item[0] === 2) {
if (item.length !== 3 || typeof item[2] !== "string" || item[2].length > MAX_VALUE) throw new TypeError("Invalid text update");
} else if (item.length < 4 || item.length > 5 || (item[4] !== undefined && item[4] !== 2)) {
throw new TypeError("Only attribute updates are permitted");
} else validateAttribute("", item[2], item[3]);
}
return true;
}
module.exports = { validatePluginRemoteTree, validatePluginMutationBatch, validateAttribute, ELEMENTS };
+46
View File
@@ -0,0 +1,46 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const { validatePluginRemoteTree, validatePluginMutationBatch } = require("./pluginRemoteTreePolicy.cjs");
const text = (id, data) => ({ id, type: 3, data });
const element = (id, name, attributes = {}, children = []) => ({ id, type: 1, element: name, attributes, children });
test("normal HTML layout and styles can be represented without executable code", () => {
const root = { children: [element("1", "section", { class: "card", style: "display:grid;grid-template-columns:1fr 2fr;gap:12px" }, [element("2", "h1", {}, [text("3", "Hello")]), element("4", "button", { type: "button" }, [text("5", "Click")])])] };
assert.equal(validatePluginRemoteTree(root), true);
assert.equal(validatePluginMutationBatch([[0, "~", root.children[0], 0], [3, "1", "class", "ready"], [2, "3", "Updated"]]), true);
});
test("untrusted content cannot introduce executable elements, URLs or dangerous properties", () => {
const attacks = [
element("1", "script"), element("1", "iframe"), element("1", "img", { src: "https://example.invalid" }),
element("1", "a", { href: "javascript:alert(1)" }), element("1", "div", { onclick: "alert(1)" }),
element("1", "input", { type: "file" }), element("1", "div", { style: "background:url(https://example.invalid)" }),
element("1", "div", { style: "color:red;} body{color:red" }),
element("1", "div", { __proto__: "bad" }, [text("1", "duplicate")]),
{ id: "1", type: 1, element: "div", attributes: {}, properties: { innerHTML: "bad" }, children: [] },
];
for (const attack of attacks) assert.throws(() => validatePluginRemoteTree({ children: [attack] }), undefined, JSON.stringify(attack));
assert.throws(() => validatePluginMutationBatch([[3, "1", "innerHTML", "<script>evil()</script>"]]));
assert.throws(() => validatePluginMutationBatch([[3, "1", "__proto__", {}]]));
assert.throws(() => validatePluginMutationBatch([[3, "1", "onclick", "evil()", 2]]));
assert.throws(() => validatePluginMutationBatch([[3, "1", "class", "x", 3]]));
});
test("remote trees and mutation batches are bounded", () => {
assert.throws(() => validatePluginRemoteTree({ children: Array.from({ length: 1025 }, (_, i) => text(`${i}`, "x")) }));
let node = text("25", "x"); for (let i = 24; i >= 0; i--) node = element(`${i}`, "div", {}, [node]);
assert.throws(() => validatePluginRemoteTree({ children: [node] }));
assert.throws(() => validatePluginMutationBatch(Array.from({ length: 2049 }, () => [2, "1", "x"])));
assert.throws(() => validatePluginMutationBatch([[2, "1", "x".repeat(8193)]]));
});
test("void children are rejected in snapshots and mutation insertions", () => {
for (const name of ["br", "hr", "img", "input"]) {
const node = element("void", name);
assert.equal(validatePluginRemoteTree({ children: [node] }), true);
node.children.push(text("child", "invalid"));
assert.throws(() => validatePluginRemoteTree({ children: [node] }), /Void view elements/);
assert.throws(() => validatePluginMutationBatch([[0, "~", node, 0]]), /Void view elements/);
}
});
+57
View File
@@ -0,0 +1,57 @@
"use strict";
// Plugin styles are untrusted: they are never inserted before this check.
const cssTree = require("css-tree");
const MAX_CSS_LENGTH = 64 * 1024;
const MAX_AST_NODES = 20_000;
const ALLOWED_AT_RULES = new Set(["media", "supports", "container", "keyframes", "-webkit-keyframes", "layer"]);
const ALLOWED_FUNCTIONS = new Set([
"var", "calc", "min", "max", "clamp", "abs", "sign", "round", "mod", "rem", "hypot", "pow", "sqrt", "log", "exp", "sin", "cos", "tan", "asin", "acos", "atan", "atan2",
"rgb", "rgba", "hsl", "hsla", "hwb", "lab", "lch", "oklab", "oklch", "color", "color-mix", "light-dark",
"linear-gradient", "radial-gradient", "conic-gradient", "repeating-linear-gradient", "repeating-radial-gradient", "repeating-conic-gradient",
"translate", "translatex", "translatey", "translatez", "translate3d", "scale", "scalex", "scaley", "scalez", "scale3d", "rotate", "rotatex", "rotatey", "rotatez", "rotate3d", "skew", "skewx", "skewy", "matrix", "matrix3d", "perspective",
"blur", "brightness", "contrast", "grayscale", "hue-rotate", "invert", "opacity", "saturate", "sepia", "drop-shadow",
"cubic-bezier", "steps", "linear", "minmax", "repeat", "fit-content", "env", "counter", "counters", "symbols",
]);
const DENIED_PROPERTIES = new Set(["behavior", "-moz-binding", "-webkit-app-region", "app-region"]);
const HOST_TOKENS = new Set(["--background", "--foreground", "--muted", "--muted-foreground", "--border", "--primary", "--primary-foreground"]);
const decode = (name) => cssTree.ident.decode(name).toLowerCase();
/** Returns canonical CSS only if every construct is safe for a clipped shadow root. */
function validatePluginCss(source, context = "stylesheet") {
if (typeof source !== "string" || source.length > MAX_CSS_LENGTH || source.includes("\0")) throw new TypeError("Invalid plugin stylesheet");
let ast;
try { ast = cssTree.parse(source, { context, parseCustomProperty: true, positions: false }); }
catch { throw new TypeError("Invalid plugin stylesheet"); }
let count = 0;
cssTree.walk(ast, (node) => {
if (++count > MAX_AST_NODES) throw new TypeError("Plugin stylesheet exceeds complexity limits");
if (node.type === "Raw" || node.type === "Url") throw new TypeError("Plugin stylesheet contains unsafe content");
if (node.type === "Atrule" && !ALLOWED_AT_RULES.has(decode(node.name))) throw new TypeError("Plugin stylesheet contains a forbidden rule");
if (node.type === "PseudoClassSelector" && ["host", "host-context", "global", "visited"].includes(decode(node.name))) throw new TypeError("Plugin selector crosses the view boundary");
if (node.type === "PseudoElementSelector" && ["part", "slotted"].includes(decode(node.name))) throw new TypeError("Plugin selector crosses the view boundary");
if (node.type === "Function") {
const name = decode(node.name);
if (!ALLOWED_FUNCTIONS.has(name)) throw new TypeError("Plugin stylesheet contains an unsafe function");
if (name === "var") {
const first = node.children?.first;
const variable = first?.type === "Identifier" ? cssTree.ident.decode(first.name) : "";
if (!variable.startsWith("--plugin-") && !HOST_TOKENS.has(variable)) throw new TypeError("Plugin stylesheet references an unapproved variable");
}
}
if (node.type === "Declaration") {
const property = decode(node.property);
if (DENIED_PROPERTIES.has(property) || (property.startsWith("--") && !property.startsWith("--plugin-"))) throw new TypeError("Plugin stylesheet contains a forbidden property");
if (property === "position") {
const first = node.value.children?.first;
if (!first || first.type !== "Identifier" || !["static", "relative", "absolute", "sticky"].includes(decode(first.name))
|| node.value.children.size !== 1) throw new TypeError("Plugin stylesheet contains unsafe positioning");
}
}
});
return cssTree.generate(ast);
}
function validatePluginStylesheet(source) { return validatePluginCss(source); }
function validatePluginInlineStyle(source) { return validatePluginCss(source, "declarationList"); }
module.exports = { validatePluginStylesheet, validatePluginInlineStyle, MAX_CSS_LENGTH, MAX_AST_NODES };
+26
View File
@@ -0,0 +1,26 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const { validatePluginStylesheet } = require("./pluginViewStylePolicy.cjs");
test("plugin styling supports ordinary layouts, animation and scoped theme tokens", () => {
const css = `.layout { display: grid; grid-template-columns: 1fr 2fr; gap: 12px; --plugin-accent: #fa0; color: hsl(var(--foreground)); }
@media (max-width: 400px) { .layout { display:flex; flex-direction:column; } }
@keyframes spin { from { transform:rotate(0deg) } to { transform:rotate(360deg) } }
.action:hover { animation:spin 1s; color:var(--plugin-accent); }`;
assert.doesNotThrow(() => validatePluginStylesheet(css));
});
test("plugin CSS rejects network loads, escapes and access outside the surface", () => {
const attacks = [
'@import "https://bad.example/a.css";', '@\\69mport "https://bad.example/a.css";',
'.x{background:url(https://bad.example/x)}', '.x{--plugin-a:url(https://bad.example)}',
'.x{background:u\\72l(https://bad.example)}', '.x{background:i\\6d age-set("https://bad.example" 1x)}',
'@font-face{font-family:x;src:url(https://bad.example/font)}', ':host{position:absolute}',
':host-context(.secret) .x {color:red}', '.x::part(secret){color:red}',
'.x{position:fixed}', '.x{position:fi\\78 ed}', '.x{--plugin-pos:fixed;position:var(--plugin-pos)}', '.x{background:image("https://bad.example/x")}', '.x{-webkit-app-region:drag}', '.x{background:var(--host-secret)}',
'.x{--foreground:red}', '.x{content:attr(data-secret)}', '.x{color:red;}\0',
];
for (const attack of attacks) assert.throws(() => validatePluginStylesheet(attack), undefined, attack);
assert.throws(() => validatePluginStylesheet('x'.repeat(65537)));
});
+2
View File
@@ -71,6 +71,8 @@ module.exports = {
'domain/pluginRuntimePolicy.cjs',
'domain/pluginPermissionDescriptions.cjs',
'domain/pluginThemePack.cjs',
'domain/pluginRemoteTreePolicy.cjs',
'domain/pluginViewStylePolicy.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 -1
View File
@@ -235,7 +235,7 @@ function createBridgeRegistrar(context) {
resolveContributionIcon: (payload) => pluginHostService?.contributionIconService?.resolve(payload),
themeService: pluginHostService?.themeService,
logService: pluginHostService?.logService,
viewHost: pluginHostService?.viewHost,
hostDrawnViewHost: pluginHostService?.hostDrawnViewHost,
env: process.env,
isTrustedSender: createTrustedPluginBridgeSender({ devServerUrl: effectiveDevServerUrl }),
broadcast: (channel, payload) => {
@@ -40,13 +40,16 @@ if (!schema.$defs.PluginAIChatResult) {
service.runtimeSupervisor.runtimeFactories.browser = inProcessBrowserRuntime;
await service.manager.initialize();
const sample = path.resolve(__dirname, "../../../examples/plugins/assistant");
// Use an explicit six-file fixture snapshot, not source/build tooling.
// This command-only fixture excludes view assets; UI runs in the Electron suite.
const staging = path.join(root, "package"); await fs.mkdir(path.join(staging, "dist"), { recursive: true });
for (const file of ["netcatty.plugin.json", "panel.html", "panel.css", "panel.js", "README.md"]) await fs.copyFile(path.join(sample, file), path.join(staging, file));
for (const file of ["netcatty.plugin.json", "README.md"]) await fs.copyFile(path.join(sample, file), path.join(staging, file));
await fs.copyFile(path.join(sample, "src/index.js"), path.join(staging, "dist/index.js"));
const commandManifest = JSON.parse(await fs.readFile(path.join(staging, 'netcatty.plugin.json'), 'utf8'));
commandManifest.main.browser = 'dist/index.js';
await fs.writeFile(path.join(staging, 'netcatty.plugin.json'), JSON.stringify(commandManifest));
const { buildPluginPackage, validatePluginPackage } = await import("@netcatty/plugin-cli");
const archive = path.join(root, "assistant.ncpkg"); const built = await buildPluginPackage(staging, archive);
assert.equal(built.fileCount, 6); assert.equal((await validatePluginPackage(archive)).manifest.id, "com.netcatty.assistant");
assert.equal(built.fileCount, 3); assert.equal((await validatePluginPackage(archive)).manifest.id, "com.netcatty.assistant");
await service.manager.install(archive, { enable: true });
const id = "com.netcatty.assistant";
const run = (name, args) => service.contributionService.executeCommand(`${id}.${name}`, args);
+5 -3
View File
@@ -41,8 +41,10 @@ const { PluginHostRpcRegistry } = require("./hostRpcRegistry.cjs");
async function fixture(t, options = {}) {
let peer;
let accepted;
const peerReady = new Promise((resolve) => { accepted = resolve; });
const sockets = new Set();
const server = net.createServer((socket) => { peer = socket; sockets.add(socket); socket.on("error", () => {}); });
const server = net.createServer((socket) => { peer = socket; accepted(socket); sockets.add(socket); socket.on("error", () => {}); });
server.listen(0, "127.0.0.1");
await once(server, "listening");
let service;
@@ -78,7 +80,7 @@ async function fixture(t, options = {}) {
service.register(registry);
const routes = registry.createRoutes({ ...identity, assertCurrent() {} });
const call = (method, params) => routes.requestHandlers[method](params, { signal: new AbortController().signal });
return { service, call, endpoint, audit, writes, swap: () => { source = "second"; }, peer: () => peer };
return { service, call, endpoint, audit, writes, swap: () => { source = "second"; }, peerReady, peer: () => peer };
}
test("source changes before grant publication cannot create a socket or grant", async (t) => {
@@ -104,7 +106,7 @@ test("source changes during a real pending read discard bytes and skip the new s
const descriptor = await f.call("network.stream.connect", { endpoint: f.endpoint });
await f.call("network.stream.attach", { handleId: descriptor.handleId });
f.swap();
f.peer().write("synthetic old-source bytes");
(await f.peerReady).write("synthetic old-source bytes");
for (let attempt = 0; attempt < 100 && f.service.records.size; attempt++) await new Promise((resolve) => setImmediate(resolve));
assert.equal(f.writes.length, 0);
assert.equal(f.service.records.size, 0);
+1 -2
View File
@@ -78,8 +78,7 @@
"en": "Starter: Greeting Panel",
"zh-CN": "入门:问候面板"
},
"location": "modal",
"entry": "panel.html"
"location": "modal"
}
]
},
+1
View File
@@ -567,6 +567,7 @@ class PluginContributionService {
return {
...setting,
label: localize(setting.label),
group: setting.group == null ? undefined : localize(setting.group),
description: setting.description == null ? undefined : localize(setting.description),
placeholder: setting.placeholder == null ? undefined : localize(setting.placeholder),
options: setting.options?.map((option) => ({
@@ -724,3 +724,19 @@ test("Provider contributions expose a PR 5 lazy activation seam", async (context
assert.deepEqual(result.identity, identity);
assert.deepEqual(calls, ["start:com.example.provider"]);
});
test("host settings groups are localized and preferences persist without activating UI code", async (t) => {
const pluginManifest = manifest();
pluginManifest.contributes.settings[0].group = { en: "General", "zh-CN": "常规" };
const { service, database, calls } = setup(t, pluginManifest);
const settingId = `${pluginManifest.id}.greeting`;
const snapshot = () => service.snapshot().plugins[0].settings[0];
assert.equal(snapshot().group, "常规");
await service.updateSetting(pluginManifest.id, settingId, "persisted preference");
assert.equal(database.getSetting(pluginManifest.id, settingId, "application", "application"), "persisted preference");
assert.equal(snapshot().value, "persisted preference");
assert.ok(!calls.some(call => call.startsWith("start:")), "editing host settings does not start plugin UI");
await service.resetSetting(pluginManifest.id, settingId);
assert.equal(snapshot().value, "hello");
});
@@ -91,6 +91,27 @@ if (!process.versions.electron) {
ready: true,
});
}
// Exercise browser-network bypasses against local listeners, never a remote service.
const http = require('node:http'); const dgram = require('node:dgram');
let requests = 0; let packets = 0;
const server = http.createServer((_request, response) => { requests++; response.end('unexpected'); });
const udp = dgram.createSocket('udp4'); udp.on('message', () => { packets++; });
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
await new Promise((resolve) => udp.bind(0, '127.0.0.1', resolve));
try {
const runtime = service.runtimeSupervisor.runtimes.get('com.netcatty.runtime-smoke-browser');
const isolation = await runtime.window.webContents.executeJavaScript(`(() => {
try { fetch('http://127.0.0.1:${server.address().port}/fetch').catch(() => {}); } catch {}
const image = new Image(); image.src = 'http://127.0.0.1:${server.address().port}/image';
try { const rtc = new RTCPeerConnection({ iceServers: [{ urls: 'stun:127.0.0.1:${udp.address().port}' }] }); rtc.createDataChannel('probe'); rtc.createOffer().then((offer) => rtc.setLocalDescription(offer)).catch(() => {}); window.rtcProbe = rtc; } catch {}
return { node: typeof require, process: typeof process, bridge: typeof window.netcatty, visibleHost: !!document.querySelector('[data-section=vault-main]') };
})()`);
assert.deepEqual(isolation, { node: 'undefined', process: 'undefined', bridge: 'undefined', visibleHost: false });
await new Promise((resolve) => setTimeout(resolve, 1500));
assert.equal(requests, 0, 'browser fetch/image cannot bypass host permissions');
assert.equal(packets, 0, 'WebRTC cannot bypass the offline runtime');
await runtime.window.webContents.executeJavaScript('window.rtcProbe?.close()');
} finally { server.close(); udp.close(); }
await service.manager.shutdown();
fs.rmSync(userData, { recursive: true, force: true });
process.stdout.write("PLUGIN_RUNTIME_SMOKE_OK\n");
@@ -0,0 +1,53 @@
"use strict";
// Isolated proof of concept; never loads plugin code into the user's profile.
if (!process.versions.electron) {
require('node:test')('embedded frame security proof', { skip: 'requires Electron' }, () => {});
} else {
const assert = require("node:assert/strict");
const http = require("node:http");
const dgram = require("node:dgram");
const electron = require("electron");
electron.app.on("window-all-closed", () => {});
void electron.app.whenReady().then(async () => {
const requests = [];
const udp = dgram.createSocket("udp4"); let udpPackets = 0;
udp.on("message", () => { udpPackets++; });
await new Promise((resolve) => udp.bind(0, "127.0.0.1", resolve));
const server = http.createServer((req, res) => { requests.push(req.url); res.end("unexpected"); });
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
const address = `http://127.0.0.1:${server.address().port}`;
const win = new electron.BrowserWindow({ show: false, width: 500, height: 400,
webPreferences: { sandbox: true, nodeIntegration: false, contextIsolation: true } });
let blockedNavigations = 0;
win.webContents.on("will-frame-navigate", (event) => {
if (!event.isMainFrame) { blockedNavigations++; event.preventDefault(); }
});
await win.loadURL("data:text/html," + encodeURIComponent(`<!doctype html><style>iframe{position:absolute;inset:0;width:100%;height:100%;border:0} #overlay{position:absolute;z-index:5;left:20px;top:20px;background:red;width:120px;height:60px}</style><iframe id="view" sandbox="allow-scripts"></iframe><div id="overlay">Host menu</div><script>window.messages=[];window.addEventListener('message',e=>{if(e.source===document.querySelector('iframe').contentWindow)window.messages.push(e.data)})</script>`));
const child = `<!doctype html><meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; connect-src 'none'; img-src data:; frame-src 'none'; form-action 'none'"><style>body{background:#325b90;color:white}</style><p>Plugin UI</p><script>
let parentBlocked=false, storageBlocked=false;
try { void parent.document.body; } catch { parentBlocked=true }
try { void localStorage.length; } catch { storageBlocked=true }
fetch('${address}/fetch').catch(()=>{});
try { const peer=new RTCPeerConnection({iceServers:[{urls:'stun:127.0.0.1:${udp.address().port}'}]}); peer.createDataChannel('probe'); peer.createOffer().then(offer=>peer.setLocalDescription(offer)); } catch {}
const img=new Image(); img.src='${address}/image';
parent.postMessage({parentBlocked,storageBlocked,node:typeof require,webrtc:typeof RTCPeerConnection},'*');
window.addEventListener('message',e=>{if(e.data==='navigate') location.href='${address}/navigation'});
</script>`;
const escaped = JSON.stringify(child);
await win.webContents.executeJavaScript(`document.querySelector('#view').srcdoc=${escaped}`);
const value = await win.webContents.executeJavaScript(`new Promise((resolve,reject)=>{const end=Date.now()+5000;const poll=()=>{if(window.messages.length)return resolve({message:window.messages[0],top:document.elementFromPoint(30,30).id});if(Date.now()>end)return reject(new Error('timeout'));setTimeout(poll,10)};poll()})`);
assert.equal(value.message.parentBlocked, true);
assert.equal(value.message.storageBlocked, true);
assert.equal(value.message.node, "undefined");
assert.equal(value.top, "overlay");
await win.webContents.executeJavaScript(`document.querySelector('#view').contentWindow.postMessage('navigate','*')`);
await new Promise((resolve) => setTimeout(resolve, 1200));
assert.equal(requests.length, 0, `unexpected direct network requests: ${requests}`);
assert.ok(blockedNavigations > 0, "frame navigation must be intercepted");
console.log(`FRAME_PROOF parent+storage isolated, fetch and image denied, host overlay on top; WebRTC constructor: ${value.message.webrtc}; local UDP packets: ${udpPackets}`);
win.destroy(); server.close(); udp.close(); electron.app.exit(0);
}).catch((error) => { console.error(error); electron.app.exit(1); });
}
@@ -0,0 +1,16 @@
"use strict";
const { contextBridge, ipcRenderer } = require("electron");
const listen = (channel, callback) => { const handler = (_event, value) => callback(value); ipcRenderer.on(channel, handler); return () => ipcRenderer.removeListener(channel, handler); };
contextBridge.exposeInMainWorld("netcatty", {
getPluginRuntimeStatus: () => ipcRenderer.invoke("netcatty:plugins:status"),
getPluginContributions: (query) => ipcRenderer.invoke("netcatty:plugins:contributions", query),
updatePluginSetting: (pluginId, settingId, value, scopeId) => ipcRenderer.invoke("netcatty:plugins:update-setting", { pluginId, settingId, value, scopeId }),
resetPluginSetting: (pluginId, settingId, scopeId) => ipcRenderer.invoke("netcatty:plugins:reset-setting", { pluginId, settingId, scopeId }),
onPluginContributionsChanged: (callback) => listen("netcatty:plugins:contributions-changed", callback),
getPluginViewResource: (payload) => ipcRenderer.invoke("netcatty:plugins:host-ui-resource", payload),
openHostDrawnPluginView: (payload) => ipcRenderer.invoke("netcatty:plugins:host-ui-open", payload),
closeHostDrawnPluginView: (instanceId) => ipcRenderer.invoke("netcatty:plugins:host-ui-close", { instanceId }),
sendHostDrawnPluginViewEvent: (instanceId, event) => ipcRenderer.invoke("netcatty:plugins:host-ui-event", { instanceId, event }),
onHostDrawnPluginViewUpdate: (callback) => listen("netcatty:plugins:host-ui-update", callback),
onPluginViewClosed: (callback) => listen("netcatty:plugins:view-closed", callback),
});
@@ -78,8 +78,7 @@
"en": "Starter: Greeting Panel",
"zh-CN": "入门:问候面板"
},
"location": "modal",
"entry": "panel.html"
"location": "modal"
}
]
},
@@ -1,7 +1,7 @@
{
"builderSha256": "57199cc6a5d30f5f356adb072d51de9e2283dac6e26c647a4404e4c2261c4a70",
"catalogSha256": "47e967516437996e00eac8fbe97699baa72439ba1bf7ae791731e4964bb13774",
"indexSha256": "af2e953e3c631b36a0e712806d0dcfb0a058fd02f2051025eddded8cc115d461",
"catalogSha256": "85a6b5efbe4fca52d2bce0ae7074b9d33ac876e29a41ae9a6a28be5c7387c8a9",
"indexSha256": "f8f81e2f86fd459e061b38e05b8fff3b21b4e660ad06bbd65129f542764c2062",
"plugins": [
{
"artifact": null,
@@ -85,8 +85,7 @@
"en": "Starter: Greeting Panel",
"zh-CN": "入门:问候面板"
},
"location": "modal",
"entry": "panel.html"
"location": "modal"
}
]
},
@@ -1,20 +1,20 @@
{
"catalog": {
"path": "/snapshots/53e5189d2cb58a02826931d5d6321ad3e556b924ef80d38636dc9e66a7df8c55/catalog.json",
"sha256": "47e967516437996e00eac8fbe97699baa72439ba1bf7ae791731e4964bb13774",
"size": 5739
"path": "/snapshots/dd5c2e20defbbf2126541de40d8f59d5a77f146c83e0e0cda8d3c4054a472988/catalog.json",
"size": 5704,
"sha256": "85a6b5efbe4fca52d2bce0ae7074b9d33ac876e29a41ae9a6a28be5c7387c8a9"
},
"delivery": {
"path": "/snapshots/53e5189d2cb58a02826931d5d6321ad3e556b924ef80d38636dc9e66a7df8c55/delivery.json",
"sha256": "53e5189d2cb58a02826931d5d6321ad3e556b924ef80d38636dc9e66a7df8c55",
"size": 1869
"path": "/snapshots/dd5c2e20defbbf2126541de40d8f59d5a77f146c83e0e0cda8d3c4054a472988/delivery.json",
"size": 1869,
"sha256": "dd5c2e20defbbf2126541de40d8f59d5a77f146c83e0e0cda8d3c4054a472988"
},
"index": {
"path": "/snapshots/53e5189d2cb58a02826931d5d6321ad3e556b924ef80d38636dc9e66a7df8c55/index.json",
"sha256": "af2e953e3c631b36a0e712806d0dcfb0a058fd02f2051025eddded8cc115d461",
"size": 7726
"path": "/snapshots/dd5c2e20defbbf2126541de40d8f59d5a77f146c83e0e0cda8d3c4054a472988/index.json",
"size": 7683,
"sha256": "f8f81e2f86fd459e061b38e05b8fff3b21b4e660ad06bbd65129f542764c2062"
},
"registryCommit": "32578ae27ff86f0151511478d698a1cdc12fb2e0",
"schemaVersion": 1,
"snapshotId": "53e5189d2cb58a02826931d5d6321ad3e556b924ef80d38636dc9e66a7df8c55"
"snapshotId": "dd5c2e20defbbf2126541de40d8f59d5a77f146c83e0e0cda8d3c4054a472988"
}
@@ -2299,6 +2299,10 @@
},
"valueSchema": {
"$ref": "#/$defs/JsonValue"
},
"group": {
"$ref": "#/$defs/LocalizedText",
"description": "Host-rendered section heading. Settings and groups retain declaration order; no custom markup or styles."
}
},
"additionalProperties": false
@@ -2438,7 +2442,6 @@
"panel",
"tab",
"modal",
"settings",
"vault-page",
"terminal-sidebar"
]
@@ -2449,6 +2452,221 @@
"TerminalSidebarFeature": {
"const": "views.terminal-sidebar"
},
"PluginViewResourceRequest": {
"type": "object",
"required": [
"instanceId",
"path"
],
"properties": {
"instanceId": {
"type": "string",
"minLength": 1,
"maxLength": 128
},
"path": {
"$ref": "#/$defs/RelativePackagePath"
}
},
"additionalProperties": false
},
"PluginViewResourceResult": {
"type": "object",
"required": [
"url"
],
"properties": {
"url": {
"type": "string",
"maxLength": 5592500,
"pattern": "^data:image/png;base64,"
}
},
"additionalProperties": false
},
"HostDrawnViewsFeature": {
"const": "views.host-drawn"
},
"HostDrawnViewMessage": {
"oneOf": [
{
"type": "object",
"required": [
"kind",
"instanceId"
],
"properties": {
"kind": {
"const": "host-ui.open"
},
"instanceId": {
"type": "string",
"minLength": 1,
"maxLength": 128
},
"stateScope": {
"type": "string",
"maxLength": 128
}
},
"additionalProperties": false
},
{
"type": "object",
"required": [
"kind",
"instanceId"
],
"properties": {
"kind": {
"const": "host-ui.close"
},
"instanceId": {
"type": "string",
"minLength": 1,
"maxLength": 128
}
},
"additionalProperties": false
},
{
"type": "object",
"required": [
"kind",
"instanceId",
"sequence",
"mutations",
"css"
],
"properties": {
"kind": {
"const": "host-ui.update"
},
"instanceId": {
"type": "string",
"minLength": 1,
"maxLength": 128
},
"sequence": {
"type": "integer",
"minimum": 1,
"maximum": 9007199254740991
},
"acknowledgedEvent": {
"type": "integer",
"minimum": 0,
"maximum": 9007199254740991
},
"mutations": {
"type": "array",
"maxItems": 2048,
"items": {
"$ref": "#/$defs/JsonValue"
}
},
"css": {
"type": "string",
"maxLength": 65536
}
},
"additionalProperties": false
},
{
"type": "object",
"required": [
"kind",
"instanceId",
"event"
],
"properties": {
"kind": {
"const": "host-ui.event"
},
"instanceId": {
"type": "string",
"minLength": 1,
"maxLength": 128
},
"event": {
"$ref": "#/$defs/HostDrawnViewEvent"
}
},
"additionalProperties": false
}
]
},
"HostDrawnViewEvent": {
"type": "object",
"required": [
"nodeId",
"type"
],
"properties": {
"nodeId": {
"type": "string",
"minLength": 1,
"maxLength": 80
},
"type": {
"enum": [
"click",
"input",
"change",
"keydown",
"keyup",
"focusin",
"focusout",
"compositionstart",
"compositionupdate",
"compositionend",
"dismiss",
"submit",
"reset"
]
},
"value": {
"type": "string",
"maxLength": 8192
},
"checked": {
"type": "boolean"
},
"eventId": {
"type": "integer",
"minimum": 1,
"maximum": 9007199254740991
},
"data": {
"type": "string",
"maxLength": 8192
},
"key": {
"type": "string",
"maxLength": 64
},
"ctrlKey": {
"type": "boolean"
},
"metaKey": {
"type": "boolean"
},
"altKey": {
"type": "boolean"
},
"shiftKey": {
"type": "boolean"
},
"values": {
"type": "array",
"maxItems": 128,
"items": {
"type": "string",
"maxLength": 8192
}
}
},
"additionalProperties": false
},
"PluginViewFocusEvent": {
"type": "object",
"required": [
@@ -2471,8 +2689,7 @@
"required": [
"id",
"title",
"location",
"entry"
"location"
],
"properties": {
"id": {
@@ -2484,9 +2701,6 @@
"location": {
"$ref": "#/$defs/ViewLocation"
},
"entry": {
"$ref": "#/$defs/RelativePackagePath"
},
"icon": {
"$ref": "#/$defs/IconReference"
},
@@ -2495,10 +2709,6 @@
},
"when": {
"$ref": "#/$defs/ContextKeyExpression"
},
"retainContextWhenHidden": {
"type": "boolean",
"default": false
}
},
"additionalProperties": false
@@ -8377,6 +8587,45 @@
"maxLength": 98304
}
}
},
"HostDrawnViewOpenRequest": {
"type": "object",
"additionalProperties": false,
"required": [
"viewId",
"instanceId",
"scopeId"
],
"properties": {
"viewId": {
"type": "string",
"minLength": 1,
"maxLength": 256
},
"instanceId": {
"type": "string",
"minLength": 1,
"maxLength": 128
},
"scopeId": {
"type": "string",
"minLength": 1,
"maxLength": 256
},
"context": {
"type": "object",
"maxProperties": 64,
"additionalProperties": {
"type": [
"string",
"number",
"boolean",
"null"
],
"maxLength": 8192
}
}
}
}
}
}
@@ -0,0 +1,53 @@
"use strict";
if (!process.versions.electron) {
require("node:test")("built DOM example views", { skip: "requires Electron and fixture server" }, () => {});
} else {
const assert = require("node:assert/strict");
const fs = require("node:fs"); const os = require("node:os"); const path = require("node:path");
const electron = require("electron");
const { createPluginHostService } = require("./hostService.cjs");
const { registerPluginBridge } = require("./pluginBridge.cjs");
const { PLUGIN_PROTOCOL_SCHEME } = require("./constants.cjs");
const appRoot = path.resolve(__dirname, "../.."); const userData = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-host-ui-"));
electron.app.setPath("userData", userData); electron.app.on("window-all-closed", () => {});
electron.protocol.registerSchemesAsPrivileged([{ scheme: PLUGIN_PROTOCOL_SCHEME, privileges: { standard: true, secure: true, supportFetchAPI: true, codeCache: true } }]);
void electron.app.whenReady().then(async () => {
const runtimeAppRoot = path.join(userData, "runtime-app"); const runtimeDirectory = path.join(runtimeAppRoot, "electron/plugins/runtime");
for (const name of ["plugin-contract", "plugin-sdk"]) fs.cpSync(path.join(appRoot, "node_modules/@netcatty", name, "dist"), path.join(runtimeAppRoot, "node_modules/@netcatty", name, "dist"), { recursive: true });
fs.cpSync(path.join(__dirname, "runtime"), runtimeDirectory, { recursive: true });
const service = createPluginHostService({ app: { getPath: (key) => electron.app.getPath(key), getVersion: () => "0.0.0", getAppPath: () => appRoot }, electron, appRoot: runtimeAppRoot, runtimeDirectory, supportedFeatures: ["views.host-drawn"],
requestPermissionDecision: async (request) => ({ requestId: request.requestId, decision: "allow", scope: "application" }) });
await service.manager.initialize();
const { buildPluginPackage } = await import("@netcatty/plugin-cli");
for (const sample of ['hello-world-v2', 'hello-vault', 'starter-netcatty', 'session-scratchpad', 'vault-scratchpad', 'settings-view-fixture']) {
const archive = path.join(userData, `${sample}.ncpkg`);
await buildPluginPackage(path.join(appRoot, 'examples/plugins', sample, '.package'), archive);
await service.manager.install(archive, { enable: true });
}
await service.contributionService.setEnvironment({ locale: 'zh-CN', theme: 'dark', reducedMotion: false, highContrast: false, themeTokens: {} });
const owner = new electron.BrowserWindow({ show: false, width: 800, height: 600, webPreferences: { sandbox: true, contextIsolation: true, preload: path.join(__dirname, "fixtures/hostReactPreload.cjs") } });
registerPluginBridge(electron.ipcMain, { ...service, isTrustedSender: (event) => event.sender === owner.webContents && event.senderFrame === owner.webContents.mainFrame });
const views = ['vault', 'sidebar', 'tab', 'aside', 'panel', 'modal'].map((name) => `com.netcatty.hello-world-v2.${name}`);
views.push('com.netcatty.hello-vault.page', 'com.netcatty.starter.panel', 'com.netcatty.session-scratchpad.page', 'com.netcatty.vault-scratchpad.page', 'com.netcatty.settings-view-fixture.panel');
for (const id of views) { console.log('opening', id);
await owner.loadURL(`http://localhost:5182/scripts/fixtures/host-plugin-react.html?id=${id}`);
await owner.webContents.executeJavaScript(`new Promise((resolve, reject) => { const end = Date.now() + 12000; const check = () => {
const shadow = window.testPluginShadow;
if (shadow?.querySelector('h1')) return resolve(true);
if (document.body.dataset.failure || Date.now() > end) return reject(new Error('Example failed: ' + document.body.innerText));
setTimeout(check, 30);
}; check(); })`);
if (id.startsWith('com.netcatty.hello-world-v2')) {
await owner.webContents.executeJavaScript(`window.testPluginShadow.getElementById('button').click()`);
await owner.webContents.executeJavaScript(`new Promise((resolve,reject) => { const end = Date.now() + 5000; const check = () => {
if (window.testPluginShadow.getElementById('result').textContent.includes('1 次')) return resolve(true);
if (Date.now() > end) return reject(new Error('Greeting did not update')); setTimeout(check,20);
}; check(); })`);
}
assert.equal(owner.contentView.children.length, 0, id);
console.log('HOST_DOM_EXAMPLE_OK', id);
}
owner.destroy(); await service.manager.shutdown(); fs.rmSync(userData, { recursive: true, force: true, maxRetries: 8, retryDelay: 100 });
console.log("HOST_DOM_EXAMPLES_OK"); electron.app.exit(0);
}).catch((error) => { console.error(error); electron.app.exit(1); });
}
@@ -0,0 +1,24 @@
"use strict";
if (!process.versions.electron) {
require("node:test")("host drawn plugin CSS", { skip: "requires Electron" }, () => {});
} else {
const assert = require("node:assert/strict");
const electron = require("electron");
const { validatePluginStylesheet } = require("../../domain/pluginViewStylePolicy.cjs");
electron.app.on("window-all-closed", () => {});
void electron.app.whenReady().then(async () => {
const win = new electron.BrowserWindow({ show: false, width: 500, height: 350,
webPreferences: { sandbox: true, nodeIntegration: false, contextIsolation: true } });
await win.loadURL("data:text/html," + encodeURIComponent(`<!doctype html><style>body{margin:0} #surface{position:relative;isolation:isolate;contain:paint;overflow:hidden;width:300px;height:200px} #overlay{position:absolute;top:20px;left:20px;z-index:10;width:100px;height:50px;background:red}</style><p id="outside">Host</p><div id="surface"></div><div id="overlay">Host menu</div>`));
const css = validatePluginStylesheet('.layout{display:grid;grid-template-columns:1fr 2fr;gap:10px}.card{color:rgb(0,128,0);padding:20px} p{color:blue}');
const result = await win.webContents.executeJavaScript(`(() => {
const root=document.querySelector('#surface').attachShadow({mode:'closed'});
const style=document.createElement('style');style.textContent=${JSON.stringify(css)};
const panel=document.createElement('div');panel.className='layout';
const text=document.createElement('p');text.className='card';text.textContent='Plugin';panel.append(text);root.append(style,panel);
return { layout:getComputedStyle(panel).display, inner:getComputedStyle(text).color, outer:getComputedStyle(document.querySelector('#outside')).color, top:document.elementFromPoint(30,30).id };
})()`);
assert.deepEqual(result, { layout: 'grid', inner: 'rgb(0, 128, 0)', outer: 'rgb(0, 0, 0)', top: 'overlay' });
win.destroy(); console.log('HOST_DRAWN_STYLE_OK'); electron.app.exit(0);
}).catch((error) => { console.error(error); electron.app.exit(1); });
}
+149
View File
@@ -0,0 +1,149 @@
"use strict";
const { createHash } = require("node:crypto");
const { createDefinitionValidator } = require("./contractValidator.cjs");
const { validatePluginMutationBatch } = require("../../domain/pluginRemoteTreePolicy.cjs");
const { validatePluginStylesheet } = require("../../domain/pluginViewStylePolicy.cjs");
const validateMessage = createDefinitionValidator("HostDrawnViewMessage");
const validateOpen = createDefinitionValidator("HostDrawnViewOpenRequest");
const validateEvent = createDefinitionValidator("HostDrawnViewEvent");
/** Owns logical views only: never creates a native view or exposes plugin code to the host. */
class HostDrawnViewHost {
constructor({ electron, contributionService, resources, isLocked = () => false, subscribeLock }) {
this.resources = resources;
this.electron = electron; this.contributions = contributionService; this.isLocked = isLocked;
this.instances = new Map(); this.opening = new Map(); this.closed = false;
this.messageSubscription = contributionService.onDidPostViewMessage((event) => this.receive(event));
this.changeSubscription = contributionService.onDidChange?.((event) => {
for (const instance of [...this.instances.values()]) {
if (["plugin-disabled", "runtime-stopped", "runtime-error", "runtime-quarantined"].includes(event.reason) && event.pluginId === instance.pluginId) {
void this.close(instance.id, undefined, event.reason);
} else {
try { this.contributions.assertViewActivationCurrent(instance.activation, { context: instance.context }); }
catch { void this.close(instance.id, undefined, "contribution-unavailable"); }
}
}
});
this.unsubscribeLock = subscribeLock?.((state) => {
if (!state.locked) return;
for (const opening of this.opening.values()) opening.cancelled = true;
for (const id of [...this.instances.keys()]) void this.close(id, undefined, "app-locked");
});
}
owned(id, sender) {
const instance = this.instances.get(id);
if (!instance) throw new Error("Plugin view was not found");
if (sender && instance.sender !== sender) throw new Error("Plugin view belongs to another window");
return instance;
}
async open(payload, sender) {
if (!validateOpen(payload)) throw new TypeError("Invalid plugin view request");
const owner = this.electron.BrowserWindow.fromWebContents(sender);
if (this.closed || this.isLocked() || !owner || owner.isDestroyed() || owner.webContents !== sender) throw new Error("Plugin view owner unavailable");
const id = payload.instanceId;
if (this.instances.has(id) || this.opening.has(id)) throw new Error("Plugin view already exists");
const ownedCount = [...this.instances.values()].filter((instance) => instance.sender === sender).length
+ [...this.opening.values()].filter((pending) => pending.sender === sender).length;
if (ownedCount >= 32 || this.instances.size + this.opening.size >= 128) throw new Error("Plugin view limit exceeded");
if (typeof payload.scopeId !== "string" || payload.scopeId.length < 1 || payload.scopeId.length > 256) throw new TypeError("Invalid plugin view scope");
const opening = { cancelled: false, owner, sender };
const cancel = () => { opening.cancelled = true; };
const navigating = (details, _url, isInPlace, isMainFrame) => {
if ((details?.isMainFrame ?? isMainFrame) !== false && (details?.isSameDocument ?? isInPlace) !== true) cancel();
};
owner.once("closed", cancel); sender.on("render-process-gone", cancel); sender.on("did-start-navigation", navigating);
this.opening.set(id, opening);
const ensure = () => {
if (opening.cancelled || this.closed || this.isLocked() || owner.isDestroyed() || sender.isDestroyed?.()) throw new Error("Plugin view opening was cancelled");
};
try {
const { plugin, view, identity } = await this.contributions.activateView(payload.viewId, { context: payload.context });
ensure();
if (!identity?.runtimeId) throw new Error("Plugin view is not host-drawn");
const activation = { pluginId: plugin.id, pluginVersion: plugin.activeVersion, viewId: view.id, runtimeId: identity.runtimeId };
this.contributions.assertViewActivationCurrent(activation, { context: payload.context });
const instance = { id, sender, owner, pluginId: plugin.id, viewId: view.id, activation,
context: structuredClone(payload.context ?? {}), identity, sequence: 0, rateStart: Date.now(), rateCount: 0, ownerClosed: () => { void this.close(id, undefined, "owner-closed"); },
rendererGone: () => { void this.close(id, undefined, "renderer-gone"); }, navigating: null };
instance.navigating = (details, _url, isInPlace, isMainFrame) => {
if ((details?.isMainFrame ?? isMainFrame) !== false && (details?.isSameDocument ?? isInPlace) !== true) instance.rendererGone();
};
this.instances.set(id, instance);
owner.once("closed", instance.ownerClosed); sender.on("render-process-gone", instance.rendererGone); sender.on("did-start-navigation", instance.navigating);
instance.startPromise = this.contributions.runtimeSupervisor.notify(plugin.id, "plugin.view.message", { viewId: view.id, message: { kind: "host-ui.open", instanceId: id, stateScope: createHash("sha256").update(payload.scopeId).digest("hex") } }, { expectedIdentity: identity });
await instance.startPromise;
ensure();
this.contributions.assertViewActivationCurrent(activation, { context: payload.context });
if (this.instances.get(id) !== instance) throw new Error("Plugin view opening was cancelled");
return { instanceId: id };
} catch (error) {
await this.close(id, undefined, "open-failed");
throw error;
} finally {
this.opening.delete(id); owner.removeListener("closed", cancel); sender.removeListener("render-process-gone", cancel); sender.removeListener("did-start-navigation", navigating);
}
}
receive(event) {
if (!event) return;
const message = event.message;
if (!validateMessage(message) || message.kind !== "host-ui.update") return;
const instance = this.instances.get(message.instanceId);
if (!instance || instance.pluginId !== event.pluginId || instance.viewId !== event.viewId || this.isLocked()) return;
try {
this.contributions.assertViewActivationCurrent(instance.activation, { context: instance.context });
if (message.sequence !== instance.sequence + 1) throw new Error("Invalid view update sequence");
const now = Date.now();
if (now - instance.rateStart >= 1000) { instance.rateStart = now; instance.rateCount = 0; }
if (++instance.rateCount > 120) throw new Error("Plugin view update rate exceeded");
validatePluginMutationBatch(message.mutations); validatePluginStylesheet(message.css);
instance.sequence = message.sequence;
if (!instance.sender.isDestroyed?.()) instance.sender.send("netcatty:plugins:host-ui-update", { viewId: instance.viewId, ...message });
} catch { void this.close(instance.id, undefined, "invalid-update"); }
}
async resource(id, resource, sender) {
const instance = this.owned(id, sender);
if (this.isLocked() || !this.resources) throw new Error("Plugin images unavailable");
const { plugin } = this.contributions.assertViewActivationCurrent(instance.activation, { context: instance.context });
instance.resources ??= new Map();
if (instance.resources.has(resource)) return instance.resources.get(resource);
if (instance.resources.size >= 32) throw new Error("Plugin image limit exceeded");
const pending = this.resources.image(plugin, resource).then((result) => {
if (this.isLocked() || this.instances.get(id) !== instance) throw new Error("Plugin view closed");
this.contributions.assertViewActivationCurrent(instance.activation, { context: instance.context });
instance.resourceBytes = (instance.resourceBytes ?? 0) + result.url.length;
if (instance.resourceBytes > 8 * 1024 * 1024) throw new Error("Plugin image budget exceeded");
return result;
});
instance.resources.set(resource, pending);
return pending;
}
async event(id, event, sender) {
const instance = this.owned(id, sender);
if (this.isLocked() || !validateEvent(event)) throw new TypeError("Invalid plugin view event");
this.contributions.assertViewActivationCurrent(instance.activation, { context: instance.context });
await this.contributions.runtimeSupervisor.notify(instance.pluginId, "plugin.view.message", { viewId: instance.viewId,
message: { kind: "host-ui.event", instanceId: id, event } }, { expectedIdentity: instance.identity });
}
async close(id, sender, reason = sender ? "renderer-requested" : "host-requested") {
const opening = this.opening.get(id);
if (opening) { if (sender && opening.sender !== sender) throw new Error("Plugin view belongs to another window"); opening.cancelled = true; }
const instance = this.instances.get(id); if (!instance) return;
if (sender && instance.sender !== sender) throw new Error("Plugin view belongs to another window");
this.instances.delete(id);
instance.owner.removeListener("closed", instance.ownerClosed); instance.sender.removeListener("render-process-gone", instance.rendererGone);
instance.sender.removeListener("did-start-navigation", instance.navigating);
if (!instance.sender.isDestroyed?.()) instance.sender.send("netcatty:plugins:view-closed", { instanceId: id, pluginId: instance.pluginId, viewId: instance.viewId, reason });
try {
await instance.startPromise?.catch(() => {});
await this.contributions.runtimeSupervisor.notify(instance.pluginId, "plugin.view.message", { viewId: instance.viewId,
message: { kind: "host-ui.close", instanceId: id } }, { expectedIdentity: instance.identity });
} catch { /* runtime may already have stopped */ }
}
async shutdown() {
this.closed = true; for (const opening of this.opening.values()) opening.cancelled = true;
await Promise.all([...this.instances.keys()].map((id) => this.close(id, undefined, "shutdown")));
this.messageSubscription?.dispose(); this.changeSubscription?.dispose(); this.unsubscribeLock?.();
}
}
module.exports = { HostDrawnViewHost };
@@ -0,0 +1,78 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const { EventEmitter } = require("node:events");
const { HostDrawnViewHost } = require("./hostDrawnViewHost.cjs");
function fixture(resources) {
const sender = new EventEmitter(); sender.sent = []; sender.send = (channel, value) => sender.sent.push([channel, value]); sender.isDestroyed = () => false;
const other = new EventEmitter(); const owner = new EventEmitter(); owner.webContents = sender; owner.isDestroyed = () => false;
const identity = { pluginId: "com.test.plugin", pluginVersion: "1.0.0", runtimeId: "run", runtimeKind: "browser", securityPrincipal: "local" };
let onMessage, onChange, locked = false, valid = true; const notifications = [];
const contributions = {
onDidPostViewMessage(fn) { onMessage = fn; return { dispose() {} }; }, onDidChange(fn) { onChange = fn; return { dispose() {} }; },
async activateView() { return { plugin: { id: identity.pluginId, activeVersion: identity.pluginVersion }, view: { id: "com.test.plugin.view" }, identity }; },
assertViewActivationCurrent() { if (!valid) throw new Error("stale"); return { plugin: { id: identity.pluginId } }; },
runtimeSupervisor: { async notify(...args) { notifications.push(args); } },
};
const host = new HostDrawnViewHost({ electron: { BrowserWindow: { fromWebContents: (s) => s === sender ? owner : null } }, contributionService: contributions, resources, isLocked: () => locked });
return { host, sender, other, owner, notifications, message: (message, overrides = {}) => onMessage({ pluginId: identity.pluginId, viewId: "com.test.plugin.view", message, ...overrides }), change: (event) => onChange(event), lock() { locked = true; }, stale() { valid = false; } };
}
const open = (f) => f.host.open({ viewId: "com.test.plugin.view", instanceId: "instance-1", scopeId: "scope", context: {} }, f.sender);
const update = (sequence = 1) => ({ kind: "host-ui.update", instanceId: "instance-1", sequence, css: '.x{display:grid}', mutations: [[0, "~", { id: "1", type: 1, element: "div", attributes: {}, children: [] }, 0]] });
test("logical views route validated updates only to their owning window", async () => {
const f = fixture(); assert.deepEqual(await open(f), { instanceId: "instance-1" });
f.message(update(), { pluginId: "com.evil.plugin" }); assert.equal(f.sender.sent.length, 0);
f.message(update()); assert.equal(f.sender.sent[0][0], "netcatty:plugins:host-ui-update");
await assert.rejects(f.host.event("instance-1", { nodeId: "1", type: "click" }, f.other), /another window/);
await f.host.event("instance-1", { nodeId: "1", type: "click" }, f.sender);
assert.equal(f.notifications.at(-1)[2].message.kind, "host-ui.event");
await f.host.close("instance-1", f.sender);
assert.equal(f.notifications.at(-1)[2].message.kind, "host-ui.close");
f.message(update(2)); assert.equal(f.sender.sent.filter(([channel]) => channel === "netcatty:plugins:host-ui-update").length, 1);
await f.host.shutdown();
});
test("invalid updates and replay close the logical view without forwarding them", async () => {
for (const message of [update(2), { ...update(), css: '@import "https://example.invalid";' }, { ...update(), mutations: [[0, "~", { id: "1", type: 1, element: "script", attributes: {}, children: [] }, 0]] }]) {
const f = fixture(); await open(f); f.message(message);
assert.equal(f.sender.sent.some(([channel]) => channel === "netcatty:plugins:host-ui-update"), false);
assert.equal(f.host.instances.size, 0); await f.host.shutdown();
}
});
test("withdrawal and lock reject events and close the view", async () => {
const f = fixture(); await open(f); f.lock();
await assert.rejects(f.host.event("instance-1", { nodeId: "1", type: "click" }, f.sender), /Invalid/);
f.stale(); f.change({ reason: "context-key" }); assert.equal(f.host.instances.size, 0);
await f.host.shutdown();
});
test("package resources require the same window and survive no close or revocation race", async () => {
let complete; let calls = 0;
const f = fixture({ image: async () => { calls++; return new Promise((resolve) => { complete = resolve; }); } });
await open(f);
await assert.rejects(f.host.resource('instance-1', 'mark.svg', f.other), /another window/);
const pending = f.host.resource('instance-1', 'mark.svg', f.sender);
const duplicate = f.host.resource('instance-1', 'mark.svg', f.sender);
assert.equal(calls, 1);
await f.host.close('instance-1', f.sender);
complete({ url: 'data:image/png;base64,AAAA' });
await assert.rejects(pending, /closed/); await assert.rejects(duplicate, /closed/);
await f.host.shutdown();
});
test("disable and owner navigation invalidate every retained view", async () => {
for (const reason of ['plugin-disabled', 'runtime-stopped', 'runtime-error', 'runtime-quarantined']) {
const f = fixture(); await open(f); f.change({ reason, pluginId: 'com.test.plugin' });
assert.equal(f.host.instances.size, 0); await f.host.shutdown();
}
const f = fixture(); await open(f); f.sender.emit('did-start-navigation', { isMainFrame: true, isSameDocument: false });
assert.equal(f.host.instances.size, 0); await f.host.shutdown();
});
test("updates are rate limited and resource caches are bounded", async () => {
const f = fixture({ image: async () => ({ url: 'data:image/png;base64,AAAA' }) }); await open(f);
for (let i = 0; i < 32; i++) await f.host.resource('instance-1', `image-${i}.png`, f.sender);
await assert.rejects(f.host.resource('instance-1', 'extra.png', f.sender), /limit/);
for (let i = 1; i <= 121; i++) f.message({ ...update(i), mutations: [] });
assert.equal(f.host.instances.size, 0); await f.host.shutdown();
});
+49
View File
@@ -0,0 +1,49 @@
"use strict";
if (!process.versions.electron) {
require("node:test")("host-rendered React plugin", { skip: "requires Electron" }, () => {});
} else {
const assert = require("node:assert/strict");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const electron = require("electron");
const { createPluginHostService } = require("./hostService.cjs");
const { PLUGIN_PROTOCOL_SCHEME } = require("./constants.cjs");
const appRoot = path.resolve(__dirname, "../..");
const userData = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-host-react-"));
electron.app.setPath("userData", userData); electron.app.on("window-all-closed", () => {});
electron.protocol.registerSchemesAsPrivileged([{ scheme: PLUGIN_PROTOCOL_SCHEME,
privileges: { standard: true, secure: true, supportFetchAPI: true, corsEnabled: false, codeCache: true } }]);
void electron.app.whenReady().then(async () => {
const runtimeAppRoot = path.join(userData, "runtime-app");
const runtimeDirectory = path.join(runtimeAppRoot, "electron/plugins/runtime");
for (const name of ["plugin-contract", "plugin-sdk"]) fs.cpSync(path.join(appRoot, "node_modules/@netcatty", name, "dist"), path.join(runtimeAppRoot, "node_modules/@netcatty", name, "dist"), { recursive: true });
fs.cpSync(path.join(__dirname, "runtime"), runtimeDirectory, { recursive: true });
const service = createPluginHostService({ app: electron.app, electron, appRoot: runtimeAppRoot, runtimeDirectory, supportedFeatures: ["views.host-drawn"],
requestPermissionDecision: async (request) => ({ requestId: request.requestId, decision: "allow", scope: "application" }) });
await service.manager.initialize();
const { buildPluginPackage } = await import("@netcatty/plugin-cli");
const archive = path.join(userData, "hello.ncpkg"); await buildPluginPackage(path.join(appRoot, "examples/plugins/hello-react-host"), archive);
await service.manager.install(archive, { enable: true });
const owner = new electron.BrowserWindow({ show: false, width: 800, height: 600 }); await owner.loadURL("data:text/html,<body>Test host</body>");
const messages = []; const send = owner.webContents.send.bind(owner.webContents);
owner.webContents.send = (channel, payload) => { messages.push({ channel, payload }); send(channel, payload); };
const host = service.hostDrawnViewHost; const id = "react-test-instance";
await host.open({ viewId: "com.netcatty.hello-react-host.vault", instanceId: id, scopeId: "smoke", context: { "netcatty.surface": "vault-page" } }, owner.webContents);
const wait = async (predicate) => {
const deadline = Date.now() + 10000;
while (!predicate()) { if (Date.now() > deadline) throw new Error(`Timed out waiting for plugin update: ${JSON.stringify(messages)}`); await new Promise((resolve) => setTimeout(resolve, 20)); }
};
const find = (node, name) => node?.element === name ? node : node?.children?.map((child) => find(child, name)).find(Boolean);
const getButton = () => messages.filter((item) => item.channel === 'netcatty:plugins:host-ui-update').flatMap((item) => item.payload.mutations).map((mutation) => find(mutation[2], 'button')).find(Boolean);
await wait(() => getButton());
const button = getButton();
assert.ok(button, "React button should be sent as plain data");
await host.event(id, { nodeId: button.id, type: "click" }, owner.webContents);
await wait(() => /你好,朋友/.test(JSON.stringify(messages)));
assert.match(JSON.stringify(messages), /你好,朋友/);
assert.equal(owner.contentView.children.length, 0, "host-drawn page must not attach a browser view");
await host.close(id, owner.webContents); owner.destroy(); await service.manager.shutdown();
fs.rmSync(userData, { recursive: true, force: true, maxRetries: 8, retryDelay: 100 }); console.log("HOST_REACT_RUNTIME_OK"); electron.app.exit(0);
}).catch((error) => { console.error(error); electron.app.exit(1); });
}
+145
View File
@@ -0,0 +1,145 @@
"use strict";
if (!process.versions.electron) {
require("node:test")("host rendered React UI", { skip: "requires Electron and fixture server" }, () => {});
} else {
const assert = require("node:assert/strict");
const fs = require("node:fs"); const os = require("node:os"); const path = require("node:path");
const electron = require("electron");
const { createPluginHostService } = require("./hostService.cjs");
const { registerPluginBridge } = require("./pluginBridge.cjs");
const { PLUGIN_PROTOCOL_SCHEME } = require("./constants.cjs");
const appRoot = path.resolve(__dirname, "../.."); const userData = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-host-ui-"));
electron.app.setPath("userData", userData); electron.app.on("window-all-closed", () => {});
electron.protocol.registerSchemesAsPrivileged([{ scheme: PLUGIN_PROTOCOL_SCHEME, privileges: { standard: true, secure: true, supportFetchAPI: true, codeCache: true } }]);
void electron.app.whenReady().then(async () => {
const runtimeAppRoot = path.join(userData, "runtime-app"); const runtimeDirectory = path.join(runtimeAppRoot, "electron/plugins/runtime");
for (const name of ["plugin-contract", "plugin-sdk"]) fs.cpSync(path.join(appRoot, "node_modules/@netcatty", name, "dist"), path.join(runtimeAppRoot, "node_modules/@netcatty", name, "dist"), { recursive: true });
fs.cpSync(path.join(__dirname, "runtime"), runtimeDirectory, { recursive: true });
const service = createPluginHostService({ app: electron.app, electron, appRoot: runtimeAppRoot, runtimeDirectory, supportedFeatures: ["views.host-drawn"],
requestPermissionDecision: async (request) => ({ requestId: request.requestId, decision: "allow", scope: "application" }) });
await service.manager.initialize();
const { buildPluginPackage } = await import("@netcatty/plugin-cli"); const archive = path.join(userData, "hello.ncpkg");
await buildPluginPackage(path.join(appRoot, "examples/plugins/hello-react-host"), archive); await service.manager.install(archive, { enable: true });
const owner = new electron.BrowserWindow({ show: false, width: 800, height: 600, webPreferences: { sandbox: true, contextIsolation: true, preload: path.join(__dirname, "fixtures/hostReactPreload.cjs") } });
const inputEvents = []; const dispatch = service.hostDrawnViewHost.event.bind(service.hostDrawnViewHost);
service.hostDrawnViewHost.event = async (...args) => { inputEvents.push(args[1]); return dispatch(...args); };
registerPluginBridge(electron.ipcMain, { ...service, isTrustedSender: (event) => event.sender === owner.webContents && event.senderFrame === owner.webContents.mainFrame });
for (const name of ['vault', 'sidebar', 'tab', 'aside', 'panel', 'modal']) {
await owner.loadURL(`http://localhost:5182/scripts/fixtures/host-plugin-react.html?view=${name}`);
const result = await owner.webContents.executeJavaScript(`new Promise((resolve,reject)=>{const end=Date.now()+12000;const check=()=>{const shadow=window.testPluginShadow;const button=shadow?.querySelector('button');if(button){const overlay=document.elementFromPoint(30,30);const input=shadow.querySelector('input');Object.getOwnPropertyDescriptor(HTMLInputElement.prototype,'value').set.call(input,'Catty');input.dispatchEvent(new Event('input',{bubbles:true}));setTimeout(()=>{button.click();setTimeout(()=>resolve({heading:shadow.querySelector('h1')?.textContent, text:shadow.textContent, top:overlay?.id, buttonColor:getComputedStyle(button).backgroundColor, hostHasBrowser:document.querySelector('iframe')!==null}),250)},250);return}if(document.body.dataset.failure||Date.now()>end)return reject(new Error('View did not render: '+document.body.innerText));setTimeout(check,30)};check()})`);
assert.ok(inputEvents.some((event) => event.type === "input" && event.value === "Catty"), name);
assert.equal(result.heading, "你好,世界!", name); assert.match(result.text, /你好,Catty!/, name); assert.equal(result.top, "host-overlay", name); assert.notEqual(result.buttonColor, "rgba(0, 0, 0, 0)", name); assert.equal(result.hostHasBrowser, false, name);
assert.equal(owner.contentView.children.length, 0, name);
const controls = await owner.webContents.executeJavaScript(`(async () => {
const root = window.testPluginShadow;
const wait = async (fn) => { for (let i = 0; i < 150; i++) { if (fn()) return; await new Promise(r => setTimeout(r, 20)); } throw new Error('Control update timeout: ' + root.querySelector('img')?.outerHTML + ' popup=' + document.querySelector('[data-plugin-view-popover]') + ' ' + root.textContent); };
const reset = [...root.querySelectorAll('button')].find(b => b.textContent === '填入示例'); reset.click();
const text = root.querySelector('input[type=text]'); const checkbox = root.querySelector('input[type=checkbox]'); const select = root.querySelector('select'); const note = root.querySelector('textarea');
await wait(() => text.value === '朋友' && checkbox.checked && select.value === 'green' && note.value === '你好,Netcatty');
checkbox.click(); select.value = 'blue'; select.dispatchEvent(new Event('change', { bubbles: true }));
note.dispatchEvent(new CompositionEvent('compositionstart', { bubbles: true, data: '' }));
Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value').set.call(note, '中文输入');
note.dispatchEvent(new Event('input', { bubbles: true })); note.dispatchEvent(new CompositionEvent('compositionend', { bubbles: true, data: '中文输入' }));
await wait(() => root.querySelector('[data-testid=controls]').textContent.includes('已停用 · blue · 中文输入'));
await wait(() => root.querySelector('img').naturalWidth === 32);
root.getElementById('menu-trigger').click(); await wait(() => document.querySelector('[data-plugin-view-popover]'));
const popup = document.querySelector('[data-plugin-view-popover]');
return { image: root.querySelector('img').src.startsWith('data:image/png;base64,'), popup: popup.getBoundingClientRect().width > 0 };
})()`);
assert.equal(controls.image, true, name); assert.equal(controls.popup, true, name);
await owner.webContents.executeJavaScript("document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))");
await new Promise((resolve) => setTimeout(resolve, 150));
assert.equal(await owner.webContents.executeJavaScript(`document.querySelector('[data-plugin-view-popover]') === null`), true, name);
const settingsModal = await owner.webContents.executeJavaScript(`(async () => {
const wait = async (fn) => { for (let i = 0; i < 200; i++) { if (fn()) return; await new Promise(r => setTimeout(r, 20)); } throw new Error('Settings entry timeout: ' + document.body.innerText); };
await wait(() => document.querySelector('[data-plugin-settings-entry] button'));
const gear = document.querySelector('[data-plugin-settings-entry] button');
const oldSurface = document.querySelector('[data-plugin-remote-surface]');
const oldRoot = window.testPluginShadow;
const eventCount = oldRoot.querySelector('textarea').value;
gear.focus(); gear.click();
await wait(() => document.querySelector('[data-plugin-settings-dialog] input[aria-label="默认称呼"]'));
const dialog = document.querySelector('[data-plugin-settings-dialog]');
const rect = dialog.getBoundingClientRect();
const result = { bounded: rect.top > 0 && rect.bottom < innerHeight && rect.width < innerWidth,
separate: !oldSurface.contains(dialog), topmost: dialog.contains(document.elementFromPoint(rect.x + 30, rect.y + 30)),
noPluginContent: !dialog.querySelector('[data-plugin-remote-surface]'), pageStillMounted: oldSurface.isConnected };
document.activeElement.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
await wait(() => !document.querySelector('[data-plugin-settings-dialog]'));
result.retained = oldSurface.isConnected && oldRoot === window.testPluginShadow && oldRoot.querySelector('textarea').value === eventCount;
result.focusReturned = document.activeElement === gear;
return result;
})()`);
assert.deepEqual(settingsModal, { bounded: true, separate: true, topmost: true, noPluginContent: true, pageStillMounted: true, retained: true, focusReturned: true }, name);
if (name === 'vault') {
const eventCount = inputEvents.length;
await owner.webContents.executeJavaScript(`const hostInput = document.getElementById('host-private-input'); hostInput.value = 'host-only-canary'; hostInput.dispatchEvent(new Event('input', { bubbles: true }));`);
await new Promise(resolve => setTimeout(resolve, 80));
assert.equal(inputEvents.length, eventCount, 'host input is never forwarded to plugin');
await owner.webContents.executeJavaScript("document.getElementById('toggle-plugin').click()");
await new Promise(resolve => setTimeout(resolve, 80));
assert.equal(await owner.webContents.executeJavaScript("document.querySelector('[data-plugin-remote-surface]') === null"), true);
await owner.webContents.executeJavaScript("document.getElementById('toggle-plugin').click()");
await new Promise(resolve => setTimeout(resolve, 200));
assert.equal(await owner.webContents.executeJavaScript("window.testPluginShadow.querySelector('textarea').value"), '中文输入', 'hide/reopen retains state');
await owner.webContents.executeJavaScript("window.testPluginShadow.getElementById('menu-trigger').click()");
await new Promise(resolve => setTimeout(resolve, 120));
assert.equal(await owner.webContents.executeJavaScript("!!document.querySelector('[data-plugin-view-popover]')"), true);
await owner.webContents.executeJavaScript(`const dialog = document.createElement('div'); dialog.id = 'trusted-dialog'; dialog.setAttribute('role', 'dialog'); dialog.setAttribute('aria-modal', 'true'); dialog.style.cssText = 'position:fixed;inset:0;z-index:50;background:white'; document.body.append(dialog);`);
await new Promise(resolve => setTimeout(resolve, 120));
assert.equal(await owner.webContents.executeJavaScript("document.querySelector('[data-plugin-view-popover]') === null && document.elementFromPoint(30,30).id === 'trusted-dialog'"), true, 'trusted dialogs dismiss plugin popovers');
await owner.webContents.executeJavaScript("document.getElementById('trusted-dialog').remove()");
}
if (name === 'vault' && process.env.NETCATTY_HOST_UI_SCREENSHOT) fs.writeFileSync(process.env.NETCATTY_HOST_UI_SCREENSHOT, (await owner.capturePage()).toPNG());
}
// Exercise the production settings form through the real bridge and database.
await owner.loadURL('http://localhost:5182/scripts/fixtures/host-plugin-react.html?view=settings-form');
const editSettings = await owner.webContents.executeJavaScript(`(async () => {
const wait = async (fn) => { for (let i = 0; i < 300; i++) { if (fn()) return; await new Promise(r => setTimeout(r, 20)); } throw new Error('Settings form timeout: ' + document.body.innerText + ' state=' + document.querySelector('[data-settings-fixture]')?.outerHTML); };
const input = () => document.querySelector('input[aria-label="默认称呼"]');
await wait(() => input());
const setText = (text) => { input().focus(); Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value').set.call(input(), text); input().dispatchEvent(new Event('input', { bubbles: true })); input().dispatchEvent(new FocusEvent('focusout', { bubbles: true })); };
setText('设置验收');
await wait(() => !input().disabled);
const select = document.querySelector('select[aria-label="强调颜色"]');
select.value = 'green'; select.dispatchEvent(new Event('change', { bubbles: true }));
await wait(() => !select.disabled);
return { groups: [...document.querySelectorAll('h4')].map(h => h.textContent), customSurface: !!document.querySelector('[data-plugin-remote-surface]') };
})()`);
assert.deepEqual(editSettings.groups, ['问候', '外观', '内容']);
assert.equal(editSettings.customSurface, false);
const pluginId = 'com.netcatty.hello-react-host';
// Renderer save transitions may settle in the next task; verify stored values before reload.
for (let i = 0; i < 100; i++) {
if (await service.contributionService.getSetting(pluginId, `${pluginId}.name`) === '设置验收' && await service.contributionService.getSetting(pluginId, `${pluginId}.color`) === 'green') break;
await new Promise(resolve => setTimeout(resolve, 25));
}
assert.equal(await service.contributionService.getSetting(pluginId, `${pluginId}.name`), '设置验收');
assert.equal(await service.contributionService.getSetting(pluginId, `${pluginId}.color`), 'green');
await owner.reload();
await new Promise(resolve => owner.webContents.once('did-finish-load', resolve));
const persistence = await owner.webContents.executeJavaScript(`new Promise((resolve,reject) => { const end = Date.now()+12000; const check = () => { const input = document.querySelector('input[aria-label="默认称呼"]'); if (input) return resolve({name:input.value,color:document.querySelector('select[aria-label="强调颜色"]').value}); if(Date.now()>end)return reject(new Error('Settings reload timeout'));setTimeout(check,30); }; check(); })`);
assert.deepEqual(persistence, { name: '设置验收', color: 'green' });
if (process.env.NETCATTY_SETTINGS_SCREENSHOT) fs.writeFileSync(process.env.NETCATTY_SETTINGS_SCREENSHOT, (await owner.capturePage()).toPNG());
const invalid = await owner.webContents.executeJavaScript(`(async () => {
const input = document.querySelector('input[aria-label="默认称呼"]'); input.focus();
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype,'value').set.call(input, 'x'.repeat(41)); input.dispatchEvent(new Event('input',{bubbles:true})); input.dispatchEvent(new FocusEvent('focusout',{bubbles:true}));
for(let i=0;i<200;i++){if(document.querySelector('[role=alert]'))return true;await new Promise(r=>setTimeout(r,20));}return false;
})()`);
assert.equal(invalid, true, 'invalid setting is reported in its host row');
assert.equal(await service.contributionService.getSetting(pluginId, `${pluginId}.name`), '设置验收');
await owner.loadURL('http://localhost:5182/scripts/fixtures/host-plugin-react.html?view=vault');
const readPage = () => owner.webContents.executeJavaScript(`new Promise((resolve,reject)=>{const end=Date.now()+12000;const check=()=>{const input=window.testPluginShadow?.querySelector('input');if(input?.value==='设置验收')return resolve(window.testPluginShadow.querySelector('select').value);if(Date.now()>end)return reject(new Error('Persisted preference was not applied'));setTimeout(check,30)};check()})`);
assert.equal(await readPage(), 'green');
await service.contributionService.updateSetting(pluginId, `${pluginId}.name`, '即时更新');
const live = await owner.webContents.executeJavaScript(`new Promise((resolve,reject)=>{const end=Date.now()+8000;const check=()=>{if(window.testPluginShadow?.querySelector('input')?.value==='即时更新' && window.testPluginShadow?.querySelector('img')?.naturalWidth===32)return resolve(true);if(Date.now()>end)return reject(new Error('Setting change did not reach open page'));setTimeout(check,30)};check()})`);
assert.equal(live, true);
await service.contributionService.resetSetting(pluginId, `${pluginId}.name`);
assert.equal(await service.contributionService.getSetting(pluginId, `${pluginId}.name`), '');
owner.destroy(); await service.manager.shutdown(); fs.rmSync(userData, { recursive: true, force: true, maxRetries: 8, retryDelay: 100 });
console.log("HOST_REACT_UI_OK"); electron.app.exit(0);
}).catch((error) => { console.error(error); electron.app.exit(1); });
}
+6 -14
View File
@@ -15,6 +15,8 @@ const { PluginCompanionSupervisor } = require("./companionSupervisor.cjs");
const { PluginCredentialBroker, assertLeaseParams } = require("./credentialBroker.cjs");
const { PluginCredentialCatalog } = require("./credentialCatalog.cjs");
const { PluginContributionService } = require("./contributionService.cjs");
const { PluginViewResources } = require("./viewResourceService.cjs");
const { HostDrawnViewHost } = require("./hostDrawnViewHost.cjs");
const { PluginContributionIconService } = require("./contributionIconService.cjs");
const { PluginFilesystemBroker } = require("./filesystemBroker.cjs");
const { createDefaultPluginHostRpcRegistry } = require("./hostRpcRegistry.cjs");
@@ -41,7 +43,6 @@ const HOST_VAULT_FEATURE = require("./generated/plugin-contract.schema.json").$d
const DIRECTORY_ENUMERATION_FEATURE = require("./generated/plugin-contract.schema.json").$defs.FilesystemDirectoryEnumerationFeature.const;
const { PluginPermissionEngine } = require("./permissionEngine.cjs");
const { PluginProtocol } = require("./pluginProtocol.cjs");
const { PluginViewHost } = require("./pluginViewHost.cjs");
const {
RuntimeSupervisor,
assertStorageParams,
@@ -104,6 +105,7 @@ function createPluginHostService(options) {
const runtimeDirectory = options.runtimeDirectory ?? path.join(__dirname, "runtime");
const supportedFeatures = [...new Set(["terminal.input-drafts", "terminal.selection-sharing", "appearance.theme-packs", "metadata.brand-icon", BYTE_STREAM_FEATURE, ...PLUGIN_AI_CONTEXT_FEATURES, PLUGIN_AGENT_FEATURE, NETWORK_CREDENTIAL_LEASE_FEATURE, HOST_INTERACTION_FEATURE, HOST_VAULT_FEATURE, VAULT_PAGES_FEATURE, TERMINAL_SIDEBAR_FEATURE, INTERACTIVE_COMMAND_FEATURE, ...(options.supportedFeatures ?? [])])]
.filter((feature) => feature !== DIRECTORY_ENUMERATION_FEATURE);
if (!supportedFeatures.includes("views.host-drawn")) supportedFeatures.push("views.host-drawn");
if (typeof options.openPluginDirectoryHandle === "function") supportedFeatures.push(DIRECTORY_ENUMERATION_FEATURE);
const database = new PluginDatabase(paths.database);
let interactionService;
@@ -474,17 +476,7 @@ function createPluginHostService(options) {
quotaManager.setViolationHandler((identity, error) => (
runtimeSupervisor.enforcePolicyViolation(identity, error)
));
const viewHost = options.electron.WebContentsView && options.electron.ipcMain
? new PluginViewHost({
electron: options.electron,
protocol,
packageStore,
database,
contributionService,
isLocked: options.isAppLocked,
subscribeLock: options.subscribeAppLock,
})
: null;
const hostDrawnViewHost = options.electron.ipcMain ? new HostDrawnViewHost({ electron: options.electron, contributionService, resources: new PluginViewResources({ electron: options.electron, packageStore }), isLocked: options.isAppLocked, subscribeLock: options.subscribeAppLock }) : null;
const themeService = new PluginThemeService({database,packageStore,contributionService});
manager = new PluginManager({
secretStore,
@@ -503,7 +495,7 @@ function createPluginHostService(options) {
terminalDataPipelineService?.shutdown();
extensionProviderService.shutdown();
credentialResolver.shutdown?.();
await viewHost?.shutdown();
await hostDrawnViewHost?.shutdown();
await companionSupervisor.shutdown();
leaseStore.shutdown();
permissionDecisionHost.shutdown();
@@ -549,7 +541,7 @@ function createPluginHostService(options) {
secretStore,
terminalProviderService,
terminalDataPipelineService,
viewHost,
hostDrawnViewHost,
};
} catch (error) {
try { permissionDecisionHost?.shutdown(); } catch {}
+13
View File
@@ -29,6 +29,19 @@ function transportContext() {
};
}
test("host-drawn views are available without a development gate", (context) => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-plugin-host-preview-"));
context.after(() => fs.rmSync(root, { recursive: true, force: true }));
const preview = createPluginHostService(createOptions(path.join(root, "preview")));
const normal = createPluginHostService({ ...createOptions(path.join(root, "normal")), env: {} });
context.after(() => { preview.database.close(); normal.database.close(); });
for (const service of [preview, normal]) {
const expected = true;
assert.equal(service.runtimeSupervisor.supportedFeatures.includes("views.host-drawn"), expected);
assert.equal(service.packageStore.supportedFeatures.includes("views.host-drawn"), expected);
}
});
test("host RPC registry configuration is complete before runtime initialization", (context) => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-plugin-host-service-"));
context.after(() => fs.rmSync(root, { recursive: true, force: true }));
+6 -1
View File
@@ -40,6 +40,11 @@ test("clean-checkout test entrypoints build plugin runtime workspaces first", ()
packageJson.scripts.pretest,
/^npm run build:plugin-runtime-deps(?: && npm run check:codebuddy-sdk-contract)?$/,
);
assert.equal(packageJson.scripts["pretest:plugin-runtime"], "npm run build:plugin-runtime-deps && npm run build --workspace @netcatty/example-starter-plugin");
assert.equal(packageJson.scripts["pretest:plugin-runtime"], "npm run build:plugin-runtime-deps && npm run build:plugin-view-examples");
assert.equal(packageJson.scripts["pretest:plugin-runtime:electron"], "npm run build:plugin-runtime-deps");
});
test("host rendering validators ship beside the main process", () => {
const config = require('../../electron-builder.config.cjs');
for (const file of ['domain/pluginRemoteTreePolicy.cjs', 'domain/pluginViewStylePolicy.cjs']) assert.ok(config.files.includes(file));
});
@@ -87,7 +87,7 @@ test("Settings-owned installed view can Ask on activation and commands without a
engines: { netcatty: ">=0.0.0", api: ">=0.1.0-internal <0.2.0" }, main: { browser: "browser.js" },
activationEvents: [`onView:${id}.panel`], permissions: { required: ["settings.read", "commands", "views"] },
contributes: { settings: [{ id: `${id}.greeting`, label: "Greeting", control: "text", scope: "application", default: "synthetic greeting" }],
commands: [{ id: `${id}.read`, title: "Read own setting" }], views: [{ id: `${id}.panel`, title: "Settings-owned panel", location: "panel", entry: "panel.html" }] } };
commands: [{ id: `${id}.read`, title: "Read own setting" }], views: [{ id: `${id}.panel`, title: "Settings-owned panel", location: "panel" }] } };
const staging = path.join(root, "package"); await fs.mkdir(staging);
await fs.writeFile(path.join(staging, "netcatty.plugin.json"), JSON.stringify(manifest));
await fs.writeFile(path.join(staging, "panel.html"), "<!doctype html><p>Synthetic view</p>");
@@ -99,25 +99,26 @@ test("Settings-owned installed view can Ask on activation and commands without a
const archive = path.join(root, "fixture.ncpkg"); await buildPluginPackage(staging, archive); await service.manager.install(archive, { enable: true });
const plugin = service.database.getActivePlugin(id); await service.permissionEngine.setMode(plugin, "settings.read", "ask");
const openedPrompt = nextPrompt(settings);
const opening = call(settings.webContents, "open-view", { viewId: `${id}.panel`, scopeId: "window:settings", bounds: { x: 0, y: 0, width: 300, height: 200 } });
const opening = call(settings.webContents, "host-ui-open", { viewId: `${id}.panel`, scopeId: "window:settings", instanceId: "settings-view" });
const prompt = await Promise.race([openedPrompt, opening.then(() => { throw new Error("Activation skipped its settings Ask"); })]);
assert.equal(prompt.permission, "settings.read"); assert.equal(promptCount(main), 0);
assert.equal(main.isFocused(), false, "Ask must not steal focus or close the originating view");
await call(settings.webContents, "permission-response", response(prompt));
const opened = await opening;
const instance = service.viewHost.instances.get(opened.instanceId);
assert.equal(instance.owner, settings); assert.equal(instance.state, "open");
const read = () => handlers.get("netcatty-plugin-view:execute-command")({ sender: instance.contents }, { command: `${id}.read` });
const instance = service.hostDrawnViewHost.instances.get(opened.instanceId);
assert.equal(instance.owner, settings); assert.equal(settings.children.length, 0);
const read = () => call(settings.webContents, "execute-command", { command: `${id}.read` });
const asked = nextPrompt(settings); const reading = read(); const commandPrompt = await asked;
await ready(main, false); // Another renderer's teardown cannot cancel Settings.
assert.equal(service.permissionDecisionHost.pending.size, 1);
const foreign = new Window(); foreign.show(); // Same app URL, but unmanaged.
await assert.rejects(ready(foreign), /sender is invalid/);
await assert.rejects(call(instance.contents, "permission-response", response(commandPrompt)), /Untrusted/);
const pluginSender = new Contents(); pluginSender.url = "netcatty-plugin://untrusted/index.html"; pluginSender.mainFrame.url = pluginSender.url;
await assert.rejects(call(pluginSender, "permission-response", response(commandPrompt)), /Untrusted/);
const childFrame = { url: settings.webContents.getURL(), parent: settings.webContents.mainFrame };
await assert.rejects(handlers.get("netcatty:plugins:permission-ready")({ sender: settings.webContents, senderFrame: childFrame }, { ready: true }), /frame/);
await assert.rejects(handlers.get("netcatty:plugins:permission-ready")({ sender: settings.webContents, senderFrame: null }, { ready: true }), /frame/);
await assert.rejects(handlers.get("netcatty:plugins:permission-response")({ sender: settings.webContents, senderFrame: childFrame }, response(commandPrompt)), /frame/);
await assert.rejects(handlers.get("netcatty:plugins:permission-ready")({ sender: settings.webContents, senderFrame: childFrame }, { ready: true }), /Untrusted|frame/);
await assert.rejects(handlers.get("netcatty:plugins:permission-ready")({ sender: settings.webContents, senderFrame: null }, { ready: true }), /Untrusted|frame/);
await assert.rejects(handlers.get("netcatty:plugins:permission-response")({ sender: settings.webContents, senderFrame: childFrame }, response(commandPrompt)), /Untrusted|frame/);
await call(settings.webContents, "permission-response", response(commandPrompt)); assert.equal(await reading, "synthetic greeting");
await ready(main); main.show(); main.focus();
const mainPrompt = nextPrompt(main); const mainRead = call(main.webContents, "execute-command", { command: `${id}.read` });
@@ -136,6 +137,6 @@ test("Settings-owned installed view can Ask on activation and commands without a
assert.equal(await call(settings.webContents, "permission-response", hiddenToken), false, "reopening Settings cannot revive an old decision");
const closingPrompt = nextPrompt(settings); const cancelled = read(); const rejected = assert.rejects(cancelled, /denied|cancel/i);
const stale = response(await closingPrompt); settings.destroy(); await rejected;
assert.equal(service.permissionDecisionHost.pending.size, 0); assert.equal(service.viewHost.instances.size, 0);
assert.equal(service.permissionDecisionHost.pending.size, 0); assert.equal(service.hostDrawnViewHost.instances.size, 0);
await assert.rejects(call(settings.webContents, "permission-response", stale), /focused unlocked|Untrusted/);
});
+20 -39
View File
@@ -5,7 +5,6 @@ const { randomUUID } = require("node:crypto");
const { createDefinitionValidator } = require("./contractValidator.cjs");
const validateManagedPluginId = createDefinitionValidator("PluginId");
const validateViewFocus = createDefinitionValidator("PluginViewFocusRequest");
const { raceWithAbort } = require("./rpcRouter.cjs");
const {
@@ -95,14 +94,9 @@ const CHANNELS = Object.freeze({
importerReleaseFile: "netcatty:plugins:importer-release-file",
importerParseFile: "netcatty:plugins:importer-parse-file",
importerProgress: "netcatty:plugins:importer-progress",
openView: "netcatty:plugins:open-view",
closeView: "netcatty:plugins:close-view",
focusView: "netcatty:plugins:focus-view",
setViewBounds: "netcatty:plugins:set-view-bounds",
setViewVisibility: "netcatty:plugins:set-view-visibility",
viewMessage: "netcatty:plugins:view-message",
viewMessagePosted: "netcatty:plugins:view-message-posted",
viewClosed: "netcatty:plugins:view-closed",
hostDrawnOpen: "netcatty:plugins:host-ui-open",
hostDrawnClose: "netcatty:plugins:host-ui-close",
hostDrawnEvent: "netcatty:plugins:host-ui-event",
getScopeCatalog: "netcatty:plugins:get-scope-catalog",
setScopeCatalog: "netcatty:plugins:set-scope-catalog",
scopeCatalogChanged: "netcatty:plugins:scope-catalog-changed",
@@ -214,6 +208,8 @@ function mergePluginScopeCatalogs(catalogs) {
function createTrustedPluginBridgeSender(options = {}) {
const devServerOrigin = options.devServerUrl ? new URL(options.devServerUrl).origin : null;
return (event) => {
// The app may embed untrusted frames; a trusted URL alone is not authority.
if (event?.sender?.mainFrame && event.senderFrame !== event.sender.mainFrame) return false;
const senderUrl = event?.senderFrame?.url || event?.sender?.getURL?.() || "";
try {
const url = new URL(senderUrl);
@@ -241,7 +237,7 @@ function registerPluginBridge(ipcMain, options) {
: DEFAULT_CONNECTION_STATUS_POLL_MS;
const getTerminalWorkerManager = options.getTerminalWorkerManager ?? (() => null);
const selectImporterFile = options.selectImporterFile;
const viewHost = options.viewHost;
const hostDrawnViewHost = options.hostDrawnViewHost;
const isTrustedSender = options.isTrustedSender;
const defaultScopeCatalog = normalizePluginScopeCatalog({ device: [{ id: "device", label: "This device" }] });
const scopeCatalogs = new Map();
@@ -835,7 +831,6 @@ function registerPluginBridge(ipcMain, options) {
handle(CHANNELS.setEnvironment, async (_activeManager, payload) => {
if (!contributionService) throw new Error("Plugin contributions are unavailable");
await contributionService.setEnvironment(payload ?? {});
viewHost?.setEnvironment?.(payload ?? {});
return null;
});
handlePassive(CHANNELS.terminalProviders, [], async (_activeManager, payload) => {
@@ -1674,35 +1669,22 @@ function registerPluginBridge(ipcMain, options) {
await handle.close();
}
}));
handle(CHANNELS.openView, async (_activeManager, payload, event) => {
if (!viewHost) throw new Error("Plugin views are unavailable");
return viewHost.open(payload, event.sender);
handle(CHANNELS.hostDrawnOpen, async (_activeManager, payload, event) => {
if (!hostDrawnViewHost) throw new Error("Host-drawn plugin views are unavailable");
return hostDrawnViewHost.open(payload, event.sender);
});
handle(CHANNELS.closeView, async (_activeManager, payload, event) => {
if (!viewHost) throw new Error("Plugin views are unavailable");
await viewHost.close(payload?.instanceId, event.sender);
return null;
handle(CHANNELS.hostDrawnClose, async (_activeManager, payload, event) => {
if (!hostDrawnViewHost) throw new Error("Host-drawn plugin views are unavailable");
await hostDrawnViewHost.close(payload?.instanceId, event.sender); return null;
});
handle(CHANNELS.focusView, async (_activeManager, payload, event) => {
if (!viewHost) throw new Error("Plugin views are unavailable");
if (!validateViewFocus(payload)) throw new TypeError("Plugin view focus request is invalid");
viewHost.focus(payload.instanceId, event.sender);
return null;
handle("netcatty:plugins:host-ui-resource", async (_activeManager, payload, event) => {
if (!hostDrawnViewHost) throw new Error("Plugin views unavailable");
if (!createDefinitionValidator("PluginViewResourceRequest")(payload)) throw new TypeError("Invalid plugin resource request");
return hostDrawnViewHost.resource(payload.instanceId, payload.path, event.sender);
});
handle(CHANNELS.setViewBounds, async (_activeManager, payload, event) => {
if (!viewHost) throw new Error("Plugin views are unavailable");
viewHost.setBounds(payload?.instanceId, payload?.bounds, event.sender);
return null;
});
handle(CHANNELS.setViewVisibility, async (_activeManager, payload, event) => {
if (!viewHost) throw new Error("Plugin views are unavailable");
viewHost.setVisible(payload?.instanceId, payload?.visible, event.sender);
return null;
});
handle(CHANNELS.viewMessage, async (_activeManager, payload, event) => {
if (!viewHost) throw new Error("Plugin views are unavailable");
await viewHost.postMessage(payload?.instanceId, payload?.message, event.sender);
return null;
handle(CHANNELS.hostDrawnEvent, async (_activeManager, payload, event) => {
if (!hostDrawnViewHost) throw new Error("Host-drawn plugin views are unavailable");
await hostDrawnViewHost.event(payload?.instanceId, payload?.event, event.sender); return null;
});
handlePassive(CHANNELS.getScopeCatalog, () => currentScopeCatalog(), async () => currentScopeCatalog());
handlePassive(CHANNELS.setScopeCatalog, null, async (_activeManager, payload, event) => {
@@ -1716,8 +1698,7 @@ function registerPluginBridge(ipcMain, options) {
});
options.catalogService?.onDidChange?.((event) => options.broadcast?.(CHANNELS.catalogChanged, event));
contributionService?.onDidChange?.((event) => options.broadcast?.(CHANNELS.contributionsChanged, event));
contributionService?.onDidPostViewMessage?.((event) => options.broadcast?.(CHANNELS.viewMessagePosted, event));
viewHost?.onDidClose?.((event) => options.broadcast?.(CHANNELS.viewClosed, event));
}
module.exports = {
+17 -20
View File
@@ -101,6 +101,15 @@ test("plugin management bridge checks sender ownership before invoking manager",
);
});
test("plugin bridge refuses embedded frames even at an app URL", () => {
const trusted = createTrustedPluginBridgeSender({ devServerUrl: "http://localhost:5173" });
const mainFrame = { url: "http://localhost:5173/" };
const sender = { mainFrame };
assert.equal(trusted({ sender, senderFrame: mainFrame }), true);
assert.equal(trusted({ sender, senderFrame: { url: mainFrame.url } }), false);
assert.equal(trusted({ sender, senderFrame: null }), false);
});
test("author presentation is a trusted-app-only route and forwards no invented authority", async () => {
const ipcMain = createIpcMain(); const calls = [];
registerPluginBridge(ipcMain, { manager: { initialize: async () => {} }, isTrustedSender: createTrustedPluginBridgeSender({ devServerUrl: "http://localhost:5173" }),
@@ -160,28 +169,16 @@ test("plugin management availability follows asynchronous host initialization",
assert.equal(listCalls, 0);
});
test("plugin view host closures are broadcast to renderer windows", async () => {
const ipcMain = createIpcMain();
const broadcasts = [];
let closeListener;
test("plugin view events are routed with their owning sender and never globally broadcast", async () => {
const ipcMain = createIpcMain(); const calls = []; const broadcasts = []; const sender = {};
registerPluginBridge(ipcMain, {
manager: { initialize: async () => {} },
contributionService: {},
viewHost: {
onDidClose(listener) { closeListener = listener; return { dispose() {} }; },
},
env: { NETCATTY_PLUGIN_DEV: "1" },
isTrustedSender: () => true,
broadcast: (...args) => broadcasts.push(args),
manager: { initialize: async () => {} }, contributionService: {},
hostDrawnViewHost: { event: async (...args) => calls.push(args) },
isTrustedSender: () => true, broadcast: (...args) => broadcasts.push(args),
});
const event = {
instanceId: "view-1",
pluginId: "com.example.view",
viewId: "com.example.view.panel",
reason: "runtime-error",
};
closeListener(event);
assert.deepEqual(broadcasts, [[CHANNELS.viewClosed, event]]);
const event = { nodeId: "button", type: "click" };
await ipcMain.handlers.get(CHANNELS.hostDrawnEvent)({ sender }, { instanceId: "view-1", event });
assert.deepEqual(calls, [["view-1", event, sender]]); assert.deepEqual(broadcasts, []);
});
test("plugin contribution icon requests use the host-owned resolver", async () => {
+7 -30
View File
@@ -41,14 +41,17 @@ function decodeRequestPath(pathname) {
return segments;
}
async function readContainedFile(root, segments) {
async function readContainedFile(root, segments, maxBytes = Infinity) {
const candidate = path.resolve(root, ...segments);
if (!isPathInside(root, candidate)) throw new Error("Plugin resource escapes its root");
const [realRoot, realCandidate] = await Promise.all([fs.realpath(root), fs.realpath(candidate)]);
if (!isPathInside(realRoot, realCandidate)) throw new Error("Plugin resource follows a symlink outside its root");
const stats = await fs.lstat(realCandidate);
if (!stats.isFile() || stats.isSymbolicLink()) throw new Error("Plugin resource is not a regular file");
return { body: await fs.readFile(realCandidate), filePath: realCandidate };
if (stats.size > maxBytes) throw new Error('Plugin resource exceeds size limit');
const body = await fs.readFile(realCandidate);
if (body.length > maxBytes) throw new Error('Plugin resource exceeds size limit');
return { body, filePath: realCandidate };
}
function htmlForRuntime(token, moduleResources) {
@@ -111,23 +114,6 @@ class PluginProtocol {
};
}
registerView(options) {
const token = createRuntimeToken();
const entry = options.entry.split("/").map(encodeURIComponent).join("/");
this.registrations.set(token, {
token,
pluginId: options.pluginId,
packageRoot: options.packageRoot,
config: null,
kind: "view",
});
return {
token,
url: `${PLUGIN_PROTOCOL_SCHEME}://${token}/package/${entry}`,
dispose: () => this.registrations.delete(token),
};
}
async #handle(request) {
const headers = {
"Cache-Control": "no-store",
@@ -145,12 +131,7 @@ class PluginProtocol {
const registration = this.registrations.get(url.hostname);
if (!registration) return respond("Not Found", 404);
const segments = decodeRequestPath(url.pathname);
if (registration.kind === "view" && segments[0] !== "package") {
return respond("Not Found", 404);
}
const csp = registration.kind === "view"
? `default-src 'none'; script-src 'self' ${PLUGIN_PROTOCOL_SCHEME}:; connect-src 'none'; img-src 'self' ${PLUGIN_PROTOCOL_SCHEME}: data:; style-src 'self' ${PLUGIN_PROTOCOL_SCHEME}: 'unsafe-inline'; font-src 'self' ${PLUGIN_PROTOCOL_SCHEME}:; media-src 'none'; object-src 'none'; frame-src 'none'; worker-src 'none'; child-src 'none'; base-uri 'none'; form-action 'none'`
: `default-src 'none'; script-src 'self' ${PLUGIN_PROTOCOL_SCHEME}: 'nonce-${registration.token}'; connect-src 'self' ${PLUGIN_PROTOCOL_SCHEME}:; img-src 'self' ${PLUGIN_PROTOCOL_SCHEME}:; style-src 'none'; font-src 'none'; media-src 'none'; object-src 'none'; frame-src 'none'; worker-src 'none'; child-src 'none'; base-uri 'none'; form-action 'none'`;
const csp = `default-src 'none'; script-src 'self' ${PLUGIN_PROTOCOL_SCHEME}: 'nonce-${registration.token}'; connect-src 'self' ${PLUGIN_PROTOCOL_SCHEME}:; img-src 'self' ${PLUGIN_PROTOCOL_SCHEME}:; style-src 'none'; font-src 'none'; media-src 'none'; object-src 'none'; frame-src 'none'; worker-src 'none'; child-src 'none'; base-uri 'none'; form-action 'none'`;
if (segments.length === 1 && segments[0] === "index.html") {
return respond(htmlForRuntime(registration.token, this.moduleResources), 200, "text/html; charset=utf-8", {
"Content-Security-Policy": csp,
@@ -182,11 +163,7 @@ class PluginProtocol {
const file = await readContainedFile(root, fileSegments);
const contentType = CONTENT_TYPES.get(path.extname(file.filePath).toLowerCase())
?? "application/octet-stream";
const viewHeaders = registration.kind === "view" ? {
"Content-Security-Policy": csp,
"Permissions-Policy": "camera=(), microphone=(), geolocation=(), display-capture=(), usb=(), serial=(), hid=(), payment=(), fullscreen=(), clipboard-read=(), clipboard-write=()",
} : {};
return respond(file.body, 200, contentType, viewHeaders);
return respond(file.body, 200, contentType);
} catch {
return respond("Not Found", 404);
}
-46
View File
@@ -139,49 +139,3 @@ test("browser runtime import maps accept reviewed host modules without protocol
});
assert.equal(await moduleResponse.text(), "export const ui = true;\n");
});
test("custom views load packaged resources only with a network-denying document policy", async (context) => {
const roots = createRoots(context);
fs.writeFileSync(path.join(roots.packageRoot, "view.html"), "<!doctype html><script src=\"view.js\"></script>\n");
fs.writeFileSync(path.join(roots.packageRoot, "view.js"), "globalThis.ready = true;\n");
fs.writeFileSync(path.join(roots.packageRoot, "view.svg"), "<svg xmlns=\"http://www.w3.org/2000/svg\"></svg>\n");
let handler;
const protocol = new PluginProtocol(roots);
protocol.registerSession({
protocol: {
handle(_scheme, callback) { handler = callback; },
unhandle() {},
},
});
const registration = protocol.registerView({
pluginId: "com.example.view",
packageRoot: roots.packageRoot,
entry: "view.html",
});
const page = await handler({ method: "GET", url: registration.url });
assert.equal(page.status, 200);
assert.match(page.headers.get("content-security-policy"), /connect-src 'none'/u);
assert.match(page.headers.get("content-security-policy"), /frame-src 'none'/u);
assert.match(page.headers.get("permissions-policy"), /clipboard-write=\(\)/u);
const svgRegistration = protocol.registerView({
pluginId: "com.example.svg-view",
packageRoot: roots.packageRoot,
entry: "view.svg",
});
const svgPage = await handler({ method: "GET", url: svgRegistration.url });
assert.equal(svgPage.headers.get("content-type"), "image/svg+xml");
assert.match(svgPage.headers.get("content-security-policy"), /worker-src 'none'/u);
assert.match(svgPage.headers.get("permissions-policy"), /camera=\(\)/u);
assert.equal((await handler({
method: "GET",
url: `netcatty-plugin://${registration.token}/package/view.js`,
})).status, 200);
assert.equal((await handler({
method: "GET",
url: `netcatty-plugin://${registration.token}/__host/runtime/browserRuntime.mjs`,
})).status, 404);
assert.equal((await handler({
method: "GET",
url: `netcatty-plugin://${registration.token}/index.html`,
})).status, 404);
});
Loaded 100 of 227 files, more files were not shown because too many files have changed in this diff. Show more