mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-10-26 23:35:17 -07:00
feat(plugins): add plugin windows, CLI actions, and data dirs
- Add `--plugin-action <id>/<name>` CLI flag, dispatched via runPluginAction - Add ctx.ui.openWindow for plugin-owned BrowserWindows with sandboxed preload - Add ctx.dataDir (`plugin-data/<id>/`), ctx.actions.run, ctx.mpv.ensureRunning - Emit mpv end-file events to plugins with reason, entry id, and error - Run Windows .cmd/.bat shims through cmd.exe in exec, killing the tree on abort - Update plugin docs, usage, architecture notes, and e2e coverage
This commit is contained in:
28 files changed
+857
-91
No files matched your search
@@ -1,5 +1,5 @@
|
||||
type: added
|
||||
area: plugins
|
||||
|
||||
- Added JavaScript plugins loaded at startup from `<config dir>/plugins/<id>/` and enabled under `plugins` in the config. Plugins can listen to mpv events, run timers, register actions bound with `__plugin-action:<id>/<action>` keybindings, run programs, and show markdown results in an overlay panel.
|
||||
- Added JavaScript plugins loaded at startup from `<config dir>/plugins/<id>/` and enabled under `plugins` in the config. Plugins can listen to mpv events, run timers, register actions bound with `__plugin-action:<id>/<action>` keybindings or run with `--plugin-action`, run built-in actions, start mpv, run programs, keep state in a per-plugin data folder, and show results in an overlay panel or their own window.
|
||||
- Added an example `ai-explain` plugin that sends the current line and its neighbors to `codex exec` and shows a translation or grammar explanation.
|
||||
+25
-17
@@ -32,6 +32,8 @@ These are SubMiner app plugins. The Lua script that runs inside mpv is the separ
|
||||
}
|
||||
```
|
||||
|
||||
To run an action from a script or a window-manager shortcut, use `subminer app --plugin-action ai-explain/explain`. It goes to the running SubMiner, or starts one if none is running.
|
||||
|
||||
4. Restart SubMiner. Plugin changes apply on the next start; keybinding changes apply immediately.
|
||||
|
||||
Load errors and plugin log output go to the app log with a `plugin:<id>` scope.
|
||||
@@ -84,25 +86,31 @@ An error thrown by a plugin is logged and does not crash SubMiner. A plugin that
|
||||
|
||||
### `ctx` reference
|
||||
|
||||
| Member | What it does |
|
||||
| -------------------------------------- | ------------------------------------------------------------------------------------------------------- |
|
||||
| `id`, `dir`, `settings` | Plugin id, plugin folder path, and `plugins.<id>.settings` |
|
||||
| `log.debug/info/warn/error(message)` | Write to the app log |
|
||||
| `every(ms, fn)`, `after(ms, fn)` | Repeating and one-shot timers. Return a function that cancels them |
|
||||
| `mpv.on(event, fn)` | Listen to mpv events (below). Returns an unsubscribe function |
|
||||
| `mpv.command(args)` | Send an mpv command, e.g. `['set', 'pause', 'yes']` |
|
||||
| `mpv.getProperty(name)` | Read an mpv property (Promise) |
|
||||
| `mpv.connected` | Whether mpv is connected |
|
||||
| `subtitles.current()` | The displayed line as `{ text, startTime, endTime }`, or `null` |
|
||||
| `subtitles.context({ before, after })` | `{ current, before, after }`. Neighbors come from the loaded subtitle file, or recent lines for streams |
|
||||
| `actions.register(name, fn)` | Add an action, bindable as `__plugin-action:<id>/<name>` |
|
||||
| `notify(message, { variant })` | Overlay notification. `variant` is `info`, `warning`, or `error` |
|
||||
| `ui.panel({ title, markdown })` | Open the plugin panel. Returns `{ update(content), close(), closed, signal }` |
|
||||
| `exec(command, args, options)` | Run a program without a shell. Resolves `{ exitCode, stdout, stderr }` |
|
||||
| `unstable` | SubMiner internals. These change between releases without notice |
|
||||
| Member | What it does |
|
||||
| -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `id`, `dir`, `settings` | Plugin id, plugin folder path, and `plugins.<id>.settings` |
|
||||
| `dataDir` | `plugin-data/<id>/` next to your config file, for caches and state. Exists before `activate` runs |
|
||||
| `log.debug/info/warn/error(message)` | Write to the app log |
|
||||
| `every(ms, fn)`, `after(ms, fn)` | Repeating and one-shot timers. Return a function that cancels them |
|
||||
| `mpv.on(event, fn)` | Listen to mpv events (below). Returns an unsubscribe function |
|
||||
| `mpv.command(args)` | Send an mpv command, e.g. `['set', 'pause', 'yes']` |
|
||||
| `mpv.getProperty(name)` | Read an mpv property (Promise) |
|
||||
| `mpv.connected` | Whether mpv is connected |
|
||||
| `mpv.ensureRunning()` | Connect to mpv, starting an idle mpv if none is running. Resolves `false` if that fails |
|
||||
| `subtitles.current()` | The displayed line as `{ text, startTime, endTime }`, or `null` |
|
||||
| `subtitles.context({ before, after })` | `{ current, before, after }`. Neighbors come from the loaded subtitle file, or recent lines for streams |
|
||||
| `actions.register(name, fn)` | Add an action, bindable as `__plugin-action:<id>/<name>` |
|
||||
| `actions.run(actionId, payload)` | Run a built-in action, the same ids keybindings use, e.g. `openJimaku` or `mineSentence` |
|
||||
| `notify(message, { variant })` | Overlay notification. `variant` is `info`, `warning`, or `error` |
|
||||
| `ui.panel({ title, markdown })` | Open the plugin panel. Returns `{ update(content), close(), closed, signal }` |
|
||||
| `ui.openWindow({ html, preload })` | Open a window from the plugin folder (below). Returns the Electron `BrowserWindow` |
|
||||
| `exec(command, args, options)` | Run a program without a shell (Windows `.cmd`/`.bat` shims go through `cmd.exe`). Resolves `{ exitCode, stdout, stderr }` |
|
||||
| `unstable` | SubMiner internals. These change between releases without notice |
|
||||
|
||||
A panel's `signal` aborts when the user closes it or another panel replaces it. Pass it to `exec` to stop work nobody will see. Only one panel is open at a time. Panel markdown supports headings, lists, quotes, code, bold, and italics; HTML is shown as plain text.
|
||||
|
||||
`exec` options are `stdin`, `cwd`, `env`, `timeoutMs`, and `signal`. It resolves for any exit code and rejects when the program can't start, times out, or is aborted.
|
||||
|
||||
mpv events: `connection-change`, `subtitle-change`, `subtitle-timing`, `time-pos-change`, `duration-change`, `pause-change`, `fullscreen-change`, `media-path-change`, `media-title-change`, `subtitle-track-change`, `subtitle-track-list-change`, `secondary-subtitle-change`, `secondary-subtitle-track-change`, `secondary-subtitle-delay-change`, `secondary-subtitle-visibility`, and `client-message`.
|
||||
`ui.openWindow` takes `html` and an optional `preload`, both relative to the plugin folder, plus `title`, `width`, and `height`. Opening the same `html` again focuses the open window. The page runs with context isolation and no Node access. The preload is sandboxed too: it can `require('electron')` but nothing else, so bundle anything more into it. Expose what the page needs from the preload with `contextBridge`, and handle its messages with `ipcMain` from `require('electron')` in the plugin. Prefix channel names with the plugin id. Plugin windows close when SubMiner quits.
|
||||
|
||||
mpv events: `connection-change`, `subtitle-change`, `subtitle-timing`, `time-pos-change`, `duration-change`, `pause-change`, `fullscreen-change`, `media-path-change`, `media-title-change`, `subtitle-track-change`, `subtitle-track-list-change`, `secondary-subtitle-change`, `secondary-subtitle-track-change`, `secondary-subtitle-delay-change`, `secondary-subtitle-visibility`, `client-message`, and `end-file`. `end-file` gives `{ reason, playlistEntryId, error }`, where `reason` is `eof`, `stop`, `quit`, `error`, `redirect`, or `unknown`.
|
||||
@@ -104,6 +104,7 @@ subminer doctor # check dependencies, config, and the mpv soc
|
||||
subminer generate-subs video.mkv # make Japanese subtitles from the audio
|
||||
subminer logs -e # export a log ZIP for bug reports
|
||||
subminer app --setup # reopen first-run setup
|
||||
subminer app --plugin-action <id>/<name> # run a plugin action
|
||||
subminer -u # update SubMiner
|
||||
```
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ Update checks and startup launcher migration share a serialized update-state sto
|
||||
- `subtitle-generation-reference.ts` ranks mpv's loaded text subtitle tracks, excludes signs/songs and forced references, and extracts timing hints with FFmpeg. The overlay and launcher snapshot references only for matching media, including active subtitle delays. Hints guide long-passage cuts with or without VAD; they never limit audio coverage or replace Whisper timestamps.
|
||||
- `src/main/runtime/subtitle-selection.ts` reads and validates mpv subtitle tracks and applies primary/secondary selections. Its opt-in session shortcut opens the shared overlay modal window, with renderer focus and subtitle suppression handled by the modal registry.
|
||||
- `src/shared/session-key-sequences.ts` rejects sequence prefixes reserved by single-key actions. The session-binding compiler reserves configured and built-in overlay keys; `src/main/runtime/session-bindings-runtime.ts` adds active mpv bindings and publishes the effective list to both the plugin artifact and the renderer through `session-bindings:changed`. mpv no-op `ignore` bindings do not reserve prefixes.
|
||||
- `src/core/services/plugins/` owns user plugins: discovery from `<config dir>/plugins/<id>/plugin.json` for ids enabled in `plugins`, the per-plugin `ctx`, guarded callbacks, and `exec` (no shell, AppImage `LD_LIBRARY_PATH` dropped). `main.ts` supplies the host's deps, attaches each new mpv client so plugin listeners survive client replacement, starts plugins from the app-ready path after logging is configured, and unloads them on `before-quit`. Plugins run in the main process with no sandbox. Plugin actions are the `runPluginAction` session action (keybinding command `__plugin-action:<id>/<name>`). `src/main/runtime/plugin-panel-runtime.ts` owns the single `plugin-panel` modal: a new panel aborts the previous handle's signal, updates broadcast to both overlay windows keyed by `panelId`, and the latest content is re-sent once the renderer acknowledges the open.
|
||||
- `src/core/services/plugins/` owns user plugins: discovery from `<config dir>/plugins/<id>/plugin.json` for ids enabled in `plugins`, the per-plugin `ctx`, guarded callbacks, and `exec` (no shell, AppImage `LD_LIBRARY_PATH` dropped). `main.ts` supplies the host's deps, attaches each new mpv client so plugin listeners survive client replacement, starts plugins from the app-ready path after logging is configured, and unloads them on `before-quit`. Plugins run in the main process with no sandbox. Plugin actions are the `runPluginAction` session action (keybinding command `__plugin-action:<id>/<name>`, CLI `--plugin-action <id>/<name>`); `runAction` waits for an in-progress load so a cold-start CLI action is not dropped. Each plugin gets `<config dir>/plugin-data/<id>/` as `ctx.dataDir`. `src/main/runtime/plugin-window-runtime.ts` owns plugin windows (`ctx.ui.openWindow`): one window per plugin + HTML path, raised above the overlay like the settings windows, destroyed on unload. `src/main/runtime/plugin-panel-runtime.ts` owns the single `plugin-panel` modal: a new panel aborts the previous handle's signal, updates broadcast to both overlay windows keyed by `panelId`, and the latest content is re-sent once the renderer acknowledges the open.
|
||||
- `src/renderer/` owns overlay rendering and input behavior.
|
||||
- `src/config/` owns config definitions, defaults, loading, and resolution.
|
||||
- `src/types/` owns shared cross-runtime contracts via domain entrypoints; `src/types.ts` stays a compatibility barrel.
|
||||
|
||||
@@ -34,7 +34,7 @@ Read when: you need to find the owner module for a behavior or test surface
|
||||
- AniList tracking + character dictionary: `src/core/services/anilist/`, `src/main/runtime/composers/anilist-*`, `src/main/character-dictionary-runtime.ts`, `src/main/character-dictionary-runtime/`
|
||||
- TMDB live-action metadata: `src/core/services/tmdb/` (client + exact-title resolver), `src/core/services/immersion-tracker/live-action-link.ts` (links an entry to a TMDB title and merges other holders of the same title). The AniList cover-art fetcher calls the resolver as its fallback; `imm_anime.media_kind` marks the result and keeps the entry out of AniList season repair.
|
||||
- Jellyfin integration: `src/core/services/jellyfin*.ts`, `src/main/runtime/composers/jellyfin-*`
|
||||
- User plugins: `src/core/services/plugins/` (host, `ctx`, `exec`), `src/main/runtime/plugin-panel-runtime.ts` (panel modal), `src/renderer/modals/plugin-panel*.ts`; example in `assets/plugins/`
|
||||
- User plugins: `src/core/services/plugins/` (host, `ctx`, `exec`), `src/main/runtime/plugin-panel-runtime.ts` (panel modal), `src/main/runtime/plugin-window-runtime.ts` (plugin windows), `src/renderer/modals/plugin-panel*.ts`; example in `assets/plugins/`
|
||||
- Window trackers: `src/window-trackers/`
|
||||
- Stats HTTP app: `src/core/services/stats-server.ts`, with route groups and shared route support
|
||||
in `src/core/services/stats-server/`
|
||||
|
||||
@@ -14,7 +14,14 @@ const TEST_TIMEOUT_MS = 120_000;
|
||||
// Opens a panel describing the subtitle context and a child process result, and
|
||||
// logs when the panel's signal aborts so the test can see the close reach main.
|
||||
const PROBE_PLUGIN = `
|
||||
const { ipcMain } = require('electron');
|
||||
|
||||
exports.activate = (ctx) => {
|
||||
ipcMain.handle('e2e-probe:info', () => ({ dataDir: ctx.dataDir, greeting: ctx.settings.greeting }));
|
||||
ctx.actions.register('window', () => {
|
||||
ctx.ui.openWindow({ html: 'ui/index.html', preload: 'preload.js', title: 'E2E Probe Window' });
|
||||
});
|
||||
ctx.mpv.on('end-file', ({ reason }) => ctx.log.info('probe end-file ' + reason));
|
||||
ctx.actions.register('show', () => {
|
||||
const { before, current, after } = ctx.subtitles.context({ before: 1, after: 1 });
|
||||
const panel = ctx.ui.panel({ title: 'Probe', markdown: '_working_' });
|
||||
@@ -38,9 +45,29 @@ exports.activate = (ctx) => {
|
||||
)
|
||||
.catch((error) => panel.update({ markdown: 'exec failed: ' + error.message }));
|
||||
});
|
||||
return () => ipcMain.removeHandler('e2e-probe:info');
|
||||
};
|
||||
`;
|
||||
|
||||
const PROBE_PRELOAD = `
|
||||
const { contextBridge, ipcRenderer } = require('electron');
|
||||
contextBridge.exposeInMainWorld('probe', { info: () => ipcRenderer.invoke('e2e-probe:info') });
|
||||
`;
|
||||
|
||||
const PROBE_WINDOW_HTML = `<!doctype html>
|
||||
<html>
|
||||
<head><title>E2E Probe Window</title></head>
|
||||
<body>
|
||||
<pre id="out">loading</pre>
|
||||
<script>
|
||||
window.probe.info().then((info) => {
|
||||
document.getElementById('out').textContent = JSON.stringify(info);
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
|
||||
type Started = { session: E2eSession; overlay: CdpPage };
|
||||
let started: Promise<Started> | undefined;
|
||||
|
||||
@@ -51,6 +78,8 @@ function useSession(): Promise<Started> {
|
||||
files: {
|
||||
'plugins/e2e-probe/plugin.json': JSON.stringify({ name: 'E2E Probe', main: 'main.cjs' }),
|
||||
'plugins/e2e-probe/main.cjs': PROBE_PLUGIN,
|
||||
'plugins/e2e-probe/preload.js': PROBE_PRELOAD,
|
||||
'plugins/e2e-probe/ui/index.html': PROBE_WINDOW_HTML,
|
||||
},
|
||||
});
|
||||
try {
|
||||
@@ -148,3 +177,41 @@ test(
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'a CLI plugin action opens a plugin window whose preload talks to the plugin',
|
||||
{ timeout: TEST_TIMEOUT_MS },
|
||||
async () => {
|
||||
const { session } = await useSession();
|
||||
|
||||
await session.runAppCommand('--plugin-action', 'e2e-probe/window');
|
||||
|
||||
const page = await session.openPage('plugins/e2e-probe/ui/index.html');
|
||||
const info = await page.waitFor<{ dataDir: string; greeting: string } | false>(
|
||||
`(() => {
|
||||
const text = document.getElementById('out')?.textContent;
|
||||
return text && text !== 'loading' ? JSON.parse(text) : false;
|
||||
})()`,
|
||||
{ description: 'plugin window to read plugin info over IPC' },
|
||||
);
|
||||
|
||||
assert.equal(info.greeting, 'hello');
|
||||
assert.equal(path.basename(info.dataDir), 'e2e-probe');
|
||||
assert.equal(path.basename(path.dirname(info.dataDir)), 'plugin-data');
|
||||
assert.equal(fs.statSync(info.dataDir).isDirectory(), true);
|
||||
},
|
||||
);
|
||||
|
||||
test('plugins receive mpv end-file events', { timeout: TEST_TIMEOUT_MS }, async () => {
|
||||
const { session } = await useSession();
|
||||
const mediaPath = await session.mpv.command('get_property', 'path');
|
||||
|
||||
// Replacing the playing file ends it with reason "stop".
|
||||
await session.mpv.command('loadfile', String(mediaPath), 'replace');
|
||||
|
||||
await waitUntil(
|
||||
() =>
|
||||
fs.readFileSync(path.join(session.root, 'app.log'), 'utf8').includes('probe end-file stop'),
|
||||
{ description: 'the plugin to log the end-file reason' },
|
||||
);
|
||||
});
|
||||
@@ -340,6 +340,14 @@ test('hasExplicitCommand and shouldStartApp preserve command intent', () => {
|
||||
assert.equal(shouldStartApp(sessionAction), true);
|
||||
assert.equal(commandNeedsOverlayRuntime(sessionAction), true);
|
||||
|
||||
const pluginAction = parseArgs(['--plugin-action', 'anime/open']);
|
||||
assert.equal(pluginAction.pluginAction, 'anime/open');
|
||||
assert.equal(parseArgs(['--plugin-action=anime/open']).pluginAction, 'anime/open');
|
||||
assert.equal(hasExplicitCommand(pluginAction), true);
|
||||
assert.equal(shouldStartApp(pluginAction), true);
|
||||
// The plugin decides what it needs; a window-only action must not force the overlay up.
|
||||
assert.equal(commandNeedsOverlayRuntime(pluginAction), false);
|
||||
|
||||
const toggleStatsOverlayRuntime = parseArgs(['--toggle-stats-overlay']);
|
||||
assert.equal(commandNeedsOverlayRuntime(toggleStatsOverlayRuntime), true);
|
||||
|
||||
|
||||
@@ -48,6 +48,8 @@ export interface CliArgs {
|
||||
cycleRuntimeOptionId?: string;
|
||||
cycleRuntimeOptionDirection?: 1 | -1;
|
||||
sessionAction?: SessionActionDispatchRequest;
|
||||
/** `<plugin id>/<action name>` to run, like a `__plugin-action:` keybinding. */
|
||||
pluginAction?: string;
|
||||
copySubtitleCount?: number;
|
||||
mineSentenceCount?: number;
|
||||
anilistStatus: boolean;
|
||||
@@ -338,6 +340,12 @@ export function parseArgs(argv: string[]): CliArgs {
|
||||
} else if (arg === '--session-action') {
|
||||
const parsed = parseSessionAction(readValue(argv[i + 1]));
|
||||
if (parsed) args.sessionAction = parsed;
|
||||
} else if (arg.startsWith('--plugin-action=')) {
|
||||
const value = arg.slice('--plugin-action='.length).trim();
|
||||
if (value) args.pluginAction = value;
|
||||
} else if (arg === '--plugin-action') {
|
||||
const value = readValue(argv[i + 1])?.trim();
|
||||
if (value) args.pluginAction = value;
|
||||
} else if (arg.startsWith('--copy-subtitle-count=')) {
|
||||
const value = Number(arg.split('=', 2)[1]);
|
||||
if (Number.isInteger(value) && value > 0) args.copySubtitleCount = value;
|
||||
@@ -601,6 +609,7 @@ export function hasExplicitCommand(args: CliArgs): boolean {
|
||||
args.playbackFeedback !== undefined ||
|
||||
args.cycleRuntimeOptionId !== undefined ||
|
||||
args.sessionAction !== undefined ||
|
||||
args.pluginAction !== undefined ||
|
||||
args.copySubtitleCount !== undefined ||
|
||||
args.mineSentenceCount !== undefined ||
|
||||
args.anilistStatus ||
|
||||
@@ -682,6 +691,7 @@ export function isStandaloneTexthookerCommand(args: CliArgs): boolean {
|
||||
args.playbackFeedback === undefined &&
|
||||
args.cycleRuntimeOptionId === undefined &&
|
||||
args.sessionAction === undefined &&
|
||||
args.pluginAction === undefined &&
|
||||
args.copySubtitleCount === undefined &&
|
||||
args.mineSentenceCount === undefined &&
|
||||
!args.anilistStatus &&
|
||||
@@ -752,6 +762,7 @@ export function shouldStartApp(args: CliArgs): boolean {
|
||||
args.playbackFeedback !== undefined ||
|
||||
args.cycleRuntimeOptionId !== undefined ||
|
||||
args.sessionAction !== undefined ||
|
||||
args.pluginAction !== undefined ||
|
||||
args.copySubtitleCount !== undefined ||
|
||||
args.mineSentenceCount !== undefined ||
|
||||
args.dictionary ||
|
||||
@@ -819,6 +830,7 @@ export function shouldRunDictionarySettingsOnlyStartup(args: CliArgs): boolean {
|
||||
args.playbackFeedback === undefined &&
|
||||
args.cycleRuntimeOptionId === undefined &&
|
||||
args.sessionAction === undefined &&
|
||||
args.pluginAction === undefined &&
|
||||
args.copySubtitleCount === undefined &&
|
||||
args.mineSentenceCount === undefined &&
|
||||
!args.anilistStatus &&
|
||||
|
||||
@@ -46,6 +46,7 @@ ${B}Mining${R}
|
||||
--open-session-help Open session help modal
|
||||
--open-controller-select Open controller select modal
|
||||
--open-controller-debug Open controller debug modal
|
||||
--plugin-action ${D}<id>/<name>${R} Run a plugin action
|
||||
|
||||
${B}AniList${R}
|
||||
--anilist-setup Open AniList authentication flow
|
||||
|
||||
@@ -737,6 +737,23 @@ test('handleCliCommand dispatches generic session action payloads', async () =>
|
||||
});
|
||||
});
|
||||
|
||||
test('handleCliCommand runs plugin actions through the session action dispatcher', async () => {
|
||||
let request: unknown = null;
|
||||
const { deps } = createDeps({
|
||||
dispatchSessionAction: async (nextRequest) => {
|
||||
request = nextRequest;
|
||||
},
|
||||
});
|
||||
|
||||
handleCliCommand(makeArgs({ pluginAction: 'anime/open' }), 'initial', deps);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
assert.deepEqual(request, {
|
||||
actionId: 'runPluginAction',
|
||||
payload: { pluginActionId: 'anime/open' },
|
||||
});
|
||||
});
|
||||
|
||||
test('handleCliCommand dispatches mark-watched session action', async () => {
|
||||
let request: unknown = null;
|
||||
const { deps } = createDeps({
|
||||
|
||||
@@ -422,6 +422,12 @@ export function handleCliCommand(
|
||||
`sessionAction:${args.sessionAction.actionId}`,
|
||||
'Session action failed',
|
||||
);
|
||||
} else if (args.pluginAction) {
|
||||
dispatchCliSessionAction(
|
||||
{ actionId: 'runPluginAction', payload: { pluginActionId: args.pluginAction } },
|
||||
`pluginAction:${args.pluginAction}`,
|
||||
'Plugin action failed',
|
||||
);
|
||||
} else if (args.toggle || args.toggleVisibleOverlay) {
|
||||
deps.toggleVisibleOverlay();
|
||||
} else if (args.togglePrimarySubtitleBar) {
|
||||
|
||||
@@ -445,3 +445,19 @@ test('dispatchMpvProtocolMessage emits client-message string args', async () =>
|
||||
|
||||
assert.deepEqual(received, [{ args: ['subminer-skip-intro', 'extra'] }]);
|
||||
});
|
||||
|
||||
test('dispatchMpvProtocolMessage emits end-file with reason, entry id, and error', async () => {
|
||||
const received: unknown[] = [];
|
||||
const { deps } = createDeps({ emitEndFile: (payload) => received.push(payload) });
|
||||
|
||||
await dispatchMpvProtocolMessage(
|
||||
{ event: 'end-file', reason: 'error', playlist_entry_id: 3, file_error: 'loading failed' },
|
||||
deps,
|
||||
);
|
||||
await dispatchMpvProtocolMessage({ event: 'end-file', reason: 'something-new' }, deps);
|
||||
|
||||
assert.deepEqual(received, [
|
||||
{ reason: 'error', playlistEntryId: 3, error: 'loading failed' },
|
||||
{ reason: 'unknown', playlistEntryId: null, error: null },
|
||||
]);
|
||||
});
|
||||
@@ -9,8 +9,30 @@ export type MpvMessage = {
|
||||
args?: unknown;
|
||||
request_id?: number;
|
||||
error?: string;
|
||||
reason?: unknown;
|
||||
playlist_entry_id?: unknown;
|
||||
file_error?: unknown;
|
||||
};
|
||||
|
||||
const MPV_END_FILE_REASONS = ['eof', 'stop', 'quit', 'error', 'redirect'] as const;
|
||||
export type MpvEndFileReason = (typeof MPV_END_FILE_REASONS)[number] | 'unknown';
|
||||
|
||||
/** mpv's `end-file` event: why the current file stopped, and mpv's error text when it failed. */
|
||||
export interface MpvEndFileEvent {
|
||||
reason: MpvEndFileReason;
|
||||
playlistEntryId: number | null;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
function parseEndFileEvent(msg: MpvMessage): MpvEndFileEvent {
|
||||
const reason = MPV_END_FILE_REASONS.find((known) => known === msg.reason) ?? 'unknown';
|
||||
return {
|
||||
reason,
|
||||
playlistEntryId: typeof msg.playlist_entry_id === 'number' ? msg.playlist_entry_id : null,
|
||||
error: typeof msg.file_error === 'string' && msg.file_error ? msg.file_error : null,
|
||||
};
|
||||
}
|
||||
|
||||
export const MPV_REQUEST_ID_SUBTEXT = 101;
|
||||
export const MPV_REQUEST_ID_PATH = 102;
|
||||
export const MPV_REQUEST_ID_SECONDARY_SUBTEXT = 103;
|
||||
@@ -100,6 +122,7 @@ export interface MpvProtocolHandleMessageDeps {
|
||||
shouldQuitOnMpvShutdown: () => boolean;
|
||||
requestAppQuit: () => void;
|
||||
emitClientMessage?: (payload: { args: string[] }) => void;
|
||||
emitEndFile?: (payload: MpvEndFileEvent) => void;
|
||||
}
|
||||
|
||||
type SubtitleTrackCandidate = {
|
||||
@@ -419,6 +442,8 @@ export async function dispatchMpvProtocolMessage(
|
||||
if (args.length > 0) {
|
||||
deps.emitClientMessage?.({ args });
|
||||
}
|
||||
} else if (msg.event === 'end-file') {
|
||||
deps.emitEndFile?.(parseEndFileEvent(msg));
|
||||
} else if (msg.event === 'shutdown') {
|
||||
deps.restorePreviousSecondarySubVisibility();
|
||||
if (deps.shouldQuitOnMpvShutdown()) {
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
dispatchMpvProtocolMessage,
|
||||
MPV_REQUEST_ID_TRACK_LIST_AUDIO,
|
||||
MPV_REQUEST_ID_TRACK_LIST_SECONDARY,
|
||||
type MpvEndFileEvent,
|
||||
MpvMessage,
|
||||
MpvProtocolHandleMessageDeps,
|
||||
splitMpvMessagesFromBuffer,
|
||||
@@ -138,6 +139,7 @@ export interface MpvIpcClientEventMap {
|
||||
'subtitle-metrics-change': { patch: Partial<MpvSubtitleRenderMetrics> };
|
||||
'secondary-subtitle-visibility': { visible: boolean };
|
||||
'client-message': { args: string[] };
|
||||
'end-file': MpvEndFileEvent;
|
||||
}
|
||||
|
||||
type MpvIpcClientEventName = keyof MpvIpcClientEventMap;
|
||||
@@ -519,6 +521,9 @@ export class MpvIpcClient implements MpvClient {
|
||||
emitClientMessage: (payload) => {
|
||||
this.emit('client-message', payload);
|
||||
},
|
||||
emitEndFile: (payload) => {
|
||||
this.emit('end-file', payload);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtemp, readFile, rm } from 'node:fs/promises';
|
||||
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
import { setTimeout as sleep } from 'node:timers/promises';
|
||||
|
||||
import { execPluginCommand } from './exec';
|
||||
import { execPluginCommand, resolvePluginSpawnTarget } from './exec';
|
||||
|
||||
// The test runtime (bun or node) doubles as a portable child process.
|
||||
const runtime = process.execPath;
|
||||
@@ -107,3 +107,31 @@ test('rejects when the binary does not exist', async () => {
|
||||
(error: NodeJS.ErrnoException) => error.code === 'ENOENT',
|
||||
);
|
||||
});
|
||||
|
||||
test('Windows batch shims run through cmd.exe; other commands spawn directly', async () => {
|
||||
const dir = await mkdtemp(path.join(os.tmpdir(), 'plugin-exec-shim-'));
|
||||
try {
|
||||
await writeFile(path.join(dir, 'codex.cmd'), '');
|
||||
await writeFile(path.join(dir, 'native.exe'), '');
|
||||
const env = { PATH: dir, PATHEXT: '.exe;.cmd', ComSpec: 'cmd.exe' };
|
||||
|
||||
const shim = resolvePluginSpawnTarget('codex', ['exec', '-'], env, 'win32');
|
||||
assert.equal(shim.viaShell, true);
|
||||
assert.equal(shim.file, 'cmd.exe');
|
||||
assert.deepEqual(shim.args.slice(0, 4), ['/d', '/s', '/v:off', '/c']);
|
||||
assert.ok(shim.args[4]!.includes(path.join(dir, 'codex.cmd')));
|
||||
|
||||
assert.deepEqual(resolvePluginSpawnTarget('native', ['-v'], env, 'win32'), {
|
||||
file: path.join(dir, 'native.exe'),
|
||||
args: ['-v'],
|
||||
viaShell: false,
|
||||
});
|
||||
assert.deepEqual(resolvePluginSpawnTarget('codex', ['-'], env, 'linux'), {
|
||||
file: 'codex',
|
||||
args: ['-'],
|
||||
viaShell: false,
|
||||
});
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -1,6 +1,11 @@
|
||||
import { spawn } from 'node:child_process';
|
||||
import { type ChildProcess, spawn } from 'node:child_process';
|
||||
|
||||
import { buildSystemBinaryEnv } from '../../utils/system-binary-env';
|
||||
import {
|
||||
buildWindowsShimInvocation,
|
||||
isWindowsCommandShim,
|
||||
resolveWindowsCommand,
|
||||
} from '../../utils/windows-command-shim';
|
||||
import type { PluginExecOptions, PluginExecResult } from './types';
|
||||
|
||||
const MAX_CAPTURED_OUTPUT_CHARS = 4 * 1024 * 1024;
|
||||
@@ -11,10 +16,42 @@ function appendCapped(current: string, chunk: string): string {
|
||||
return (current + chunk).slice(0, MAX_CAPTURED_OUTPUT_CHARS);
|
||||
}
|
||||
|
||||
interface SpawnTarget {
|
||||
file: string;
|
||||
args: string[];
|
||||
viaShell: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs `command` without a shell and collects its output. Resolves with the exit code on any exit,
|
||||
* Windows batch shims (npm installs `codex` as `codex.cmd`) only run through cmd.exe. Native
|
||||
* binaries, and every command on Linux and macOS, are spawned directly.
|
||||
*/
|
||||
export function resolvePluginSpawnTarget(
|
||||
command: string,
|
||||
args: string[],
|
||||
env: NodeJS.ProcessEnv,
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
): SpawnTarget {
|
||||
if (platform !== 'win32') return { file: command, args, viaShell: false };
|
||||
const resolved = resolveWindowsCommand(command, env);
|
||||
if (!isWindowsCommandShim(resolved)) return { file: resolved, args, viaShell: false };
|
||||
return { ...buildWindowsShimInvocation(resolved, args, env), viaShell: true };
|
||||
}
|
||||
|
||||
// Killing cmd.exe alone would leave the shim's own child running, so end the whole tree.
|
||||
function killWindowsProcessTree(child: ChildProcess): void {
|
||||
if (child.pid === undefined) return;
|
||||
spawn('taskkill', ['/pid', String(child.pid), '/T', '/F'], {
|
||||
stdio: 'ignore',
|
||||
windowsHide: true,
|
||||
}).on('error', () => child.kill());
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs `command` (no shell, except cmd.exe for Windows batch shims) and collects its output. Resolves with the exit code on any exit,
|
||||
* including non-zero; rejects when the binary cannot start, the timeout passes, or `signal` aborts.
|
||||
* A timed-out or aborted child gets SIGTERM, then SIGKILL after `killGraceMs`.
|
||||
* A timed-out or aborted child gets SIGTERM, then SIGKILL after `killGraceMs` (a shim's
|
||||
* process tree is ended with taskkill instead).
|
||||
*/
|
||||
export function execPluginCommand(
|
||||
command: string,
|
||||
@@ -28,11 +65,14 @@ export function execPluginCommand(
|
||||
return;
|
||||
}
|
||||
|
||||
const child = spawn(command, args, {
|
||||
const env = { ...buildSystemBinaryEnv(), ...options.env };
|
||||
const target = resolvePluginSpawnTarget(command, args, env);
|
||||
const child = spawn(target.file, target.args, {
|
||||
cwd: options.cwd,
|
||||
env: { ...buildSystemBinaryEnv(), ...options.env },
|
||||
env,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
windowsHide: true,
|
||||
windowsVerbatimArguments: target.viaShell,
|
||||
});
|
||||
|
||||
let stdout = '';
|
||||
@@ -51,6 +91,10 @@ export function execPluginCommand(
|
||||
// Rejects right away, then escalates to SIGKILL if the child ignores SIGTERM.
|
||||
const killWith = (error: Error): void => {
|
||||
settle(() => reject(error));
|
||||
if (target.viaShell) {
|
||||
killWindowsProcessTree(child);
|
||||
return;
|
||||
}
|
||||
child.kill('SIGTERM');
|
||||
const forceKill = setTimeout(() => {
|
||||
if (child.exitCode === null && child.signalCode === null) child.kill('SIGKILL');
|
||||
|
||||
@@ -36,9 +36,9 @@ export function parsePluginManifest(
|
||||
};
|
||||
}
|
||||
|
||||
/** Resolves the entry file, rejecting paths that leave the plugin directory. */
|
||||
export function resolvePluginEntryPath(pluginDir: string, main: string): string | null {
|
||||
const resolved = path.resolve(pluginDir, main);
|
||||
/** Resolves a plugin file (entry, window HTML, preload), rejecting paths that leave the plugin directory. */
|
||||
export function resolvePluginFilePath(pluginDir: string, file: string): string | null {
|
||||
const resolved = path.resolve(pluginDir, file);
|
||||
const relative = path.relative(pluginDir, resolved);
|
||||
if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) {
|
||||
return null;
|
||||
|
||||
@@ -1,12 +1,20 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
import type { BrowserWindow } from 'electron';
|
||||
|
||||
import type { SessionActionDispatchRequest } from '../../../types/runtime';
|
||||
import { createPluginHost, type PluginHostDeps } from './plugin-host';
|
||||
import type { PluginConfigEntry, PluginLogger, PluginMpvEventName } from './types';
|
||||
import type {
|
||||
PluginConfigEntry,
|
||||
PluginLogger,
|
||||
PluginMpvEventName,
|
||||
PluginWindowRequest,
|
||||
} from './types';
|
||||
|
||||
function writePlugin(root: string, id: string, files: Record<string, string>): void {
|
||||
const dir = path.join(root, id);
|
||||
@@ -16,10 +24,31 @@ function writePlugin(root: string, id: string, files: Record<string, string>): v
|
||||
}
|
||||
}
|
||||
|
||||
type FakeWindow = ReturnType<typeof createFakeWindow>;
|
||||
|
||||
/** The slice of BrowserWindow the host touches: destroy, isDestroyed, and the `closed` event. */
|
||||
function createFakeWindow() {
|
||||
const events = new EventEmitter();
|
||||
const fake = {
|
||||
destroyed: false,
|
||||
isDestroyed: () => fake.destroyed,
|
||||
destroy: () => {
|
||||
fake.destroyed = true;
|
||||
events.emit('closed');
|
||||
},
|
||||
once: (event: string, listener: () => void) => events.once(event, listener),
|
||||
asWindow: () => fake as unknown as BrowserWindow,
|
||||
};
|
||||
return fake;
|
||||
}
|
||||
|
||||
function createHarness(config: Record<string, PluginConfigEntry>) {
|
||||
const root = mkdtempSync(path.join(os.tmpdir(), 'subminer-plugins-'));
|
||||
const errors: string[] = [];
|
||||
const notifications: string[] = [];
|
||||
const sessionActions: SessionActionDispatchRequest[] = [];
|
||||
const windowRequests: PluginWindowRequest[] = [];
|
||||
const windows = new Map<string, FakeWindow>();
|
||||
const logger: PluginLogger = {
|
||||
debug: () => {},
|
||||
info: () => {},
|
||||
@@ -27,24 +56,41 @@ function createHarness(config: Record<string, PluginConfigEntry>) {
|
||||
error: (message) => errors.push(message),
|
||||
};
|
||||
const deps: PluginHostDeps<{ marker: string }> = {
|
||||
pluginsDir: root,
|
||||
pluginsDir: path.join(root, 'plugins'),
|
||||
pluginDataDir: path.join(root, 'data'),
|
||||
getPluginConfig: () => config,
|
||||
createLogger: () => logger,
|
||||
isMpvConnected: () => true,
|
||||
sendMpvCommand: () => {},
|
||||
requestMpvProperty: async () => null,
|
||||
ensureMpvRunning: async () => true,
|
||||
dispatchSessionAction: async (request) => {
|
||||
sessionActions.push(request);
|
||||
},
|
||||
getCurrentSubtitle: () => null,
|
||||
getSubtitleContext: () => ({ current: null, before: [], after: [] }),
|
||||
notify: (message) => notifications.push(message),
|
||||
openPanel: () => {
|
||||
throw new Error('not used');
|
||||
},
|
||||
openWindow: (request) => {
|
||||
windowRequests.push(request);
|
||||
const existing = windows.get(request.key);
|
||||
if (existing && !existing.destroyed) return { window: existing.asWindow(), created: false };
|
||||
const window = createFakeWindow();
|
||||
windows.set(request.key, window);
|
||||
return { window: window.asWindow(), created: true };
|
||||
},
|
||||
unstable: { marker: 'internals' },
|
||||
};
|
||||
return {
|
||||
root,
|
||||
root: deps.pluginsDir,
|
||||
dataRoot: deps.pluginDataDir,
|
||||
errors,
|
||||
notifications,
|
||||
sessionActions,
|
||||
windowRequests,
|
||||
windows,
|
||||
host: createPluginHost(deps),
|
||||
cleanup: () => rmSync(root, { recursive: true, force: true }),
|
||||
};
|
||||
@@ -85,9 +131,9 @@ test('loads enabled plugins and runs their registered actions', async () => {
|
||||
|
||||
await h.host.loadAll();
|
||||
|
||||
assert.equal(h.host.runAction('greeter/say'), true);
|
||||
assert.equal(await h.host.runAction('greeter/say'), true);
|
||||
assert.deepEqual(h.notifications, ['hi internals']);
|
||||
assert.equal(h.host.runAction('greeter/missing'), false);
|
||||
assert.equal(await h.host.runAction('greeter/missing'), false);
|
||||
assert.deepEqual(h.errors, []);
|
||||
} finally {
|
||||
h.cleanup();
|
||||
@@ -104,7 +150,7 @@ test('loads ES module plugins', async () => {
|
||||
|
||||
await h.host.loadAll();
|
||||
|
||||
assert.equal(h.host.runAction('esm/go'), true);
|
||||
assert.equal(await h.host.runAction('esm/go'), true);
|
||||
assert.deepEqual(h.notifications, ['esm']);
|
||||
} finally {
|
||||
h.cleanup();
|
||||
@@ -128,8 +174,8 @@ test('a failed activation rolls back its registrations without affecting other p
|
||||
|
||||
await h.host.loadAll();
|
||||
|
||||
assert.equal(h.host.runAction('broken/half'), false);
|
||||
assert.equal(h.host.runAction('healthy/ok'), true);
|
||||
assert.equal(await h.host.runAction('broken/half'), false);
|
||||
assert.equal(await h.host.runAction('healthy/ok'), true);
|
||||
assert.deepEqual(h.errors, ['Activation failed: boom']);
|
||||
} finally {
|
||||
h.cleanup();
|
||||
@@ -204,3 +250,105 @@ test('events follow a replacement mpv client', async () => {
|
||||
h.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('runAction waits for a load that is still in progress', async () => {
|
||||
const h = createHarness({ slow: enabled() });
|
||||
try {
|
||||
writePlugin(h.root, 'slow', {
|
||||
'plugin.json': JSON.stringify({ main: 'main.cjs' }),
|
||||
'main.cjs': `exports.activate = async (ctx) => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
ctx.actions.register('go', () => ctx.notify('went'));
|
||||
};`,
|
||||
});
|
||||
|
||||
void h.host.loadAll();
|
||||
|
||||
assert.equal(await h.host.runAction('slow/go'), true);
|
||||
assert.deepEqual(h.notifications, ['went']);
|
||||
} finally {
|
||||
h.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('each plugin gets its own data folder, created before activate', async () => {
|
||||
const h = createHarness({ keeper: enabled() });
|
||||
try {
|
||||
writePlugin(h.root, 'keeper', {
|
||||
'plugin.json': JSON.stringify({ main: 'main.cjs' }),
|
||||
'main.cjs': `const fs = require('node:fs');
|
||||
exports.activate = (ctx) => {
|
||||
fs.writeFileSync(require('node:path').join(ctx.dataDir, 'state.json'), '{}');
|
||||
ctx.notify(ctx.dataDir);
|
||||
};`,
|
||||
});
|
||||
|
||||
await h.host.loadAll();
|
||||
|
||||
const expected = path.join(h.dataRoot, 'keeper');
|
||||
assert.deepEqual(h.notifications, [expected]);
|
||||
assert.equal(existsSync(path.join(expected, 'state.json')), true);
|
||||
assert.deepEqual(h.errors, []);
|
||||
} finally {
|
||||
h.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('actions.run forwards built-in session actions', async () => {
|
||||
const h = createHarness({ caller: enabled() });
|
||||
try {
|
||||
writePlugin(h.root, 'caller', {
|
||||
'plugin.json': JSON.stringify({ main: 'main.cjs' }),
|
||||
'main.cjs': `exports.activate = async (ctx) => {
|
||||
await ctx.actions.run('openJimaku');
|
||||
await ctx.actions.run('cycleRuntimeOption', { runtimeOptionId: 'x', direction: -1 });
|
||||
};`,
|
||||
});
|
||||
|
||||
await h.host.loadAll();
|
||||
|
||||
assert.deepEqual(h.sessionActions, [
|
||||
{ actionId: 'openJimaku' },
|
||||
{ actionId: 'cycleRuntimeOption', payload: { runtimeOptionId: 'x', direction: -1 } },
|
||||
]);
|
||||
} finally {
|
||||
h.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('openWindow resolves plugin files, reuses open windows, and closes them on unload', async () => {
|
||||
const h = createHarness({ viewer: enabled() });
|
||||
try {
|
||||
writePlugin(h.root, 'viewer', {
|
||||
'plugin.json': JSON.stringify({ main: 'main.cjs' }),
|
||||
'main.cjs': `exports.activate = (ctx) => {
|
||||
ctx.actions.register('open', () => ctx.ui.openWindow({ html: 'ui/index.html', preload: 'preload.js', width: 400 }));
|
||||
ctx.actions.register('escape', () => ctx.ui.openWindow({ html: '../other/index.html' }));
|
||||
};`,
|
||||
});
|
||||
await h.host.loadAll();
|
||||
|
||||
await h.host.runAction('viewer/open');
|
||||
await h.host.runAction('viewer/open');
|
||||
const dir = path.join(h.root, 'viewer');
|
||||
assert.deepEqual(h.windowRequests[0], {
|
||||
key: `viewer:${path.join(dir, 'ui', 'index.html')}`,
|
||||
htmlPath: path.join(dir, 'ui', 'index.html'),
|
||||
preloadPath: path.join(dir, 'preload.js'),
|
||||
title: 'viewer',
|
||||
width: 400,
|
||||
height: 720,
|
||||
});
|
||||
assert.equal(h.windows.size, 1);
|
||||
|
||||
await h.host.runAction('viewer/escape');
|
||||
assert.equal(h.windowRequests.length, 2);
|
||||
assert.match(h.errors[0] ?? '', /must stay inside/);
|
||||
|
||||
const [window] = [...h.windows.values()];
|
||||
await h.host.unloadAll();
|
||||
assert.equal(window?.destroyed, true);
|
||||
} finally {
|
||||
h.cleanup();
|
||||
}
|
||||
});
|
||||
@@ -2,8 +2,11 @@ import { EventEmitter } from 'node:events';
|
||||
import { promises as fs } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import type { BrowserWindow } from 'electron';
|
||||
|
||||
import type { SessionActionDispatchRequest } from '../../../types/runtime';
|
||||
import { execPluginCommand } from './exec';
|
||||
import { isValidPluginName, parsePluginManifest, resolvePluginEntryPath } from './manifest';
|
||||
import { isValidPluginName, parsePluginManifest, resolvePluginFilePath } from './manifest';
|
||||
import {
|
||||
PLUGIN_MPV_EVENT_NAMES,
|
||||
type PluginConfigEntry,
|
||||
@@ -16,9 +19,12 @@ import {
|
||||
type PluginPanelHandle,
|
||||
type PluginSubtitleContext,
|
||||
type PluginSubtitleLine,
|
||||
type PluginWindowRequest,
|
||||
} from './types';
|
||||
|
||||
const MIN_TIMER_INTERVAL_MS = 100;
|
||||
const DEFAULT_WINDOW_WIDTH = 960;
|
||||
const DEFAULT_WINDOW_HEIGHT = 720;
|
||||
|
||||
interface MpvClientEvents {
|
||||
on: (event: PluginMpvEventName, listener: (payload: unknown) => void) => void;
|
||||
@@ -27,15 +33,21 @@ interface MpvClientEvents {
|
||||
|
||||
export interface PluginHostDeps<TUnstable> {
|
||||
pluginsDir: string;
|
||||
/** Root of the per-plugin data folders; each plugin gets `<pluginDataDir>/<id>`. */
|
||||
pluginDataDir: string;
|
||||
getPluginConfig: () => Record<string, PluginConfigEntry>;
|
||||
createLogger: (scope: string) => PluginLogger;
|
||||
isMpvConnected: () => boolean;
|
||||
sendMpvCommand: (command: (string | number)[]) => void;
|
||||
requestMpvProperty: (name: string) => Promise<unknown>;
|
||||
ensureMpvRunning: () => Promise<boolean>;
|
||||
dispatchSessionAction: (request: SessionActionDispatchRequest) => Promise<void>;
|
||||
getCurrentSubtitle: () => PluginSubtitleLine | null;
|
||||
getSubtitleContext: (options: { before?: number; after?: number }) => PluginSubtitleContext;
|
||||
notify: (message: string, variant: PluginNotifyVariant) => void;
|
||||
openPanel: (content: PluginPanelContent) => PluginPanelHandle;
|
||||
/** Focuses the window already open under `request.key`, or creates it (`created: true`). */
|
||||
openWindow: (request: PluginWindowRequest) => { window: BrowserWindow; created: boolean };
|
||||
unstable: TUnstable;
|
||||
/** Test seam; defaults to Node's `require`, which also loads synchronous ES modules. */
|
||||
loadModule?: (entryPath: string) => PluginModule;
|
||||
@@ -106,6 +118,7 @@ export function createPluginHost<TUnstable>(deps: PluginHostDeps<TUnstable>) {
|
||||
function createContext(
|
||||
plugin: LoadedPlugin,
|
||||
dir: string,
|
||||
dataDir: string,
|
||||
entry: PluginConfigEntry,
|
||||
log: PluginLogger,
|
||||
): PluginContext<TUnstable> {
|
||||
@@ -125,6 +138,7 @@ export function createPluginHost<TUnstable>(deps: PluginHostDeps<TUnstable>) {
|
||||
id: plugin.id,
|
||||
dir,
|
||||
settings: Object.freeze({ ...entry.settings }),
|
||||
dataDir,
|
||||
log,
|
||||
every: (intervalMs, callback) => {
|
||||
const handle = setInterval(
|
||||
@@ -158,6 +172,7 @@ export function createPluginHost<TUnstable>(deps: PluginHostDeps<TUnstable>) {
|
||||
},
|
||||
command: (command) => deps.sendMpvCommand(command),
|
||||
getProperty: (name) => deps.requestMpvProperty(name),
|
||||
ensureRunning: () => deps.ensureMpvRunning(),
|
||||
},
|
||||
subtitles: {
|
||||
current: () => deps.getCurrentSubtitle(),
|
||||
@@ -175,6 +190,8 @@ export function createPluginHost<TUnstable>(deps: PluginHostDeps<TUnstable>) {
|
||||
actions.set(actionId, { pluginId: plugin.id, handler });
|
||||
return track(() => actions.delete(actionId));
|
||||
},
|
||||
run: (actionId, payload) =>
|
||||
deps.dispatchSessionAction(payload === undefined ? { actionId } : { actionId, payload }),
|
||||
},
|
||||
notify: (message, options) => deps.notify(message, options?.variant ?? 'info'),
|
||||
ui: {
|
||||
@@ -187,6 +204,34 @@ export function createPluginHost<TUnstable>(deps: PluginHostDeps<TUnstable>) {
|
||||
handle.signal.addEventListener('abort', dispose, { once: true });
|
||||
return handle;
|
||||
},
|
||||
openWindow: (options) => {
|
||||
const resolveFile = (label: string, file: string): string => {
|
||||
const resolved = resolvePluginFilePath(dir, file);
|
||||
if (!resolved) throw new Error(`Window ${label} "${file}" must stay inside ${dir}.`);
|
||||
return resolved;
|
||||
};
|
||||
const htmlPath = resolveFile('html', options.html);
|
||||
const { window, created } = deps.openWindow({
|
||||
key: `${plugin.id}:${htmlPath}`,
|
||||
htmlPath,
|
||||
preloadPath:
|
||||
options.preload === undefined ? null : resolveFile('preload', options.preload),
|
||||
title: options.title ?? plugin.id,
|
||||
width: options.width ?? DEFAULT_WINDOW_WIDTH,
|
||||
height: options.height ?? DEFAULT_WINDOW_HEIGHT,
|
||||
});
|
||||
if (created) {
|
||||
let closed = false;
|
||||
const dispose = track(() => {
|
||||
if (!closed && !window.isDestroyed()) window.destroy();
|
||||
});
|
||||
window.once('closed', () => {
|
||||
closed = true;
|
||||
dispose();
|
||||
});
|
||||
}
|
||||
return window;
|
||||
},
|
||||
},
|
||||
exec: (command, args, options = {}) =>
|
||||
exec(command, args, {
|
||||
@@ -231,12 +276,20 @@ export function createPluginHost<TUnstable>(deps: PluginHostDeps<TUnstable>) {
|
||||
log.error(parsed.error);
|
||||
return;
|
||||
}
|
||||
const entryPath = resolvePluginEntryPath(dir, parsed.manifest.main);
|
||||
const entryPath = resolvePluginFilePath(dir, parsed.manifest.main);
|
||||
if (!entryPath) {
|
||||
log.error(`Entry "${parsed.manifest.main}" must stay inside ${dir}.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const dataDir = path.join(deps.pluginDataDir, id);
|
||||
try {
|
||||
await fs.mkdir(dataDir, { recursive: true });
|
||||
} catch (error) {
|
||||
log.error(`Cannot create data folder ${dataDir}: ${errorMessage(error)}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const plugin: LoadedPlugin = {
|
||||
id,
|
||||
disposers: new Set(),
|
||||
@@ -250,7 +303,9 @@ export function createPluginHost<TUnstable>(deps: PluginHostDeps<TUnstable>) {
|
||||
if (!activate) {
|
||||
throw new Error(`${parsed.manifest.main} does not export an activate(ctx) function.`);
|
||||
}
|
||||
const returned = await activate(createContext(plugin, dir, entry, log) as PluginContext);
|
||||
const returned = await activate(
|
||||
createContext(plugin, dir, dataDir, entry, log) as PluginContext,
|
||||
);
|
||||
plugin.deactivate =
|
||||
typeof returned === 'function'
|
||||
? (returned as () => unknown)
|
||||
@@ -308,8 +363,12 @@ export function createPluginHost<TUnstable>(deps: PluginHostDeps<TUnstable>) {
|
||||
);
|
||||
},
|
||||
|
||||
/** Runs a registered `<plugin id>/<name>` action. Returns false when none is registered. */
|
||||
runAction(actionId: string): boolean {
|
||||
/**
|
||||
* Runs a registered `<plugin id>/<name>` action, waiting for an in-progress load first so a
|
||||
* command sent at startup is not lost. Resolves false when no plugin registered the action.
|
||||
*/
|
||||
async runAction(actionId: string): Promise<boolean> {
|
||||
await loadPromise;
|
||||
const action = actions.get(actionId);
|
||||
if (!action) return false;
|
||||
runGuarded(
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import type { BrowserWindow } from 'electron';
|
||||
|
||||
import type { SessionActionId, SessionActionPayload } from '../../../types/session-bindings';
|
||||
import type { MpvIpcClientEventMap } from '../mpv';
|
||||
|
||||
/** `plugin.json` at the root of a plugin directory. */
|
||||
@@ -36,6 +39,7 @@ export const PLUGIN_MPV_EVENT_NAMES = [
|
||||
'media-title-change',
|
||||
'secondary-subtitle-visibility',
|
||||
'client-message',
|
||||
'end-file',
|
||||
] as const satisfies readonly PluginMpvEventName[];
|
||||
|
||||
export interface PluginSubtitleLine {
|
||||
@@ -66,6 +70,28 @@ export interface PluginPanelHandle {
|
||||
close: () => void;
|
||||
}
|
||||
|
||||
export interface PluginWindowOptions {
|
||||
/** HTML file to load, relative to the plugin directory. */
|
||||
html: string;
|
||||
/** Preload script, relative to the plugin directory. Runs with context isolation. */
|
||||
preload?: string;
|
||||
/** Window title; Hyprland matches it to float the window above the overlay. */
|
||||
title?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
}
|
||||
|
||||
/** What the host needs to show a plugin window; paths are absolute and already validated. */
|
||||
export interface PluginWindowRequest {
|
||||
/** Same key, same window: reopening focuses the existing one. */
|
||||
key: string;
|
||||
htmlPath: string;
|
||||
preloadPath: string | null;
|
||||
title: string;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface PluginExecOptions {
|
||||
/** Written to the child's stdin, which is then closed. */
|
||||
stdin?: string;
|
||||
@@ -103,6 +129,11 @@ export interface PluginContext<TUnstable = unknown> {
|
||||
readonly dir: string;
|
||||
/** `plugins.<id>.settings` from config. */
|
||||
readonly settings: Readonly<Record<string, unknown>>;
|
||||
/**
|
||||
* `<config dir>/plugin-data/<id>/` for caches and state. Kept apart from `dir`, so replacing the
|
||||
* plugin folder keeps its data. Created before `activate`.
|
||||
*/
|
||||
readonly dataDir: string;
|
||||
readonly log: PluginLogger;
|
||||
/** Repeating timer; cleared automatically when the plugin unloads. */
|
||||
every: (intervalMs: number, callback: () => unknown) => Disposer;
|
||||
@@ -116,6 +147,8 @@ export interface PluginContext<TUnstable = unknown> {
|
||||
) => Disposer;
|
||||
command: (command: (string | number)[]) => void;
|
||||
getProperty: (name: string) => Promise<unknown>;
|
||||
/** Connects to mpv, launching an idle mpv if none is running. Resolves false when that fails. */
|
||||
ensureRunning: () => Promise<boolean>;
|
||||
};
|
||||
readonly subtitles: {
|
||||
current: () => PluginSubtitleLine | null;
|
||||
@@ -124,11 +157,18 @@ export interface PluginContext<TUnstable = unknown> {
|
||||
readonly actions: {
|
||||
/** Bind with `["__plugin-action:<plugin id>/<name>"]` in `keybindings`. */
|
||||
register: (name: string, handler: () => unknown) => Disposer;
|
||||
/** Runs a built-in session action, the same ones keybindings use (e.g. `openJimaku`). */
|
||||
run: (actionId: SessionActionId, payload?: SessionActionPayload) => Promise<void>;
|
||||
};
|
||||
notify: (message: string, options?: { variant?: PluginNotifyVariant }) => void;
|
||||
readonly ui: {
|
||||
/** Opens the shared plugin panel, replacing any panel that is already open. */
|
||||
panel: (content: Partial<PluginPanelContent> & { markdown: string }) => PluginPanelHandle;
|
||||
/**
|
||||
* Opens a window for this plugin, or focuses it if the same `html` is already open.
|
||||
* The window is destroyed when the plugin unloads.
|
||||
*/
|
||||
openWindow: (options: PluginWindowOptions) => BrowserWindow;
|
||||
};
|
||||
/** Runs a binary without a shell, with the AppImage library path removed from its environment. */
|
||||
exec: (command: string, args: string[], options?: PluginExecOptions) => Promise<PluginExecResult>;
|
||||
|
||||
@@ -55,7 +55,7 @@ function createDeps(overrides: Partial<SessionActionExecutorDeps> = {}) {
|
||||
playNextSubtitle: () => calls.push('play-next'),
|
||||
cycleRuntimeOption: () => ({ ok: true }),
|
||||
playNextPlaylistItem: () => calls.push('playlist-next'),
|
||||
runPluginAction: (pluginActionId) => {
|
||||
runPluginAction: async (pluginActionId) => {
|
||||
calls.push(`plugin:${pluginActionId}`);
|
||||
return pluginActionId === 'ai-explain/explain';
|
||||
},
|
||||
|
||||
@@ -33,7 +33,7 @@ export interface SessionActionExecutorDeps {
|
||||
cycleRuntimeOption: (id: RuntimeOptionId, direction: 1 | -1) => RuntimeOptionApplyResult;
|
||||
playNextPlaylistItem: () => void;
|
||||
/** Returns false when no loaded plugin registered the action. */
|
||||
runPluginAction: (pluginActionId: string) => boolean;
|
||||
runPluginAction: (pluginActionId: string) => Promise<boolean>;
|
||||
showMpvOsd: (text: string) => void;
|
||||
}
|
||||
|
||||
@@ -155,7 +155,7 @@ export async function dispatchSessionAction(
|
||||
}
|
||||
case 'runPluginAction': {
|
||||
const pluginActionId = request.payload?.pluginActionId;
|
||||
if (!pluginActionId || !deps.runPluginAction(pluginActionId)) {
|
||||
if (!pluginActionId || !(await deps.runPluginAction(pluginActionId))) {
|
||||
deps.showMpvOsd(`Plugin action not available: ${pluginActionId ?? 'unknown'}`);
|
||||
}
|
||||
return;
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
buildWindowsShimInvocation,
|
||||
isWindowsCommandShim,
|
||||
resolveWindowsCommand,
|
||||
} from './windows-command-shim';
|
||||
|
||||
test('resolves a bare name through PATH and PATHEXT in order', () => {
|
||||
const npmDir = path.join('/fake', 'npm');
|
||||
const binDir = path.join('/fake', 'bin');
|
||||
const existing = new Set([path.join(npmDir, 'codex.CMD'), path.join(binDir, 'codex.EXE')]);
|
||||
const env = { PATH: [npmDir, binDir].join(path.delimiter), PATHEXT: '.EXE;.CMD' };
|
||||
|
||||
assert.equal(
|
||||
resolveWindowsCommand('codex', env, (candidate) => existing.has(candidate)),
|
||||
path.join(npmDir, 'codex.CMD'),
|
||||
);
|
||||
assert.equal(
|
||||
resolveWindowsCommand('codex.exe', env, () => true),
|
||||
'codex.exe',
|
||||
'commands with an extension are left alone',
|
||||
);
|
||||
assert.equal(
|
||||
resolveWindowsCommand('missing', env, () => false),
|
||||
'missing',
|
||||
);
|
||||
});
|
||||
|
||||
test('resolves an extensionless path in its own directory', () => {
|
||||
const shim = path.join('/fake', 'npm', 'codex');
|
||||
assert.equal(
|
||||
resolveWindowsCommand(shim, { PATHEXT: '.CMD' }, (candidate) => candidate === `${shim}.CMD`),
|
||||
`${shim}.CMD`,
|
||||
);
|
||||
});
|
||||
|
||||
test('recognizes batch shims only', () => {
|
||||
assert.equal(isWindowsCommandShim('C:\\npm\\codex.cmd'), true);
|
||||
assert.equal(isWindowsCommandShim('run.BAT'), true);
|
||||
assert.equal(isWindowsCommandShim('codex.exe'), false);
|
||||
});
|
||||
|
||||
test('quotes the shim call for cmd.exe', () => {
|
||||
const invocation = buildWindowsShimInvocation(
|
||||
'C:\\npm\\codex.cmd',
|
||||
['-C', 'C:\\Temp dir\\a&b', '-c', 'effort="low"'],
|
||||
{ ComSpec: 'C:\\Windows\\system32\\cmd.exe' },
|
||||
);
|
||||
|
||||
assert.deepEqual(invocation, {
|
||||
file: 'C:\\Windows\\system32\\cmd.exe',
|
||||
args: [
|
||||
'/d',
|
||||
'/s',
|
||||
'/v:off',
|
||||
'/c',
|
||||
'"^"C:\\npm\\codex.cmd^" ^"-C^" ^"C:\\Temp^ dir\\a^&b^" ^"-c^" ^"effort=\\^"low\\^"^""',
|
||||
],
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
/** True for batch shims (`.cmd`/`.bat`), which Windows only runs through cmd.exe. */
|
||||
export function isWindowsCommandShim(command: string): boolean {
|
||||
return /\.(cmd|bat)$/i.test(command);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves an extensionless Windows command the way cmd.exe does: each `PATHEXT` extension in
|
||||
* the command's own directory, or in each `PATH` entry for a bare name. Returns `command`
|
||||
* unchanged when it already has an extension or nothing matches.
|
||||
*/
|
||||
export function resolveWindowsCommand(
|
||||
command: string,
|
||||
env: NodeJS.ProcessEnv,
|
||||
existsSync: (candidate: string) => boolean = fs.existsSync,
|
||||
): string {
|
||||
if (path.extname(command)) return command;
|
||||
const directory = path.dirname(command);
|
||||
const directories =
|
||||
directory === '.' ? (env.PATH ?? env.Path ?? '').split(path.delimiter).filter(Boolean) : [''];
|
||||
const extensions = (env.PATHEXT ?? '.COM;.EXE;.BAT;.CMD').split(';').filter(Boolean);
|
||||
for (const entry of directories) {
|
||||
for (const extension of extensions) {
|
||||
const candidate = entry ? path.join(entry, command + extension) : command + extension;
|
||||
if (existsSync(candidate)) return candidate;
|
||||
}
|
||||
}
|
||||
return 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');
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the cmd.exe invocation that runs a batch shim with `args`. Spawn the result with
|
||||
* `windowsVerbatimArguments: true` so Node does not re-quote the prepared command line.
|
||||
*/
|
||||
export function buildWindowsShimInvocation(
|
||||
command: string,
|
||||
args: readonly string[],
|
||||
env: NodeJS.ProcessEnv,
|
||||
): { file: string; args: string[] } {
|
||||
const commandLine = [command, ...args].map(escapeWindowsShellArgument).join(' ');
|
||||
return {
|
||||
file: env.ComSpec ?? env.COMSPEC ?? process.env.ComSpec ?? 'cmd.exe',
|
||||
args: ['/d', '/s', '/v:off', '/c', `"${commandLine}"`],
|
||||
};
|
||||
}
|
||||
+16
-1
@@ -519,6 +519,7 @@ import {
|
||||
createPluginPanelRuntime,
|
||||
openPluginPanelModal,
|
||||
} from './main/runtime/plugin-panel-runtime';
|
||||
import { createPluginWindowRuntime } from './main/runtime/plugin-window-runtime';
|
||||
import { openCharacterDictionaryManagerModal as openCharacterDictionaryManagerModalRuntime } from './main/runtime/character-dictionary-open';
|
||||
import { openControllerSelectModal as openControllerSelectModalRuntime } from './main/runtime/controller-select-open';
|
||||
import { openControllerDebugModal as openControllerDebugModalRuntime } from './main/runtime/controller-debug-open';
|
||||
@@ -3128,8 +3129,15 @@ function getPluginCurrentSubtitle(): PluginSubtitleLine | null {
|
||||
};
|
||||
}
|
||||
|
||||
const pluginWindowRuntime = createPluginWindowRuntime({
|
||||
createBrowserWindow: (options) => new BrowserWindow(options),
|
||||
promoteAboveOverlay: (window) => promoteSettingsWindowAboveOverlay(window),
|
||||
logError: (message) => logger.error(message),
|
||||
});
|
||||
|
||||
const pluginHost = createPluginHost({
|
||||
pluginsDir: path.join(CONFIG_DIR, 'plugins'),
|
||||
pluginDataDir: path.join(USER_DATA_PATH, 'plugin-data'),
|
||||
getPluginConfig: () => configService.getConfig().plugins,
|
||||
createLogger,
|
||||
isMpvConnected: () => appState.mpvClient?.connected === true,
|
||||
@@ -3138,6 +3146,8 @@ const pluginHost = createPluginHost({
|
||||
if (!appState.mpvClient) throw new Error('mpv is not connected.');
|
||||
return await appState.mpvClient.requestProperty(name);
|
||||
},
|
||||
ensureMpvRunning: () => ensureMpvConnectedForPlayback(),
|
||||
dispatchSessionAction: (request) => dispatchSessionAction(request),
|
||||
getCurrentSubtitle: () => getPluginCurrentSubtitle(),
|
||||
getSubtitleContext: (options) =>
|
||||
buildPluginSubtitleContext({
|
||||
@@ -3153,6 +3163,7 @@ const pluginHost = createPluginHost({
|
||||
notify: (message, variant) =>
|
||||
overlayNotificationsRuntime.showConfiguredStatusNotification(message, { variant }),
|
||||
openPanel: (content) => pluginPanelRuntime.open(content),
|
||||
openWindow: (request) => pluginWindowRuntime.open(request),
|
||||
unstable: { appState, configService, overlayManager },
|
||||
});
|
||||
|
||||
@@ -5942,7 +5953,11 @@ async function dispatchSessionAction(request: SessionActionDispatchRequest): Pro
|
||||
},
|
||||
playNextPlaylistItem: () =>
|
||||
sendMpvCommandRuntime(appState.mpvClient, ['playlist-next', 'force']),
|
||||
runPluginAction: (pluginActionId) => pluginHost.runAction(pluginActionId),
|
||||
runPluginAction: async (pluginActionId) => {
|
||||
const ran = await pluginHost.runAction(pluginActionId);
|
||||
if (!ran) logger.warn(`Plugin action is not registered: ${pluginActionId}`);
|
||||
return ran;
|
||||
},
|
||||
showMpvOsd: (text) => showConfiguredPlaybackFeedback(text),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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