feat(plugins): add JavaScript plugin system with overlay panel (#303)

This commit is contained in:
sudacode authored and GitHub committed 2026-10-31 19:09:54 -07:00
1 parent 90fc7cf940
commit a9a4c4f083
110 files changed
+5897 -344

No files matched your search

@@ -55,6 +55,7 @@ function makeCleanupDeps(calls: string[], overrides: Partial<CleanupDeps> = {}):
cleanupRemoteMediaWindows: record('cleanupRemoteMediaWindows'),
cleanupJellyfinSubtitleCache: record('cleanupJellyfinSubtitleCache'),
stopDiscordPresenceService: record('stopDiscordPresenceService'),
unloadPlugins: record('unloadPlugins'),
...overrides,
};
}
@@ -99,6 +100,47 @@ test('on will quit cleanup handler flushes before teardown and awaits stats befo
assert.ok(calls.indexOf('destroyImmersionTracker') < calls.indexOf('destroyAnkiIntegration'));
});
test('on will quit cleanup handler finishes plugin unload before any other teardown', async () => {
const calls: string[] = [];
let finishUnload!: () => void;
const cleanup = createOnWillQuitCleanupHandler(
makeCleanupDeps(calls, {
unloadPlugins: () => {
calls.push('unloadPlugins');
return new Promise<void>((resolve) => (finishUnload = resolve));
},
}),
);
let settled = false;
const pending = cleanup().then(() => {
settled = true;
});
await new Promise((resolve) => setImmediate(resolve));
assert.deepEqual(calls, ['unloadPlugins']);
assert.equal(settled, false);
finishUnload();
await pending;
assert.equal(settled, true);
assert.ok(calls.indexOf('destroyMpvSocket') > 0);
});
test('on will quit cleanup handler keeps tearing down when plugin unload fails', async () => {
const calls: string[] = [];
const cleanup = createOnWillQuitCleanupHandler(
makeCleanupDeps(calls, {
unloadPlugins: async () => {
throw new Error('unload failed');
},
}),
);
await assert.rejects(cleanup(), /unload failed/);
assert.ok(calls.includes('destroyMpvSocket'));
assert.ok(calls.includes('stopDiscordPresenceService'));
});
test('on will quit cleanup handler cleans subtitle caches when stopping remote session fails', async () => {
const calls: string[] = [];
const cleanup = createOnWillQuitCleanupHandler(
+9 -1
View File
@@ -59,8 +59,17 @@ export function createOnWillQuitCleanupHandler(deps: {
cleanupRemoteMediaWindows: () => void;
cleanupJellyfinSubtitleCache: () => void;
stopDiscordPresenceService: () => void;
unloadPlugins: () => void | Promise<void>;
}) {
return async (): Promise<void> => {
const cleanupErrors: unknown[] = [];
// Plugins deactivate before any teardown so they can still reach mpv and the overlay;
// the plugin host bounds how long that takes.
try {
await deps.unloadPlugins();
} catch (error) {
cleanupErrors.push(error);
}
deps.destroyTray();
deps.stopConfigHotReload();
deps.restorePreviousSecondarySubVisibility();
@@ -68,7 +77,6 @@ export function createOnWillQuitCleanupHandler(deps: {
deps.unregisterAllGlobalShortcuts();
deps.stopSubtitleWebsocket();
deps.stopTexthookerService();
const cleanupErrors: unknown[] = [];
const stopSyncAutoScheduler = Promise.resolve(deps.stopSyncAutoScheduler()).catch(
(error: unknown) => {
cleanupErrors.push(error);
@@ -52,6 +52,7 @@ function buildCleanupDeps(overrides: Partial<BuilderDeps> = {}) {
cleanupRemoteMediaWindows: noop,
cleanupJellyfinSubtitleCache: noop,
stopDiscordPresenceService: noop,
unloadPlugins: noop,
...overrides,
});
}
@@ -67,6 +67,7 @@ export function createOnWillQuitCleanupDeps(deps: {
cleanupRemoteMediaWindows: () => void;
cleanupJellyfinSubtitleCache: () => void;
stopDiscordPresenceService: () => void;
unloadPlugins: () => void | Promise<void>;
}) {
return {
destroyTray: () => deps.destroyTray(),
@@ -156,5 +157,6 @@ export function createOnWillQuitCleanupDeps(deps: {
cleanupRemoteMediaWindows: () => deps.cleanupRemoteMediaWindows(),
cleanupJellyfinSubtitleCache: () => deps.cleanupJellyfinSubtitleCache(),
stopDiscordPresenceService: () => deps.stopDiscordPresenceService(),
unloadPlugins: () => deps.unloadPlugins(),
};
}
+8 -43
View File
@@ -2,6 +2,11 @@ import { spawn } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import {
buildWindowsShimInvocation,
isWindowsCommandShim,
} from '../../core/utils/windows-command-shim';
export interface RunCommandResult {
exitCode: number | null;
stdout: string;
@@ -142,43 +147,7 @@ export function failureMessage(result: RunCommandResult, fallback: string): stri
}
function needsWindowsShell(command: string): boolean {
return process.platform === 'win32' && /\.(cmd|bat)$/i.test(command);
}
/*!
* Windows command escaping adapted from cross-spawn 7.0.6.
*
* The MIT License (MIT)
*
* Copyright (c) 2018 Made With MOXY Lda <hello@moxy.studio>
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
* THE SOFTWARE.
*/
const WINDOWS_SHELL_META_CHARACTERS = /([()\][%!^"`<>&|;, *?])/g;
// Quote for both cmd.exe and the Windows argv parser. The outer caret escapes
// are consumed by cmd, leaving the quoted argument unchanged for the command.
function escapeWindowsShellArgument(value: string): string {
const quotesEscaped = value
.replace(/(?=(\\+?)?)\1"/g, '$1$1\\"')
.replace(/(?=(\\+?)?)\1$/, '$1$1');
return `"${quotesEscaped}"`.replace(WINDOWS_SHELL_META_CHARACTERS, '^$1');
return process.platform === 'win32' && isWindowsCommandShim(command);
}
function createDefaultRunCommand(): RunCommand {
@@ -189,12 +158,8 @@ function createDefaultRunCommand(): RunCommand {
try {
const env = options.env ?? process.env;
if (useShell) {
const shellCommand = [
escapeWindowsShellArgument(command),
...args.map(escapeWindowsShellArgument),
].join(' ');
const commandProcessor = env.ComSpec ?? env.COMSPEC ?? process.env.ComSpec ?? 'cmd.exe';
child = spawn(commandProcessor, ['/d', '/s', '/v:off', '/c', `"${shellCommand}"`], {
const invocation = buildWindowsShimInvocation(command, args, env);
child = spawn(invocation.file, invocation.args, {
env,
windowsHide: false,
windowsVerbatimArguments: true,
+108 -95
View File
@@ -279,114 +279,127 @@ test('Windows managed runtime path is absolute, versioned, and injectable', () =
);
});
test('Windows managed launcher forwards arguments without a system Bun', async (t) => {
if (process.platform !== 'win32') return;
const root = workspace(t);
const appDirectory = path.join(root, 'Installed App');
const appPath = path.join(appDirectory, 'SubMiner.exe');
const launcherDirectory = path.join(appDirectory, 'resources', 'launcher');
const script = path.join(launcherDirectory, 'subminer.js');
fs.mkdirSync(launcherDirectory, { recursive: true });
fs.copyFileSync(process.execPath, appPath);
fs.writeFileSync(script, 'console.log(JSON.stringify(process.argv.slice(2)));');
fs.writeFileSync(path.join(launcherDirectory, 'version'), '1.0.0');
const options = {
platform: process.platform,
env: { ...process.env, PATH: '', LOCALAPPDATA: root },
bundledBunPath: process.execPath,
launcherResourcePath: script,
appExePath: appPath,
appVersion: '1.0.0',
localAppData: root,
getUserPath: () => '',
setUserPath: () => {},
broadcastEnvironmentChange: () => {},
};
const snapshot = await installLauncher(options);
assert.equal(snapshot.status, 'ready', snapshot.message ?? 'install failed');
const { getRunCommand } = await import('./command-line-launcher-deps');
const args = ['spaces here', 'a&b', 'p%TEMP%q', 'bang!z', 'say "hi"', '日本語'];
const result = await getRunCommand({})(snapshot.installPath!, args, { env: options.env });
assert.equal(result.exitCode, 0, result.stderr);
assert.deepEqual(JSON.parse(result.stdout), args);
});
// These copy and spawn the real bun.exe, which can outlast the default 5s on CI runners.
const WINDOWS_BUN_TEST_TIMEOUT_MS = 30_000;
test('Windows stages a new runtime version while the prior Bun executable is running', async (t) => {
if (process.platform !== 'win32') return;
const root = workspace(t);
const bundledDirectory = path.join(root, 'packaged', 'bun');
const bundledBunPath = path.join(bundledDirectory, 'bun.exe');
const launcherResourcePath = path.join(root, 'packaged', 'launcher', 'subminer');
fs.mkdirSync(path.join(bundledDirectory, 'licenses'), { recursive: true });
fs.mkdirSync(path.dirname(launcherResourcePath), { recursive: true });
fs.copyFileSync(process.execPath, bundledBunPath);
fs.writeFileSync(path.join(bundledDirectory, 'licenses', 'Bun-LICENSE.md'), 'license');
fs.writeFileSync(launcherResourcePath, 'console.log("launcher");');
test(
'Windows managed launcher forwards arguments without a system Bun',
{ timeout: WINDOWS_BUN_TEST_TIMEOUT_MS },
async (t) => {
if (process.platform !== 'win32') return;
const root = workspace(t);
const appDirectory = path.join(root, 'Installed App');
const appPath = path.join(appDirectory, 'SubMiner.exe');
const launcherDirectory = path.join(appDirectory, 'resources', 'launcher');
const script = path.join(launcherDirectory, 'subminer.js');
fs.mkdirSync(launcherDirectory, { recursive: true });
fs.copyFileSync(process.execPath, appPath);
fs.writeFileSync(script, 'console.log(JSON.stringify(process.argv.slice(2)));');
fs.writeFileSync(path.join(launcherDirectory, 'version'), '1.0.0');
const options = {
platform: process.platform,
env: { ...process.env, PATH: '', LOCALAPPDATA: root },
bundledBunPath: process.execPath,
launcherResourcePath: script,
appExePath: appPath,
appVersion: '1.0.0',
localAppData: root,
getUserPath: () => '',
setUserPath: () => {},
broadcastEnvironmentChange: () => {},
};
const snapshot = await installLauncher(options);
assert.equal(snapshot.status, 'ready', snapshot.message ?? 'install failed');
const { getRunCommand } = await import('./command-line-launcher-deps');
const args = ['spaces here', 'a&b', 'p%TEMP%q', 'bang!z', 'say "hi"', '日本語'];
const result = await getRunCommand({})(snapshot.installPath!, args, { env: options.env });
assert.equal(result.exitCode, 0, result.stderr);
assert.deepEqual(JSON.parse(result.stdout), args);
},
);
const first = stageManagedLauncher({
platform: 'win32',
localAppData: root,
appVersion: '1.0.0',
bundledBunPath,
launcherResourcePath,
});
const running = spawn(first.bunPath, ['-e', 'setInterval(() => {}, 1000)']);
await new Promise<void>((resolve, reject) => {
running.once('spawn', resolve);
running.once('error', reject);
});
test(
'Windows stages a new runtime version while the prior Bun executable is running',
{ timeout: WINDOWS_BUN_TEST_TIMEOUT_MS },
async (t) => {
if (process.platform !== 'win32') return;
const root = workspace(t);
const bundledDirectory = path.join(root, 'packaged', 'bun');
const bundledBunPath = path.join(bundledDirectory, 'bun.exe');
const launcherResourcePath = path.join(root, 'packaged', 'launcher', 'subminer');
fs.mkdirSync(path.join(bundledDirectory, 'licenses'), { recursive: true });
fs.mkdirSync(path.dirname(launcherResourcePath), { recursive: true });
fs.copyFileSync(process.execPath, bundledBunPath);
fs.writeFileSync(path.join(bundledDirectory, 'licenses', 'Bun-LICENSE.md'), 'license');
fs.writeFileSync(launcherResourcePath, 'console.log("launcher");');
const expectedSecond = windowsManagedRuntimePaths({
platform: 'win32',
localAppData: root,
appVersion: '2.0.0',
});
try {
const second = stageManagedLauncher({
const first = stageManagedLauncher({
platform: 'win32',
localAppData: root,
appVersion: '2.0.0',
appVersion: '1.0.0',
bundledBunPath,
launcherResourcePath,
});
assert.notEqual(second.bunPath, first.bunPath);
assert.ok(fs.existsSync(second.bunPath));
cleanupOldWindowsManagedRuntimes({
const running = spawn(first.bunPath, ['-e', 'setInterval(() => {}, 1000)']);
await new Promise<void>((resolve, reject) => {
running.once('spawn', resolve);
running.once('error', reject);
});
const expectedSecond = windowsManagedRuntimePaths({
platform: 'win32',
localAppData: root,
appVersion: '2.0.0',
});
assert.ok(fs.existsSync(first.bunPath));
assert.ok(fs.existsSync(path.join(path.dirname(first.bunPath), 'licenses', 'Bun-LICENSE.md')));
assert.equal(
fs.readFileSync(
path.join(path.dirname(second.bunPath), 'licenses', 'Bun-LICENSE.md'),
'utf8',
),
'license',
);
} finally {
if (running.exitCode === null) {
const exited = new Promise<void>((resolve) => running.once('exit', () => resolve()));
running.kill();
await exited;
try {
const second = stageManagedLauncher({
platform: 'win32',
localAppData: root,
appVersion: '2.0.0',
bundledBunPath,
launcherResourcePath,
});
assert.notEqual(second.bunPath, first.bunPath);
assert.ok(fs.existsSync(second.bunPath));
cleanupOldWindowsManagedRuntimes({
platform: 'win32',
localAppData: root,
appVersion: '2.0.0',
});
assert.ok(fs.existsSync(first.bunPath));
assert.ok(
fs.existsSync(path.join(path.dirname(first.bunPath), 'licenses', 'Bun-LICENSE.md')),
);
assert.equal(
fs.readFileSync(
path.join(path.dirname(second.bunPath), 'licenses', 'Bun-LICENSE.md'),
'utf8',
),
'license',
);
} finally {
if (running.exitCode === null) {
const exited = new Promise<void>((resolve) => running.once('exit', () => resolve()));
running.kill();
await exited;
}
}
}
// Windows can keep bun.exe locked briefly after the exit event; cleanup
// skips locked runtimes, so retry the way the next launch would.
const firstDirectory = path.dirname(first.bunPath);
for (let attempt = 0; attempt < 30; attempt += 1) {
cleanupOldWindowsManagedRuntimes({
platform: 'win32',
localAppData: root,
appVersion: '2.0.0',
});
if (!fs.existsSync(firstDirectory)) break;
await new Promise((resolve) => setTimeout(resolve, 100));
}
assert.equal(fs.existsSync(firstDirectory), false);
assert.ok(fs.existsSync(expectedSecond.bunPath));
});
// Windows can keep bun.exe locked briefly after the exit event; cleanup
// skips locked runtimes, so retry the way the next launch would.
const firstDirectory = path.dirname(first.bunPath);
for (let attempt = 0; attempt < 30; attempt += 1) {
cleanupOldWindowsManagedRuntimes({
platform: 'win32',
localAppData: root,
appVersion: '2.0.0',
});
if (!fs.existsSync(firstDirectory)) break;
await new Promise((resolve) => setTimeout(resolve, 100));
}
assert.equal(fs.existsSync(firstDirectory), false);
assert.ok(fs.existsSync(expectedSecond.bunPath));
},
);
test('Windows cleanup removes an obsolete runtime with no Bun executable', (t) => {
if (process.platform !== 'win32') return;
@@ -1,23 +1,18 @@
import type { OverlayHostedModal } from '../../shared/ipc/contracts';
import type { OverlayModalRuntime } from '../overlay-runtime';
export function openOverlayHostedModal(
deps: {
ensureOverlayStartupPrereqs: () => void;
ensureOverlayWindowsReadyForVisibilityActions: () => void;
sendToActiveOverlayWindow: (
channel: string,
payload?: unknown,
runtimeOptions?: {
restoreOnModalClose?: OverlayHostedModal;
preferModalWindow?: boolean;
},
) => boolean;
sendToActiveOverlayWindow: OverlayModalRuntime['sendToActiveOverlayWindow'];
},
input: {
channel: string;
modal: OverlayHostedModal;
payload?: unknown;
preferModalWindow?: boolean;
signal?: AbortSignal;
},
): boolean {
deps.ensureOverlayStartupPrereqs();
@@ -25,6 +20,7 @@ export function openOverlayHostedModal(
return deps.sendToActiveOverlayWindow(input.channel, input.payload, {
restoreOnModalClose: input.modal,
preferModalWindow: input.preferModalWindow,
...(input.signal ? { signal: input.signal } : {}),
});
}
@@ -1,5 +1,5 @@
import type { RuntimeOptionState } from '../../types';
import type { OverlayHostedModal } from '../../shared/ipc/contracts';
import type { OverlayModalRuntime } from '../overlay-runtime';
type RuntimeOptionsManagerLike = {
listOptions: () => RuntimeOptionState[];
@@ -47,16 +47,12 @@ export function createBroadcastRuntimeOptionsChangedHandler(deps: {
}
export function createSendToActiveOverlayWindowHandler(deps: {
sendToActiveOverlayWindowRuntime: (
channel: string,
payload?: unknown,
runtimeOptions?: { restoreOnModalClose?: OverlayHostedModal },
) => boolean;
sendToActiveOverlayWindowRuntime: OverlayModalRuntime['sendToActiveOverlayWindow'];
}) {
return (
channel: string,
payload?: unknown,
runtimeOptions?: { restoreOnModalClose?: OverlayHostedModal },
runtimeOptions?: Parameters<OverlayModalRuntime['sendToActiveOverlayWindow']>[2],
): boolean => deps.sendToActiveOverlayWindowRuntime(channel, payload, runtimeOptions);
}
@@ -0,0 +1,146 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { IPC_CHANNELS } from '../../shared/ipc/contracts';
import type { PluginPanelSnapshot } from '../../types/runtime';
import { createPluginPanelRuntime, openPluginPanelModal } from './plugin-panel-runtime';
function createHarness(openResult: Promise<boolean>) {
const sent: Array<{ channel: string; payload: unknown }> = [];
const opened: PluginPanelSnapshot[] = [];
let openFailures = 0;
let modalCloses = 0;
const runtime = createPluginPanelRuntime({
openModal: async (getSnapshot) => {
opened.push(getSnapshot());
return await openResult;
},
broadcast: (channel, payload) => sent.push({ channel, payload }),
closeModal: () => {
modalCloses += 1;
},
onOpenFailed: () => {
openFailures += 1;
},
});
return { runtime, sent, opened, failures: () => openFailures, closes: () => modalCloses };
}
test('re-sends content updated before the renderer acknowledged the open', async () => {
let acknowledge!: (opened: boolean) => void;
const h = createHarness(new Promise((resolve) => (acknowledge = resolve)));
const panel = h.runtime.open({ title: 'Explain', markdown: '_Asking..._' });
panel.update({ markdown: 'done' });
acknowledge(true);
await new Promise((resolve) => setImmediate(resolve));
assert.deepEqual(h.opened, [{ panelId: 1, title: 'Explain', markdown: '_Asking..._' }]);
assert.deepEqual(h.sent.at(-1), {
channel: IPC_CHANNELS.event.pluginPanelUpdate,
payload: { panelId: 1, title: 'Explain', markdown: 'done' },
});
});
test('closing from the overlay aborts the handle and ignores later updates', async () => {
const h = createHarness(Promise.resolve(true));
const panel = h.runtime.open({ title: 'Explain', markdown: '' });
await new Promise((resolve) => setImmediate(resolve));
const sentBefore = h.sent.length;
assert.equal(h.runtime.handleModalClosed(1), true);
panel.update({ markdown: 'too late' });
assert.equal(panel.signal.aborted, true);
assert.equal(panel.closed, true);
assert.equal(h.sent.length, sentBefore);
});
test('a late close acknowledgement for a replaced panel leaves the new panel open', () => {
const h = createHarness(Promise.resolve(true));
const first = h.runtime.open({ title: 'One', markdown: '' });
first.close();
const second = h.runtime.open({ title: 'Two', markdown: '' });
assert.equal(h.runtime.handleModalClosed(1), false, 'skip teardown of the newer panel');
assert.equal(second.signal.aborted, false);
});
test('a close notice after the plugin closed its panel still tears the modal down', () => {
const h = createHarness(Promise.resolve(true));
h.runtime.open({ title: 'One', markdown: '' }).close();
assert.equal(h.runtime.handleModalClosed(1), true);
});
test('opening a new panel aborts the previous one', () => {
const h = createHarness(Promise.resolve(true));
const first = h.runtime.open({ title: 'One', markdown: '' });
const second = h.runtime.open({ title: 'Two', markdown: '' });
assert.equal(first.signal.aborted, true);
assert.equal(second.signal.aborted, false);
});
test('plugin close tells the renderer which panel to close', () => {
const h = createHarness(Promise.resolve(true));
const panel = h.runtime.open({ title: 'One', markdown: '' });
panel.close();
assert.equal(panel.signal.aborted, true);
assert.equal(h.closes(), 1);
assert.deepEqual(h.sent.at(-1), { channel: IPC_CHANNELS.event.pluginPanelClose, payload: 1 });
});
test('a failed open aborts the handle and reports it once', async () => {
const h = createHarness(Promise.resolve(false));
const panel = h.runtime.open({ title: 'One', markdown: '' });
await new Promise((resolve) => setImmediate(resolve));
assert.equal(panel.signal.aborted, true);
assert.equal(h.failures(), 1);
assert.equal(h.closes(), 1);
});
for (const cancel of ['close', 'replace'] as const) {
test(`${cancel} cancels a pending plugin panel open before it can retry`, async () => {
const sent: unknown[] = [];
const acknowledgements: Array<(opened: boolean) => void> = [];
let modalCloses = 0;
const runtime = createPluginPanelRuntime({
openModal: (getSnapshot, signal) =>
openPluginPanelModal(
{
ensureOverlayStartupPrereqs: () => {},
ensureOverlayWindowsReadyForVisibilityActions: () => {},
sendToActiveOverlayWindow: (_channel, payload) => {
sent.push(payload);
return true;
},
waitForModalOpen: () => new Promise((resolve) => acknowledgements.push(resolve)),
logWarn: () => {},
},
getSnapshot,
signal,
),
closeModal: () => {
modalCloses += 1;
},
broadcast: () => {},
onOpenFailed: () => assert.fail('cancelled opens must not report failures'),
});
const first = runtime.open({ title: 'first', markdown: '' });
if (cancel === 'close') first.close();
else runtime.open({ title: 'second', markdown: '' });
acknowledgements[0]!(false);
await new Promise((resolve) => setImmediate(resolve));
assert.equal(first.closed, true);
assert.deepEqual(sent, [
{ panelId: 1, title: 'first', markdown: '' },
...(cancel === 'replace' ? [{ panelId: 2, title: 'second', markdown: '' }] : []),
]);
assert.equal(modalCloses, cancel === 'close' ? 1 : 0);
acknowledgements[1]?.(true);
});
}
+123
View File
@@ -0,0 +1,123 @@
import type { PluginPanelContent, PluginPanelHandle } from '../../core/services/plugins/types';
import type { OverlayHostedModal } from '../../shared/ipc/contracts';
import { IPC_CHANNELS } from '../../shared/ipc/contracts';
import type { PluginPanelSnapshot } from '../../types/runtime';
import { openOverlayHostedModal, retryOverlayModalOpen } from './overlay-hosted-modal-open';
const PLUGIN_PANEL_MODAL: OverlayHostedModal = 'plugin-panel';
const PLUGIN_PANEL_OPEN_TIMEOUT_MS = 1500;
interface PanelState {
panelId: number;
content: PluginPanelContent;
controller: AbortController;
}
/** Sends the open request, retrying once like the other overlay-hosted modals. */
export async function openPluginPanelModal(
deps: Parameters<typeof openOverlayHostedModal>[0] & Parameters<typeof retryOverlayModalOpen>[0],
getSnapshot: () => PluginPanelSnapshot,
signal: AbortSignal,
): Promise<boolean> {
return await retryOverlayModalOpen(deps, {
modal: PLUGIN_PANEL_MODAL,
signal,
timeoutMs: PLUGIN_PANEL_OPEN_TIMEOUT_MS,
retryWarning:
'Plugin panel did not acknowledge modal open on first attempt; retrying dedicated modal window.',
sendOpen: () =>
openOverlayHostedModal(deps, {
channel: IPC_CHANNELS.event.pluginPanelOpen,
modal: PLUGIN_PANEL_MODAL,
payload: getSnapshot(),
preferModalWindow: true,
signal,
}),
});
}
/**
* Owns the single plugin panel. Opening a panel replaces the previous one, and each handle's
* signal aborts when its panel goes away so plugins can stop the work feeding it.
*/
export function createPluginPanelRuntime(deps: {
openModal: (getSnapshot: () => PluginPanelSnapshot, signal: AbortSignal) => Promise<boolean>;
closeModal: () => void;
/** Sends to every overlay window; renderers ignore panel ids they are not showing. */
broadcast: (channel: string, payload: unknown) => void;
onOpenFailed: () => void;
}) {
let nextPanelId = 0;
let active: PanelState | null = null;
const snapshot = (state: PanelState): PluginPanelSnapshot => ({
panelId: state.panelId,
...state.content,
});
const finish = (state: PanelState): void => {
if (active === state) active = null;
state.controller.abort();
};
return {
open(content: PluginPanelContent): PluginPanelHandle {
if (active) finish(active);
const state: PanelState = {
panelId: ++nextPanelId,
content: { ...content },
controller: new AbortController(),
};
active = state;
const handleOpenFailure = (): void => {
if (state.controller.signal.aborted) return;
finish(state);
deps.closeModal();
deps.onOpenFailed();
};
deps
.openModal(() => snapshot(state), state.controller.signal)
.then((opened) => {
if (!opened) {
handleOpenFailure();
return;
}
// Updates sent before the renderer acknowledged the open were dropped there.
if (!state.controller.signal.aborted) {
deps.broadcast(IPC_CHANNELS.event.pluginPanelUpdate, snapshot(state));
}
})
.catch(handleOpenFailure);
return {
signal: state.controller.signal,
get closed() {
return state.controller.signal.aborted;
},
update: (next) => {
if (state.controller.signal.aborted) return;
state.content = { ...state.content, ...next };
deps.broadcast(IPC_CHANNELS.event.pluginPanelUpdate, snapshot(state));
},
close: () => {
if (state.controller.signal.aborted) return;
finish(state);
deps.broadcast(IPC_CHANNELS.event.pluginPanelClose, state.panelId);
deps.closeModal();
},
};
},
/**
* The overlay closed `panelId`. Returns false for a late notice about a panel that a newer one
* already replaced, so the caller skips the modal teardown that would hide the newer panel.
*/
handleModalClosed(panelId: number): boolean {
if (!active) return true;
if (active.panelId !== panelId) return false;
finish(active);
return true;
},
};
}
@@ -0,0 +1,91 @@
import assert from 'node:assert/strict';
import { EventEmitter } from 'node:events';
import test from 'node:test';
import type { BrowserWindowConstructorOptions } from 'electron';
import { createPluginWindowRuntime } from './plugin-window-runtime';
function createFakeWindow(loadResult: Promise<void>) {
const events = new EventEmitter();
const calls: string[] = [];
const window = {
calls,
destroyed: false,
isDestroyed: () => window.destroyed,
show: () => calls.push('show'),
focus: () => calls.push('focus'),
loadFile: (filePath: string) => {
calls.push(`load:${filePath}`);
return loadResult;
},
destroy: () => {
window.destroyed = true;
events.emit('closed');
},
once: (event: 'closed', listener: () => void) => events.once(event, listener),
};
return window;
}
const request = {
key: 'viewer:/plugins/viewer/index.html',
htmlPath: '/plugins/viewer/index.html',
preloadPath: '/plugins/viewer/preload.js',
title: 'Viewer',
width: 400,
height: 300,
};
test('reopening a key focuses the open window and a closed window is recreated', () => {
const created: Array<ReturnType<typeof createFakeWindow>> = [];
const options: BrowserWindowConstructorOptions[] = [];
const promoted: unknown[] = [];
const runtime = createPluginWindowRuntime({
createBrowserWindow: (windowOptions) => {
options.push(windowOptions);
const window = createFakeWindow(Promise.resolve());
created.push(window);
return window;
},
promoteAboveOverlay: (window) => promoted.push(window),
logError: () => {},
});
const first = runtime.open(request);
const again = runtime.open(request);
assert.equal(first.created, true);
assert.equal(again.created, false);
assert.equal(again.window, first.window);
assert.deepEqual(first.window.calls, [
'load:/plugins/viewer/index.html',
'show',
'focus',
'show',
'focus',
]);
assert.equal(promoted.length, 2);
assert.equal(options[0]?.webPreferences?.preload, '/plugins/viewer/preload.js');
assert.equal(options[0]?.webPreferences?.contextIsolation, true);
first.window.destroy();
const reopened = runtime.open(request);
assert.equal(reopened.created, true);
assert.equal(created.length, 2);
});
test('a window whose page fails to load is destroyed and logged', async () => {
const errors: string[] = [];
const window = createFakeWindow(Promise.reject(new Error('ENOENT')));
const runtime = createPluginWindowRuntime({
createBrowserWindow: () => window,
promoteAboveOverlay: () => {},
logError: (message) => errors.push(message),
});
runtime.open(request);
await new Promise((resolve) => setImmediate(resolve));
assert.equal(window.destroyed, true);
assert.deepEqual(errors, ['Failed to load plugin window /plugins/viewer/index.html: ENOENT']);
});
+64
View File
@@ -0,0 +1,64 @@
import type { BrowserWindowConstructorOptions } from 'electron';
import type { PluginWindowRequest } from '../../core/services/plugins/types';
interface PluginWindowLike {
isDestroyed(): boolean;
show(): void;
focus(): void;
loadFile(filePath: string): Promise<void>;
destroy(): void;
once(event: 'closed', listener: () => void): unknown;
}
/**
* Creates plugin windows and keeps one per request key, so a plugin that reopens the same page
* focuses the existing window. Windows are raised above the overlay like the settings windows.
*/
export function createPluginWindowRuntime<TWindow extends PluginWindowLike>(deps: {
createBrowserWindow: (options: BrowserWindowConstructorOptions) => TWindow;
promoteAboveOverlay: (window: TWindow) => void;
logError: (message: string) => void;
}) {
const windows = new Map<string, TWindow>();
const showAndFocus = (window: TWindow): void => {
window.show();
window.focus();
deps.promoteAboveOverlay(window);
};
return {
open(request: PluginWindowRequest): { window: TWindow; created: boolean } {
const existing = windows.get(request.key);
if (existing && !existing.isDestroyed()) {
showAndFocus(existing);
return { window: existing, created: false };
}
const window = deps.createBrowserWindow({
width: request.width,
height: request.height,
title: request.title,
show: false,
autoHideMenuBar: true,
webPreferences: {
nodeIntegration: false,
contextIsolation: true,
...(request.preloadPath ? { preload: request.preloadPath } : {}),
},
});
windows.set(request.key, window);
window.once('closed', () => {
if (windows.get(request.key) === window) windows.delete(request.key);
});
window.loadFile(request.htmlPath).catch((error: unknown) => {
const message = error instanceof Error ? error.message : String(error);
deps.logError(`Failed to load plugin window ${request.htmlPath}: ${message}`);
if (!window.isDestroyed()) window.destroy();
});
showAndFocus(window);
return { window, created: true };
},
};
}