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:
sudacode committed 2026-10-26 22:40:25 -07:00
1 parent 7b92511f23
commit d5fb43caa3
28 files changed
+857 -91

No files matched your search

+1 -1
View File
@@ -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
View File
@@ -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`.
+1
View File
@@ -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
```
+1 -1
View File
@@ -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.
+1 -1
View File
@@ -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/`
+67
View File
@@ -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' },
);
});
+8
View File
@@ -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);
+12
View File
@@ -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 &&
+1
View File
@@ -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
+17
View File
@@ -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({
+6
View File
@@ -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) {
+16
View File
@@ -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 },
]);
});
+25
View File
@@ -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()) {
+5
View File
@@ -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);
},
};
}
+30 -2
View File
@@ -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 });
}
});
+49 -5
View File
@@ -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');
+3 -3
View File
@@ -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;
+157 -9
View File
@@ -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();
}
});
+64 -5
View File
@@ -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(
+40
View File
@@ -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>;
+1 -1
View File
@@ -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';
},
+2 -2
View File
@@ -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\\^"^""',
],
});
});
+83
View File
@@ -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
View File
@@ -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),
});
}
+8 -43
View File
@@ -2,6 +2,11 @@ import { spawn } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import {
buildWindowsShimInvocation,
isWindowsCommandShim,
} from '../../core/utils/windows-command-shim';
export interface RunCommandResult {
exitCode: number | null;
stdout: string;
@@ -142,43 +147,7 @@ export function failureMessage(result: RunCommandResult, fallback: string): stri
}
function needsWindowsShell(command: string): boolean {
return process.platform === 'win32' && /\.(cmd|bat)$/i.test(command);
}
/*!
* Windows command escaping adapted from cross-spawn 7.0.6.
*
* The MIT License (MIT)
*
* Copyright (c) 2018 Made With MOXY Lda <hello@moxy.studio>
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
* THE SOFTWARE.
*/
const WINDOWS_SHELL_META_CHARACTERS = /([()\][%!^"`<>&|;, *?])/g;
// Quote for both cmd.exe and the Windows argv parser. The outer caret escapes
// are consumed by cmd, leaving the quoted argument unchanged for the command.
function escapeWindowsShellArgument(value: string): string {
const quotesEscaped = value
.replace(/(?=(\\+?)?)\1"/g, '$1$1\\"')
.replace(/(?=(\\+?)?)\1$/, '$1$1');
return `"${quotesEscaped}"`.replace(WINDOWS_SHELL_META_CHARACTERS, '^$1');
return process.platform === 'win32' && isWindowsCommandShim(command);
}
function createDefaultRunCommand(): RunCommand {
@@ -189,12 +158,8 @@ function createDefaultRunCommand(): RunCommand {
try {
const env = options.env ?? process.env;
if (useShell) {
const shellCommand = [
escapeWindowsShellArgument(command),
...args.map(escapeWindowsShellArgument),
].join(' ');
const commandProcessor = env.ComSpec ?? env.COMSPEC ?? process.env.ComSpec ?? 'cmd.exe';
child = spawn(commandProcessor, ['/d', '/s', '/v:off', '/c', `"${shellCommand}"`], {
const invocation = buildWindowsShimInvocation(command, args, env);
child = spawn(invocation.file, invocation.args, {
env,
windowsHide: false,
windowsVerbatimArguments: true,
@@ -0,0 +1,91 @@
import assert from 'node:assert/strict';
import { EventEmitter } from 'node:events';
import test from 'node:test';
import type { BrowserWindowConstructorOptions } from 'electron';
import { createPluginWindowRuntime } from './plugin-window-runtime';
function createFakeWindow(loadResult: Promise<void>) {
const events = new EventEmitter();
const calls: string[] = [];
const window = {
calls,
destroyed: false,
isDestroyed: () => window.destroyed,
show: () => calls.push('show'),
focus: () => calls.push('focus'),
loadFile: (filePath: string) => {
calls.push(`load:${filePath}`);
return loadResult;
},
destroy: () => {
window.destroyed = true;
events.emit('closed');
},
once: (event: 'closed', listener: () => void) => events.once(event, listener),
};
return window;
}
const request = {
key: 'viewer:/plugins/viewer/index.html',
htmlPath: '/plugins/viewer/index.html',
preloadPath: '/plugins/viewer/preload.js',
title: 'Viewer',
width: 400,
height: 300,
};
test('reopening a key focuses the open window and a closed window is recreated', () => {
const created: Array<ReturnType<typeof createFakeWindow>> = [];
const options: BrowserWindowConstructorOptions[] = [];
const promoted: unknown[] = [];
const runtime = createPluginWindowRuntime({
createBrowserWindow: (windowOptions) => {
options.push(windowOptions);
const window = createFakeWindow(Promise.resolve());
created.push(window);
return window;
},
promoteAboveOverlay: (window) => promoted.push(window),
logError: () => {},
});
const first = runtime.open(request);
const again = runtime.open(request);
assert.equal(first.created, true);
assert.equal(again.created, false);
assert.equal(again.window, first.window);
assert.deepEqual(first.window.calls, [
'load:/plugins/viewer/index.html',
'show',
'focus',
'show',
'focus',
]);
assert.equal(promoted.length, 2);
assert.equal(options[0]?.webPreferences?.preload, '/plugins/viewer/preload.js');
assert.equal(options[0]?.webPreferences?.contextIsolation, true);
first.window.destroy();
const reopened = runtime.open(request);
assert.equal(reopened.created, true);
assert.equal(created.length, 2);
});
test('a window whose page fails to load is destroyed and logged', async () => {
const errors: string[] = [];
const window = createFakeWindow(Promise.reject(new Error('ENOENT')));
const runtime = createPluginWindowRuntime({
createBrowserWindow: () => window,
promoteAboveOverlay: () => {},
logError: (message) => errors.push(message),
});
runtime.open(request);
await new Promise((resolve) => setImmediate(resolve));
assert.equal(window.destroyed, true);
assert.deepEqual(errors, ['Failed to load plugin window /plugins/viewer/index.html: ENOENT']);
});
+64
View File
@@ -0,0 +1,64 @@
import type { BrowserWindowConstructorOptions } from 'electron';
import type { PluginWindowRequest } from '../../core/services/plugins/types';
interface PluginWindowLike {
isDestroyed(): boolean;
show(): void;
focus(): void;
loadFile(filePath: string): Promise<void>;
destroy(): void;
once(event: 'closed', listener: () => void): unknown;
}
/**
* Creates plugin windows and keeps one per request key, so a plugin that reopens the same page
* focuses the existing window. Windows are raised above the overlay like the settings windows.
*/
export function createPluginWindowRuntime<TWindow extends PluginWindowLike>(deps: {
createBrowserWindow: (options: BrowserWindowConstructorOptions) => TWindow;
promoteAboveOverlay: (window: TWindow) => void;
logError: (message: string) => void;
}) {
const windows = new Map<string, TWindow>();
const showAndFocus = (window: TWindow): void => {
window.show();
window.focus();
deps.promoteAboveOverlay(window);
};
return {
open(request: PluginWindowRequest): { window: TWindow; created: boolean } {
const existing = windows.get(request.key);
if (existing && !existing.isDestroyed()) {
showAndFocus(existing);
return { window: existing, created: false };
}
const window = deps.createBrowserWindow({
width: request.width,
height: request.height,
title: request.title,
show: false,
autoHideMenuBar: true,
webPreferences: {
nodeIntegration: false,
contextIsolation: true,
...(request.preloadPath ? { preload: request.preloadPath } : {}),
},
});
windows.set(request.key, window);
window.once('closed', () => {
if (windows.get(request.key) === window) windows.delete(request.key);
});
window.loadFile(request.htmlPath).catch((error: unknown) => {
const message = error instanceof Error ? error.message : String(error);
deps.logError(`Failed to load plugin window ${request.htmlPath}: ${message}`);
if (!window.isDestroyed()) window.destroy();
});
showAndFocus(window);
return { window, created: true };
},
};
}