mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-10-31 19:02:31 +00:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6ba1b7b34a | ||
|
|
c309a455ed | ||
|
|
39a0008876 | ||
|
|
f5a802d610 |
No files matched your search
@@ -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/
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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': 'Включённые плагины не предоставляют настройки.',
|
||||
|
||||
@@ -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': '当前没有已启用插件提供设置。',
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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: [] });
|
||||
});
|
||||
@@ -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; },
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>));
|
||||
|
||||
@@ -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); }
|
||||
}
|
||||
});
|
||||
@@ -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); }
|
||||
}
|
||||
});
|
||||
@@ -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); }
|
||||
}
|
||||
});
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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>)}</>;
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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,
|
||||
)}</>;
|
||||
}
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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 };
|
||||
@@ -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/);
|
||||
}
|
||||
});
|
||||
@@ -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 };
|
||||
@@ -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)));
|
||||
});
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -78,8 +78,7 @@
|
||||
"en": "Starter: Greeting Panel",
|
||||
"zh-CN": "入门:问候面板"
|
||||
},
|
||||
"location": "modal",
|
||||
"entry": "panel.html"
|
||||
"location": "modal"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -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); });
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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); });
|
||||
}
|
||||
@@ -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); });
|
||||
}
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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 }));
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
Reference in new issue
Block a user