From d5fb43caa3b6c16b11b31952ca035484e3e73994 Mon Sep 17 00:00:00 2001 From: sudacode Date: Mon, 26 Oct 2026 22:40:25 -0700 Subject: [PATCH] feat(plugins): add plugin windows, CLI actions, and data dirs - Add `--plugin-action /` CLI flag, dispatched via runPluginAction - Add ctx.ui.openWindow for plugin-owned BrowserWindows with sandboxed preload - Add ctx.dataDir (`plugin-data//`), 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 --- changes/plugin-system.md | 2 +- docs-site/plugins.md | 42 +++-- docs-site/usage.md | 1 + docs/architecture/README.md | 2 +- docs/architecture/domains.md | 2 +- e2e/plugins.e2e.test.ts | 67 +++++++ src/cli/args.test.ts | 8 + src/cli/args.ts | 12 ++ src/cli/help.ts | 1 + src/core/services/cli-command.test.ts | 17 ++ src/core/services/cli-command.ts | 6 + src/core/services/mpv-protocol.test.ts | 16 ++ src/core/services/mpv-protocol.ts | 25 +++ src/core/services/mpv.ts | 5 + src/core/services/plugins/exec.test.ts | 32 +++- src/core/services/plugins/exec.ts | 54 +++++- src/core/services/plugins/manifest.ts | 6 +- src/core/services/plugins/plugin-host.test.ts | 166 +++++++++++++++++- src/core/services/plugins/plugin-host.ts | 69 +++++++- src/core/services/plugins/types.ts | 40 +++++ src/core/services/session-actions.test.ts | 2 +- src/core/services/session-actions.ts | 4 +- src/core/utils/windows-command-shim.test.ts | 63 +++++++ src/core/utils/windows-command-shim.ts | 83 +++++++++ src/main.ts | 17 +- .../runtime/command-line-launcher-deps.ts | 51 +----- .../runtime/plugin-window-runtime.test.ts | 91 ++++++++++ src/main/runtime/plugin-window-runtime.ts | 64 +++++++ 28 files changed, 857 insertions(+), 91 deletions(-) create mode 100644 src/core/utils/windows-command-shim.test.ts create mode 100644 src/core/utils/windows-command-shim.ts create mode 100644 src/main/runtime/plugin-window-runtime.test.ts create mode 100644 src/main/runtime/plugin-window-runtime.ts diff --git a/changes/plugin-system.md b/changes/plugin-system.md index b04b2d93..7beea8fa 100644 --- a/changes/plugin-system.md +++ b/changes/plugin-system.md @@ -1,5 +1,5 @@ type: added area: plugins -- Added JavaScript plugins loaded at startup from `/plugins//` and enabled under `plugins` in the config. Plugins can listen to mpv events, run timers, register actions bound with `__plugin-action:/` keybindings, run programs, and show markdown results in an overlay panel. +- Added JavaScript plugins loaded at startup from `/plugins//` and enabled under `plugins` in the config. Plugins can listen to mpv events, run timers, register actions bound with `__plugin-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. diff --git a/docs-site/plugins.md b/docs-site/plugins.md index f5874c1f..f80e8bb5 100644 --- a/docs-site/plugins.md +++ b/docs-site/plugins.md @@ -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:` 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..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:/` | -| `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..settings` | +| `dataDir` | `plugin-data//` 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:/` | +| `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`. diff --git a/docs-site/usage.md b/docs-site/usage.md index 674c21d1..f32dcb6f 100644 --- a/docs-site/usage.md +++ b/docs-site/usage.md @@ -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 / # run a plugin action subminer -u # update SubMiner ``` diff --git a/docs/architecture/README.md b/docs/architecture/README.md index e0567225..5b813e2c 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -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 `/plugins//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:/`). `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 `/plugins//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:/`, CLI `--plugin-action /`); `runAction` waits for an in-progress load so a cold-start CLI action is not dropped. Each plugin gets `/plugin-data//` 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. diff --git a/docs/architecture/domains.md b/docs/architecture/domains.md index 13cf2db1..be08cd20 100644 --- a/docs/architecture/domains.md +++ b/docs/architecture/domains.md @@ -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/` diff --git a/e2e/plugins.e2e.test.ts b/e2e/plugins.e2e.test.ts index 5647653c..e8233f03 100644 --- a/e2e/plugins.e2e.test.ts +++ b/e2e/plugins.e2e.test.ts @@ -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 = ` + + E2E Probe Window + +
loading
+ + + +`; + type Started = { session: E2eSession; overlay: CdpPage }; let started: Promise | undefined; @@ -51,6 +78,8 @@ function useSession(): Promise { 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' }, + ); +}); diff --git a/src/cli/args.test.ts b/src/cli/args.test.ts index 4f5e6143..fedbe1e5 100644 --- a/src/cli/args.test.ts +++ b/src/cli/args.test.ts @@ -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); diff --git a/src/cli/args.ts b/src/cli/args.ts index da3579a2..f81c6bd3 100644 --- a/src/cli/args.ts +++ b/src/cli/args.ts @@ -48,6 +48,8 @@ export interface CliArgs { cycleRuntimeOptionId?: string; cycleRuntimeOptionDirection?: 1 | -1; sessionAction?: SessionActionDispatchRequest; + /** `/` 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 && diff --git a/src/cli/help.ts b/src/cli/help.ts index b3223e76..a7af588d 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -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}/${R} Run a plugin action ${B}AniList${R} --anilist-setup Open AniList authentication flow diff --git a/src/core/services/cli-command.test.ts b/src/core/services/cli-command.test.ts index f935ae4c..fc1b8689 100644 --- a/src/core/services/cli-command.test.ts +++ b/src/core/services/cli-command.test.ts @@ -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({ diff --git a/src/core/services/cli-command.ts b/src/core/services/cli-command.ts index d91e1bd9..17e9bbd2 100644 --- a/src/core/services/cli-command.ts +++ b/src/core/services/cli-command.ts @@ -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) { diff --git a/src/core/services/mpv-protocol.test.ts b/src/core/services/mpv-protocol.test.ts index 4cada30f..eb9df2ef 100644 --- a/src/core/services/mpv-protocol.test.ts +++ b/src/core/services/mpv-protocol.test.ts @@ -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 }, + ]); +}); diff --git a/src/core/services/mpv-protocol.ts b/src/core/services/mpv-protocol.ts index 36cbc351..a3645824 100644 --- a/src/core/services/mpv-protocol.ts +++ b/src/core/services/mpv-protocol.ts @@ -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()) { diff --git a/src/core/services/mpv.ts b/src/core/services/mpv.ts index 5f3e46f7..cf390064 100644 --- a/src/core/services/mpv.ts +++ b/src/core/services/mpv.ts @@ -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 }; '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); + }, }; } diff --git a/src/core/services/plugins/exec.test.ts b/src/core/services/plugins/exec.test.ts index d28bf67a..aa0d381a 100644 --- a/src/core/services/plugins/exec.test.ts +++ b/src/core/services/plugins/exec.test.ts @@ -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 }); + } +}); diff --git a/src/core/services/plugins/exec.ts b/src/core/services/plugins/exec.ts index f2a7f6fc..b2e8d88e 100644 --- a/src/core/services/plugins/exec.ts +++ b/src/core/services/plugins/exec.ts @@ -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'); diff --git a/src/core/services/plugins/manifest.ts b/src/core/services/plugins/manifest.ts index 8367d9e7..8beeb4b1 100644 --- a/src/core/services/plugins/manifest.ts +++ b/src/core/services/plugins/manifest.ts @@ -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; diff --git a/src/core/services/plugins/plugin-host.test.ts b/src/core/services/plugins/plugin-host.test.ts index 67ee86cf..a0ab1a6b 100644 --- a/src/core/services/plugins/plugin-host.test.ts +++ b/src/core/services/plugins/plugin-host.test.ts @@ -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): void { const dir = path.join(root, id); @@ -16,10 +24,31 @@ function writePlugin(root: string, id: string, files: Record): v } } +type FakeWindow = ReturnType; + +/** 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) { 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(); const logger: PluginLogger = { debug: () => {}, info: () => {}, @@ -27,24 +56,41 @@ function createHarness(config: Record) { 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(); + } +}); diff --git a/src/core/services/plugins/plugin-host.ts b/src/core/services/plugins/plugin-host.ts index 6eaffb42..b325bda4 100644 --- a/src/core/services/plugins/plugin-host.ts +++ b/src/core/services/plugins/plugin-host.ts @@ -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 { pluginsDir: string; + /** Root of the per-plugin data folders; each plugin gets `/`. */ + pluginDataDir: string; getPluginConfig: () => Record; createLogger: (scope: string) => PluginLogger; isMpvConnected: () => boolean; sendMpvCommand: (command: (string | number)[]) => void; requestMpvProperty: (name: string) => Promise; + ensureMpvRunning: () => Promise; + dispatchSessionAction: (request: SessionActionDispatchRequest) => Promise; 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(deps: PluginHostDeps) { function createContext( plugin: LoadedPlugin, dir: string, + dataDir: string, entry: PluginConfigEntry, log: PluginLogger, ): PluginContext { @@ -125,6 +138,7 @@ export function createPluginHost(deps: PluginHostDeps) { id: plugin.id, dir, settings: Object.freeze({ ...entry.settings }), + dataDir, log, every: (intervalMs, callback) => { const handle = setInterval( @@ -158,6 +172,7 @@ export function createPluginHost(deps: PluginHostDeps) { }, command: (command) => deps.sendMpvCommand(command), getProperty: (name) => deps.requestMpvProperty(name), + ensureRunning: () => deps.ensureMpvRunning(), }, subtitles: { current: () => deps.getCurrentSubtitle(), @@ -175,6 +190,8 @@ export function createPluginHost(deps: PluginHostDeps) { 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(deps: PluginHostDeps) { 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(deps: PluginHostDeps) { 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(deps: PluginHostDeps) { 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(deps: PluginHostDeps) { ); }, - /** Runs a registered `/` action. Returns false when none is registered. */ - runAction(actionId: string): boolean { + /** + * Runs a registered `/` 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 { + await loadPromise; const action = actions.get(actionId); if (!action) return false; runGuarded( diff --git a/src/core/services/plugins/types.ts b/src/core/services/plugins/types.ts index f80c33ab..ffd096a2 100644 --- a/src/core/services/plugins/types.ts +++ b/src/core/services/plugins/types.ts @@ -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 { readonly dir: string; /** `plugins..settings` from config. */ readonly settings: Readonly>; + /** + * `/plugin-data//` 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 { ) => Disposer; command: (command: (string | number)[]) => void; getProperty: (name: string) => Promise; + /** Connects to mpv, launching an idle mpv if none is running. Resolves false when that fails. */ + ensureRunning: () => Promise; }; readonly subtitles: { current: () => PluginSubtitleLine | null; @@ -124,11 +157,18 @@ export interface PluginContext { readonly actions: { /** Bind with `["__plugin-action:/"]` 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; }; notify: (message: string, options?: { variant?: PluginNotifyVariant }) => void; readonly ui: { /** Opens the shared plugin panel, replacing any panel that is already open. */ panel: (content: Partial & { 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; diff --git a/src/core/services/session-actions.test.ts b/src/core/services/session-actions.test.ts index 6d1d8b60..e9b8e9d1 100644 --- a/src/core/services/session-actions.test.ts +++ b/src/core/services/session-actions.test.ts @@ -55,7 +55,7 @@ function createDeps(overrides: Partial = {}) { 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'; }, diff --git a/src/core/services/session-actions.ts b/src/core/services/session-actions.ts index 444f5011..44a10055 100644 --- a/src/core/services/session-actions.ts +++ b/src/core/services/session-actions.ts @@ -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; 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; diff --git a/src/core/utils/windows-command-shim.test.ts b/src/core/utils/windows-command-shim.test.ts new file mode 100644 index 00000000..b193d429 --- /dev/null +++ b/src/core/utils/windows-command-shim.test.ts @@ -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\\^"^""', + ], + }); +}); diff --git a/src/core/utils/windows-command-shim.ts b/src/core/utils/windows-command-shim.ts new file mode 100644 index 00000000..95c00b90 --- /dev/null +++ b/src/core/utils/windows-command-shim.ts @@ -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 + * + * 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}"`], + }; +} diff --git a/src/main.ts b/src/main.ts index 149fc663..5411bc93 100644 --- a/src/main.ts +++ b/src/main.ts @@ -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), }); } diff --git a/src/main/runtime/command-line-launcher-deps.ts b/src/main/runtime/command-line-launcher-deps.ts index 58f01693..0f88e5df 100644 --- a/src/main/runtime/command-line-launcher-deps.ts +++ b/src/main/runtime/command-line-launcher-deps.ts @@ -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 - * - * 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, diff --git a/src/main/runtime/plugin-window-runtime.test.ts b/src/main/runtime/plugin-window-runtime.test.ts new file mode 100644 index 00000000..4b61fb88 --- /dev/null +++ b/src/main/runtime/plugin-window-runtime.test.ts @@ -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) { + 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> = []; + 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']); +}); diff --git a/src/main/runtime/plugin-window-runtime.ts b/src/main/runtime/plugin-window-runtime.ts new file mode 100644 index 00000000..cb0ef71c --- /dev/null +++ b/src/main/runtime/plugin-window-runtime.ts @@ -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; + 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(deps: { + createBrowserWindow: (options: BrowserWindowConstructorOptions) => TWindow; + promoteAboveOverlay: (window: TWindow) => void; + logError: (message: string) => void; +}) { + const windows = new Map(); + + 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 }; + }, + }; +}