mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-11-10 04:46:07 -08:00
feat(plugins): add JavaScript plugin system with overlay panel (#303)
This commit is contained in:
1 parent
90fc7cf940
commit
a9a4c4f083
110 files changed
+5897
-344
No files matched your search
@@ -24,6 +24,7 @@ export interface AppReadyRuntimeDepsFactoryInput {
|
||||
loadSubtitlePosition: AppReadyRuntimeDeps['loadSubtitlePosition'];
|
||||
resolveKeybindings: AppReadyRuntimeDeps['resolveKeybindings'];
|
||||
createMpvClient: AppReadyRuntimeDeps['createMpvClient'];
|
||||
startPlugins?: AppReadyRuntimeDeps['startPlugins'];
|
||||
reloadConfig: AppReadyRuntimeDeps['reloadConfig'];
|
||||
getResolvedConfig: AppReadyRuntimeDeps['getResolvedConfig'];
|
||||
getConfigWarnings: AppReadyRuntimeDeps['getConfigWarnings'];
|
||||
@@ -94,6 +95,7 @@ export function createAppReadyRuntimeDeps(
|
||||
loadSubtitlePosition: params.loadSubtitlePosition,
|
||||
resolveKeybindings: params.resolveKeybindings,
|
||||
createMpvClient: params.createMpvClient,
|
||||
startPlugins: params.startPlugins,
|
||||
reloadConfig: params.reloadConfig,
|
||||
getResolvedConfig: params.getResolvedConfig,
|
||||
getConfigWarnings: params.getConfigWarnings,
|
||||
|
||||
@@ -303,7 +303,7 @@ test('subtitle sidebar open state is restored for replacement visible overlay wi
|
||||
/onOverlayModalOpened:\s*\(modal,\s*senderWindow\)\s*=>\s*\{(?<body>[\s\S]*?)\n \},/,
|
||||
)?.groups?.body;
|
||||
const closedBlock = source.match(
|
||||
/onOverlayModalClosed:\s*\(modal,\s*senderWindow\)\s*=>\s*\{(?<body>[\s\S]*?)\n \},/,
|
||||
/onOverlayModalClosed:\s*\(modal,\s*senderWindow(?:,\s*instanceId)?\)\s*=>\s*\{(?<body>[\s\S]*?)\n \},/,
|
||||
)?.groups?.body;
|
||||
const depsBlock = source.match(/getSubtitleSidebarOpen:\s*\(\)\s*=>\s*(?<body>[^\n,]+)/)?.groups
|
||||
?.body;
|
||||
|
||||
@@ -158,6 +158,32 @@ test('primeModalWindow leaves Linux modal creation lazy', () => {
|
||||
assert.equal(h.createCalls, 0);
|
||||
});
|
||||
|
||||
test('a cancelled queued plugin panel cannot replace its successor when the window loads', () => {
|
||||
const modal = new FakeOverlayWindow({ ...LOADING_BLANK_WINDOW, documentLoaded: false });
|
||||
const h = createHarness({ modal });
|
||||
const first = new AbortController();
|
||||
h.runtime.sendToActiveOverlayWindow(
|
||||
'plugin-panel:open',
|
||||
{ panelId: 1 },
|
||||
{
|
||||
restoreOnModalClose: 'plugin-panel',
|
||||
preferModalWindow: true,
|
||||
signal: first.signal,
|
||||
},
|
||||
);
|
||||
first.abort();
|
||||
h.runtime.sendToActiveOverlayWindow(
|
||||
'plugin-panel:open',
|
||||
{ panelId: 2 },
|
||||
{
|
||||
restoreOnModalClose: 'plugin-panel',
|
||||
preferModalWindow: true,
|
||||
},
|
||||
);
|
||||
modal.finishLoad();
|
||||
assert.deepEqual(modal.sent, [['plugin-panel:open', { panelId: 2 }]]);
|
||||
});
|
||||
|
||||
test('sendToActiveOverlayWindow does not retain restore state when modal creation fails', () => {
|
||||
const h = createHarness({ createModal: () => null });
|
||||
|
||||
|
||||
+32
-19
@@ -50,6 +50,7 @@ export interface OverlayModalRuntime {
|
||||
runtimeOptions?: {
|
||||
restoreOnModalClose?: OverlayHostedModal;
|
||||
preferModalWindow?: boolean;
|
||||
signal?: AbortSignal;
|
||||
},
|
||||
) => boolean;
|
||||
openRuntimeOptionsPalette: () => void;
|
||||
@@ -265,7 +266,9 @@ export function createOverlayModalRuntimeService(
|
||||
const sendOrQueueForWindow = (
|
||||
window: BrowserWindow,
|
||||
sendNow: (window: BrowserWindow) => void,
|
||||
signal?: AbortSignal,
|
||||
): void => {
|
||||
if (signal?.aborted) return;
|
||||
if (isWindowReadyForIpc(window)) {
|
||||
sendNow(window);
|
||||
return;
|
||||
@@ -273,7 +276,7 @@ export function createOverlayModalRuntimeService(
|
||||
|
||||
let delivered = false;
|
||||
const deliver = (isReady: () => boolean): void => {
|
||||
if (delivered || window.isDestroyed() || !isReady()) {
|
||||
if (delivered || signal?.aborted || window.isDestroyed() || !isReady()) {
|
||||
return;
|
||||
}
|
||||
delivered = true;
|
||||
@@ -433,8 +436,10 @@ export function createOverlayModalRuntimeService(
|
||||
runtimeOptions?: {
|
||||
restoreOnModalClose?: OverlayHostedModal;
|
||||
preferModalWindow?: boolean;
|
||||
signal?: AbortSignal;
|
||||
},
|
||||
): boolean => {
|
||||
if (runtimeOptions?.signal?.aborted) return false;
|
||||
const restoreOnModalClose = runtimeOptions?.restoreOnModalClose;
|
||||
const preferModalWindow = runtimeOptions?.preferModalWindow === true;
|
||||
|
||||
@@ -451,13 +456,17 @@ export function createOverlayModalRuntimeService(
|
||||
const mainWindow = getTargetOverlayWindow();
|
||||
if (!preferModalWindow && mainWindow && !mainWindow.isDestroyed() && mainWindow.isVisible()) {
|
||||
restoreVisibleOverlayOnModalClose.add(restoreOnModalClose);
|
||||
sendOrQueueForWindow(mainWindow, (window) => {
|
||||
if (payload === undefined) {
|
||||
window.webContents.send(channel);
|
||||
} else {
|
||||
window.webContents.send(channel, payload);
|
||||
}
|
||||
});
|
||||
sendOrQueueForWindow(
|
||||
mainWindow,
|
||||
(window) => {
|
||||
if (payload === undefined) {
|
||||
window.webContents.send(channel);
|
||||
} else {
|
||||
window.webContents.send(channel, payload);
|
||||
}
|
||||
},
|
||||
runtimeOptions?.signal,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -479,16 +488,20 @@ export function createOverlayModalRuntimeService(
|
||||
showModalWindow(modalWindow);
|
||||
}
|
||||
|
||||
sendOrQueueForWindow(modalWindow, (window) => {
|
||||
if (window.isVisible()) {
|
||||
ensureModalWindowInteractive(window);
|
||||
}
|
||||
if (payload === undefined) {
|
||||
window.webContents.send(channel);
|
||||
} else {
|
||||
window.webContents.send(channel, payload);
|
||||
}
|
||||
});
|
||||
sendOrQueueForWindow(
|
||||
modalWindow,
|
||||
(window) => {
|
||||
if (window.isVisible()) {
|
||||
ensureModalWindowInteractive(window);
|
||||
}
|
||||
if (payload === undefined) {
|
||||
window.webContents.send(channel);
|
||||
} else {
|
||||
window.webContents.send(channel, payload);
|
||||
}
|
||||
},
|
||||
runtimeOptions?.signal,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -500,7 +513,7 @@ export function createOverlayModalRuntimeService(
|
||||
showOverlayWindowForModal(target);
|
||||
}
|
||||
|
||||
sendOrQueueForWindow(target, sendNow);
|
||||
sendOrQueueForWindow(target, sendNow, runtimeOptions?.signal);
|
||||
return true;
|
||||
};
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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']);
|
||||
});
|
||||
@@ -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 };
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user