diff --git a/README.md b/README.md
index 1a677593..267677f9 100644
--- a/README.md
+++ b/README.md
@@ -102,6 +102,10 @@ A stats dashboard tracks watch time, vocabulary growth, mining throughput, sessi
WebSocket |
Plain subtitle feed plus a dedicated annotated feed for texthooker pages and custom tools |
+
+ | Plugins |
+ Load your own JavaScript at startup to react to playback, run timers, add key-bound actions, and show results in an overlay panel. The bundled example explains the current line with Codex. Plugin guide |
+
@@ -267,7 +271,7 @@ SubMiner builds on the work of these open-source projects:
| [jellyfin-mpv-shim](https://github.com/jellyfin/jellyfin-mpv-shim) | Jellyfin integration |
| [Jimaku.cc](https://jimaku.cc) | Japanese subtitle search and downloads |
| [Renji's Texthooker Page](https://github.com/Renji-XD/texthooker-ui) | Base for the WebSocket texthooker integration |
-| [Yomitan](https://github.com/yomidevs/yomitan) | Default dictionary engine and morphological parser |
+| [Yomitan](https://github.com/yomidevs/yomitan) | Default dictionary engine and morphological parser |
| [Hachidori](https://github.com/bee-san/hachidori) | Alternative dictionary backend, powered by HoshiDicts |
| [yomitan-jlpt-vocab](https://github.com/stephenmk/yomitan-jlpt-vocab) | JLPT level tags for vocabulary |
diff --git a/changes/plugin-system.md b/changes/plugin-system.md
new file mode 100644
index 00000000..b04b2d93
--- /dev/null
+++ b/changes/plugin-system.md
@@ -0,0 +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 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/config.example.jsonc b/config.example.jsonc
index e895f90d..adb6472a 100644
--- a/config.example.jsonc
+++ b/config.example.jsonc
@@ -218,6 +218,14 @@
"overlayPosition": "top-right" // Position for in-overlay notification cards. Values: top-left | top | top-right
}, // Overlay notification display behavior.
+ // ==========================================
+ // Plugins
+ // JavaScript plugins loaded from /plugins// at startup.
+ // Plugins run inside SubMiner with full access to your system. Only enable code you trust.
+ // Restart SubMiner after changing plugins.
+ // ==========================================
+ "plugins": {}, // Plugins to load at startup, keyed by directory name under /plugins/. Each entry is { "enabled": true, "settings": { ... } }; settings are passed to the plugin as ctx.settings.
+
// ==========================================
// Keyboard Shortcuts
// Overlay keyboard shortcuts. Set a shortcut to null to disable.
diff --git a/docs-site/.vitepress/config.ts b/docs-site/.vitepress/config.ts
index 42795fd1..84d0200b 100644
--- a/docs-site/.vitepress/config.ts
+++ b/docs-site/.vitepress/config.ts
@@ -221,6 +221,7 @@ const sidebar: DefaultTheme.SidebarItem[] = [
{ text: 'AniList', link: '/anilist-integration' },
{ text: 'AniSkip', link: '/aniskip-integration' },
{ text: 'Character Dictionary', link: '/character-dictionary' },
+ { text: 'Plugins', link: '/plugins' },
],
},
{
diff --git a/docs-site/configuration.md b/docs-site/configuration.md
index 3daf5c51..06bef814 100644
--- a/docs-site/configuration.md
+++ b/docs-site/configuration.md
@@ -106,6 +106,15 @@ Overlay notifications are also kept in a session-only history panel. Toggle it w
Mining and startup status notifications use `ankiConnect.behavior.notificationType` (see [AnkiConnect](#ankiconnect)).
+### Plugins
+
+`plugins` enables [plugins](/plugins) from the `plugins/` folder next to this config file. Each key is a plugin folder name. Changes apply after a restart.
+
+| Key | Default | What it does |
+| ----------------------- | ------- | -------------------------------------- |
+| `plugins..enabled` | `false` | Load the plugin at startup |
+| `plugins..settings` | `{}` | Passed to the plugin as `ctx.settings` |
+
### Auto-start overlay
When mpv is started by SubMiner or the `subminer` launcher, the launcher passes these settings to the bundled mpv plugin. There is no separate plugin config file. `mpv.autoStartSubMiner` and `mpv.pauseUntilOverlayReady` (see [MPV launcher](#mpv-launcher)) control the background start and the initial pause.
@@ -268,7 +277,7 @@ Adds a modal for choosing mpv's primary and secondary subtitle tracks. Open it w
- `key` uses `KeyboardEvent.code` names (`Space`, `KeyR`, `ArrowRight`) with optional `Ctrl+`, `Alt+`, `Shift+`, `Meta+`. Mouse buttons are `MBTN_LEFT`, `MBTN_MID`, `MBTN_RIGHT`, `MBTN_BACK`, `MBTN_FORWARD`. Scroll wheel keys are `WHEEL_UP`, `WHEEL_DOWN`, `WHEEL_LEFT`, `WHEEL_RIGHT`.
- `command` is any mpv JSON IPC command array. Set it to `null` to disable a default.
-- Commands starting with `__` run inside SubMiner: `__playlist-browser-open`, `__youtube-picker-open`, `__replay-subtitle`, `__play-next-subtitle`, `__runtime-options-open`, and `__runtime-option-cycle:[:next|prev]`.
+- Commands starting with `__` run inside SubMiner: `__playlist-browser-open`, `__youtube-picker-open`, `__replay-subtitle`, `__play-next-subtitle`, `__runtime-options-open`, `__runtime-option-cycle:[:next|prev]`, and `__plugin-action:/` (see [Plugins](/plugins)).
- Unused single-key, mouse button, and scroll wheel bindings from your mpv config also work in the overlay. Your SubMiner bindings win on conflicts.
### Shortcuts configuration
diff --git a/docs-site/plugins.md b/docs-site/plugins.md
new file mode 100644
index 00000000..6c45b708
--- /dev/null
+++ b/docs-site/plugins.md
@@ -0,0 +1,106 @@
+# Plugins
+
+Plugins are JavaScript files that SubMiner loads at startup. A plugin can react to playback, run code on a timer, add actions you bind to keys, run programs, and show results in an overlay panel, all without rebuilding SubMiner.
+
+::: warning Only run plugins you trust
+Plugins run inside SubMiner with the same access to your files and system as SubMiner itself. There is no sandbox.
+:::
+
+These are SubMiner app plugins. The Lua script that runs inside mpv is the separate [mpv plugin](/mpv-plugin).
+
+## Install a plugin
+
+1. Put the plugin in its own folder in `plugins/` next to your [config file](/configuration#configuration-file), for example `~/.config/SubMiner/plugins/ai-explain/`. The folder name is the plugin id.
+2. Enable it in `config.jsonc`. `settings` is passed to the plugin as-is:
+
+ ```jsonc
+ {
+ "plugins": {
+ "ai-explain": { "enabled": true, "settings": { "reasoningEffort": "medium" } },
+ },
+ }
+ ```
+
+3. Bind its actions to keys with the `__plugin-action:/` command:
+
+ ```jsonc
+ {
+ "keybindings": [
+ { "key": "Ctrl+KeyE", "command": ["__plugin-action:ai-explain/explain"] },
+ { "key": "Ctrl+KeyT", "command": ["__plugin-action:ai-explain/translate"] },
+ ],
+ }
+ ```
+
+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.
+
+## Example: explain the current line with Codex
+
+The repository ships [`examples/plugins/ai-explain`](https://github.com/ksyasuda/SubMiner/tree/main/examples/plugins/ai-explain). It sends the current line, plus the lines around it, to [`codex exec`](https://github.com/openai/codex) and shows the answer in a panel. It needs a logged-in Codex CLI.
+
+| Action | What it does |
+| ----------- | --------------------------------------------------------------- |
+| `explain` | Translation plus a breakdown of the grammar and vocabulary |
+| `translate` | Natural English translation, with a note only when it's unclear |
+
+| Setting | Default | What it does |
+| ----------------- | --------- | ----------------------------------------------------------------- |
+| `codexPath` | `"codex"` | Codex binary. Use a full path if SubMiner can't find it on `PATH` |
+| `model` | `""` | Model passed to `codex exec -m`; empty uses your Codex config |
+| `reasoningEffort` | `"low"` | Codex reasoning effort; empty uses your Codex config |
+| `before`, `after` | `2`, `1` | Lines of context sent before and after the current line |
+| `pause` | `true` | Pause playback when the panel opens |
+| `timeoutMs` | `120000` | Give up after this long |
+| `prompts` | `{}` | Replace an action's instructions, e.g. `{ "translate": "..." }` |
+
+Press `Esc` to close the panel. Closing it, or starting another request, stops the running Codex process.
+
+## Write a plugin
+
+A plugin folder needs a `plugin.json`:
+
+```json
+{ "name": "My Plugin", "version": "1.0.0", "main": "main.mjs" }
+```
+
+`main` points to a CommonJS or ES module that exports `activate(ctx)`. It may return a cleanup function, or the module can export `deactivate()`. Everything a plugin registers through `ctx` (listeners, timers, actions, panels, running programs) is cleaned up automatically when SubMiner quits.
+
+```js
+export function activate(ctx) {
+ ctx.mpv.on('media-path-change', ({ path }) => ctx.log.info(`Now playing ${path}`));
+ ctx.every(60_000, () => ctx.log.info('Still watching'));
+ ctx.actions.register('hello', () => {
+ const line = ctx.subtitles.current();
+ ctx.ui.panel({ title: 'Hello', markdown: line ? `> ${line.text}` : '_No subtitle_' });
+ });
+}
+```
+
+An error thrown by a plugin is logged and does not crash SubMiner. A plugin that blocks with a long synchronous loop still freezes the app, so keep slow work asynchronous.
+
+### `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 |
+
+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`.
diff --git a/docs-site/public/config.example.jsonc b/docs-site/public/config.example.jsonc
index e895f90d..adb6472a 100644
--- a/docs-site/public/config.example.jsonc
+++ b/docs-site/public/config.example.jsonc
@@ -218,6 +218,14 @@
"overlayPosition": "top-right" // Position for in-overlay notification cards. Values: top-left | top | top-right
}, // Overlay notification display behavior.
+ // ==========================================
+ // Plugins
+ // JavaScript plugins loaded from /plugins// at startup.
+ // Plugins run inside SubMiner with full access to your system. Only enable code you trust.
+ // Restart SubMiner after changing plugins.
+ // ==========================================
+ "plugins": {}, // Plugins to load at startup, keyed by directory name under /plugins/. Each entry is { "enabled": true, "settings": { ... } }; settings are passed to the plugin as ctx.settings.
+
// ==========================================
// Keyboard Shortcuts
// Overlay keyboard shortcuts. Set a shortcut to null to disable.
diff --git a/docs-site/shortcuts.md b/docs-site/shortcuts.md
index ed0466c1..44cb6f33 100644
--- a/docs-site/shortcuts.md
+++ b/docs-site/shortcuts.md
@@ -138,6 +138,8 @@ The plugin's `v` replaces mpv's own subtitle visibility toggle. When the overlay
}
```
+To run a [plugin](/plugins) action, use `["__plugin-action:/"]` as the command.
+
Mouse button names are `MBTN_LEFT`, `MBTN_MID`, `MBTN_RIGHT`, `MBTN_BACK`, and `MBTN_FORWARD`. Scroll wheel names are `WHEEL_UP`, `WHEEL_DOWN`, `WHEEL_LEFT`, and `WHEEL_RIGHT`. See [keybindings](/configuration#keybindings) and [shortcuts configuration](/configuration#shortcuts-configuration) in the config reference.
## Automatic mpv bindings
diff --git a/docs/architecture/README.md b/docs/architecture/README.md
index 0392a434..e0567225 100644
--- a/docs/architecture/README.md
+++ b/docs/architecture/README.md
@@ -37,6 +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/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 343343b9..d28bb7c8 100644
--- a/docs/architecture/domains.md
+++ b/docs/architecture/domains.md
@@ -34,6 +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 `examples/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/docs/workflow/verification.md b/docs/workflow/verification.md
index 06a6960e..62a010b5 100644
--- a/docs/workflow/verification.md
+++ b/docs/workflow/verification.md
@@ -123,7 +123,8 @@ whenever the behavior does not depend on a specific compositor.
per-window screenshots), mpv over its JSON IPC socket, and app commands through the
normal CLI handoff (`--mine-sentence`, `--toggle-subtitle-sidebar`, ...).
- Scenarios live in `e2e/*.e2e.test.ts` and share one session per file through
- `startE2eSession()`. Pass `config` to override settings for a scenario file.
+ `startE2eSession()`. Pass `config` to override settings for a scenario file, and `files`
+ to write files such as plugins into the isolated config dir before launch.
- On a failed startup the temp dir is kept and its path is in the error. Set
`SUBMINER_E2E_KEEP=1` to keep it after any run (`app-stdio.log`, `app.log`, `mpv.log`).
diff --git a/e2e/harness/session.ts b/e2e/harness/session.ts
index cc153491..e52b4678 100644
--- a/e2e/harness/session.ts
+++ b/e2e/harness/session.ts
@@ -28,6 +28,8 @@ type JsonValue = string | number | boolean | null | JsonValue[] | JsonObject;
export type E2eSessionOptions = {
/** Deep-merged over the harness config, for scenarios that need other settings. */
config?: JsonObject;
+ /** Files written before launch, keyed by path relative to the config dir (e.g. plugins). */
+ files?: Record;
};
export type AppTarget = {
@@ -193,6 +195,15 @@ export async function startE2eSession(options: E2eSessionOptions = {}): Promise<
const config = mergeConfig(buildConfig(mpvSocketPath, anki.url), options.config ?? {});
fs.writeFileSync(path.join(userDataDir, 'config.jsonc'), JSON.stringify(config, null, 2));
+ for (const [relativePath, content] of Object.entries(options.files ?? {})) {
+ const filePath = path.resolve(userDataDir, relativePath);
+ const inside = path.relative(userDataDir, filePath);
+ if (!inside || inside.startsWith('..') || path.isAbsolute(inside)) {
+ throw new Error(`E2E file path must stay inside the config dir: ${relativePath}`);
+ }
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
+ fs.writeFileSync(filePath, content);
+ }
const env: EnvDelta = {
set: {
diff --git a/e2e/plugins.e2e.test.ts b/e2e/plugins.e2e.test.ts
new file mode 100644
index 00000000..5647653c
--- /dev/null
+++ b/e2e/plugins.e2e.test.ts
@@ -0,0 +1,150 @@
+import assert from 'node:assert/strict';
+import fs from 'node:fs';
+import path from 'node:path';
+import test, { after } from 'node:test';
+import type { CdpPage } from './harness/cdp';
+import { FIXTURE_CUES } from './harness/fixtures';
+import { OVERLAY_PAGE, startE2eSession, type E2eSession } from './harness/session';
+import { waitUntil } from './harness/wait';
+
+// Bun caps node:test hooks at 5s whatever their timeout option says, so the
+// session boots inside whichever test runs first and every test budgets for it.
+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 = `
+exports.activate = (ctx) => {
+ ctx.actions.register('show', () => {
+ const { before, current, after } = ctx.subtitles.context({ before: 1, after: 1 });
+ const panel = ctx.ui.panel({ title: 'Probe', markdown: '_working_' });
+ panel.signal.addEventListener('abort', () => ctx.log.info('probe panel aborted'));
+ ctx
+ .exec(process.execPath, ['-e', 'process.stdout.write("child-ok")'], {
+ env: { ELECTRON_RUN_AS_NODE: '1' },
+ signal: panel.signal,
+ })
+ .then((result) =>
+ panel.update({
+ markdown: [
+ '## ' + ctx.settings.greeting,
+ '',
+ '- before: ' + before.map((line) => line.text).join('|'),
+ '- current: **' + (current ? current.text : '') + '**',
+ '- after: ' + after.map((line) => line.text).join('|'),
+ '- exec: ' + result.stdout,
+ ].join('\\n'),
+ }),
+ )
+ .catch((error) => panel.update({ markdown: 'exec failed: ' + error.message }));
+ });
+};
+`;
+
+type Started = { session: E2eSession; overlay: CdpPage };
+let started: Promise | undefined;
+
+function useSession(): Promise {
+ started ??= (async () => {
+ const session = await startE2eSession({
+ config: { plugins: { 'e2e-probe': { enabled: true, settings: { greeting: 'hello' } } } },
+ files: {
+ 'plugins/e2e-probe/plugin.json': JSON.stringify({ name: 'E2E Probe', main: 'main.cjs' }),
+ 'plugins/e2e-probe/main.cjs': PROBE_PLUGIN,
+ },
+ });
+ try {
+ return { session, overlay: await session.openPage(OVERLAY_PAGE) };
+ } catch (error) {
+ await session.dispose();
+ throw error;
+ }
+ })();
+ return started;
+}
+
+after(async () => {
+ const running = await started?.catch(() => undefined);
+ await running?.session.dispose();
+});
+
+type PanelState = {
+ title: string;
+ heading: string;
+ current: string;
+ items: string[];
+};
+
+test(
+ 'a plugin action opens the plugin panel with subtitle context and child process output',
+ { timeout: TEST_TIMEOUT_MS },
+ async () => {
+ const { session, overlay } = await useSession();
+ const [previous, cue, next] = FIXTURE_CUES;
+ await session.mpv.command('seek', cue.at, 'absolute+exact');
+ await overlay.waitFor(
+ `document.getElementById('subtitleRoot').textContent === ${JSON.stringify(cue.text)}`,
+ { description: `overlay to show "${cue.text}"` },
+ );
+
+ await session.runAppCommand(
+ '--session-action',
+ JSON.stringify({
+ actionId: 'runPluginAction',
+ payload: { pluginActionId: 'e2e-probe/show' },
+ }),
+ );
+
+ const modal = await session.openPage('renderer/index.html?layer=modal');
+ const panel = await modal
+ .waitFor(
+ `(() => {
+ const root = document.getElementById('pluginPanelModal');
+ const body = document.getElementById('pluginPanelBody');
+ if (root.classList.contains('hidden') || !/exec[: ]/.test(body.textContent)) return false;
+ return {
+ title: document.getElementById('pluginPanelTitle').textContent,
+ heading: body.querySelector('h4')?.textContent ?? '',
+ current: body.querySelector('strong')?.textContent ?? '',
+ items: Array.from(body.querySelectorAll('li'), (item) => item.textContent),
+ };
+ })()`,
+ { description: 'plugin panel to show the finished probe result' },
+ )
+ .catch(async (error: unknown) => {
+ const body = await modal.evaluate(
+ `document.getElementById('pluginPanelBody').textContent`,
+ );
+ throw new Error(`Plugin panel body: ${JSON.stringify(body)}`, { cause: error });
+ });
+
+ assert.deepEqual(panel, {
+ title: 'Probe',
+ heading: 'hello',
+ current: cue.text,
+ items: [
+ `before: ${previous.text}`,
+ `current: ${cue.text}`,
+ `after: ${next.text}`,
+ 'exec: child-ok',
+ ],
+ });
+
+ // Closing hides the modal window (Linux destroys it), often before Chromium
+ // acknowledges the key event, so the send is not awaited. The plugin's log line
+ // proves the close reached main and aborted the handle.
+ void modal
+ .send('Input.dispatchKeyEvent', {
+ type: 'rawKeyDown',
+ key: 'Escape',
+ code: 'Escape',
+ windowsVirtualKeyCode: 27,
+ })
+ .catch(() => {});
+ await waitUntil(
+ () =>
+ fs.readFileSync(path.join(session.root, 'app.log'), 'utf8').includes('probe panel aborted'),
+ { description: 'closing the panel to abort the plugin handle in main' },
+ );
+ },
+);
diff --git a/examples/plugins/ai-explain/main.mjs b/examples/plugins/ai-explain/main.mjs
new file mode 100644
index 00000000..8caec021
--- /dev/null
+++ b/examples/plugins/ai-explain/main.mjs
@@ -0,0 +1,128 @@
+// Sends the current subtitle line, with a few lines of context, to `codex exec` and shows the
+// answer in the SubMiner plugin panel. Registers two actions: `explain` and `translate`.
+import { mkdtemp, readFile, rm } from 'node:fs/promises';
+import os from 'node:os';
+import path from 'node:path';
+
+const DEFAULT_SETTINGS = {
+ codexPath: 'codex',
+ model: '',
+ // Passed as -c model_reasoning_effort; low keeps answers to a few seconds. Empty uses your Codex config.
+ reasoningEffort: 'low',
+ before: 2,
+ after: 1,
+ timeoutMs: 120_000,
+ pause: true,
+ prompts: {},
+};
+
+const ACTIONS = {
+ explain: {
+ title: 'Grammar',
+ prompt:
+ 'You are a Japanese tutor. Explain the line marked >>> for an intermediate learner. ' +
+ 'Use the other lines only as context. Reply in English markdown: a natural translation, ' +
+ 'then a bullet list of the grammar points and notable vocabulary. Be concise.',
+ },
+ translate: {
+ title: 'Translation',
+ prompt:
+ 'Translate the line marked >>> into natural English. Use the other lines only as context. ' +
+ 'Reply with the translation, then one short note only if the line is ambiguous.',
+ },
+};
+
+export function activate(ctx) {
+ const settings = { ...DEFAULT_SETTINGS, ...ctx.settings };
+ for (const [name, action] of Object.entries(ACTIONS)) {
+ const prompt = settings.prompts?.[name] ?? action.prompt;
+ ctx.actions.register(name, () => ask(ctx, settings, action.title, prompt));
+ }
+}
+
+function quote(text) {
+ return text
+ .split('\n')
+ .map((line) => `> ${line}`)
+ .join('\n');
+}
+
+// Subtitles wrap across lines; flatten each cue so the >>> marker covers all of it.
+function oneLine(text) {
+ return text.split('\n').map((part) => part.trim()).filter(Boolean).join(' ');
+}
+
+function tail(text, lineCount) {
+ return text.trim().split('\n').slice(-lineCount).join('\n');
+}
+
+async function ask(ctx, settings, title, instructions) {
+ const { before, current, after } = ctx.subtitles.context({
+ before: settings.before,
+ after: settings.after,
+ });
+ if (!current) {
+ ctx.notify('No subtitle line to send.', { variant: 'warning' });
+ return;
+ }
+ if (settings.pause) ctx.mpv.command(['set', 'pause', 'yes']);
+
+ const header = quote(current.text);
+ const panel = ctx.ui.panel({ title, markdown: `${header}\n\n_Asking Codex..._` });
+ const prompt = [
+ instructions,
+ '',
+ 'Subtitles:',
+ ...before.map((line) => oneLine(line.text)),
+ `>>> ${oneLine(current.text)}`,
+ ...after.map((line) => oneLine(line.text)),
+ ].join('\n');
+
+ // Codex runs in an empty read-only directory so any command it tries cannot touch real files.
+ const workDir = await mkdtemp(path.join(os.tmpdir(), 'subminer-ai-explain-'));
+ const answerPath = path.join(workDir, 'answer.md');
+ const args = [
+ 'exec',
+ '--ephemeral',
+ '--skip-git-repo-check',
+ '--sandbox',
+ 'read-only',
+ '--color',
+ 'never',
+ '-C',
+ workDir,
+ '-o',
+ answerPath,
+ ];
+ if (settings.model) args.push('-m', settings.model);
+ if (settings.reasoningEffort) {
+ args.push('-c', `model_reasoning_effort=${JSON.stringify(settings.reasoningEffort)}`);
+ }
+ args.push('-');
+
+ try {
+ const result = await ctx.exec(settings.codexPath, args, {
+ stdin: prompt,
+ timeoutMs: settings.timeoutMs,
+ signal: panel.signal,
+ });
+ if (result.exitCode !== 0) {
+ throw new Error(tail(result.stderr, 6) || `codex exited with code ${result.exitCode}`);
+ }
+ const answer = (await readFile(answerPath, 'utf8')).trim();
+ panel.update({ markdown: `${header}\n\n${answer || '_Codex returned an empty answer._'}` });
+ } catch (error) {
+ // Closing the panel aborts the request; there is nobody left to show the error to.
+ if (panel.signal.aborted) return;
+ const message =
+ error?.code === 'ENOENT'
+ ? `Cannot find \`${settings.codexPath}\`. Set \`plugins.ai-explain.settings.codexPath\` to its full path.`
+ : String(error?.message ?? error);
+ ctx.log.warn(`Codex request failed: ${message}`);
+ panel.update({
+ markdown: `${header}\n\n**Codex request failed.**\n\n\`\`\`\n${message}\n\`\`\``,
+ });
+ } finally {
+ await rm(workDir, { recursive: true, force: true });
+ }
+}
diff --git a/examples/plugins/ai-explain/plugin.json b/examples/plugins/ai-explain/plugin.json
new file mode 100644
index 00000000..d67f3afc
--- /dev/null
+++ b/examples/plugins/ai-explain/plugin.json
@@ -0,0 +1,5 @@
+{
+ "name": "AI Explain",
+ "version": "1.0.0",
+ "main": "main.mjs"
+}
diff --git a/src/config/definitions.ts b/src/config/definitions.ts
index 7da11e6a..edaf26e4 100644
--- a/src/config/definitions.ts
+++ b/src/config/definitions.ts
@@ -37,6 +37,7 @@ const {
startupWarmups,
updates,
notifications,
+ plugins,
auto_start_overlay,
} = CORE_DEFAULT_CONFIG;
const {
@@ -74,6 +75,7 @@ export const DEFAULT_CONFIG: ResolvedConfig = {
startupWarmups,
updates,
notifications,
+ plugins,
subtitleStyle,
subtitleSidebar,
auto_start_overlay,
diff --git a/src/config/definitions/defaults-core.ts b/src/config/definitions/defaults-core.ts
index b92fd9c8..9d823db2 100644
--- a/src/config/definitions/defaults-core.ts
+++ b/src/config/definitions/defaults-core.ts
@@ -17,6 +17,7 @@ export const CORE_DEFAULT_CONFIG: Pick<
| 'startupWarmups'
| 'updates'
| 'notifications'
+ | 'plugins'
| 'auto_start_overlay'
> = {
dictionaryBackend: 'yomitan',
@@ -145,5 +146,6 @@ export const CORE_DEFAULT_CONFIG: Pick<
notifications: {
overlayPosition: 'top-right',
},
+ plugins: {},
auto_start_overlay: true,
};
diff --git a/src/config/definitions/options-core.ts b/src/config/definitions/options-core.ts
index 1852f997..8166d91a 100644
--- a/src/config/definitions/options-core.ts
+++ b/src/config/definitions/options-core.ts
@@ -546,6 +546,13 @@ export function buildCoreConfigOptionRegistry(
defaultValue: defaultConfig.notifications.overlayPosition,
description: 'Position for in-overlay notification cards.',
},
+ {
+ path: 'plugins',
+ kind: 'object',
+ defaultValue: defaultConfig.plugins,
+ description:
+ 'Plugins to load at startup, keyed by directory name under /plugins/. Each entry is { "enabled": true, "settings": { ... } }; settings are passed to the plugin as ctx.settings.',
+ },
{
path: 'shortcuts.multiCopyTimeoutMs',
kind: 'number',
diff --git a/src/config/definitions/shared.ts b/src/config/definitions/shared.ts
index ef937dbe..ebbf16b9 100644
--- a/src/config/definitions/shared.ts
+++ b/src/config/definitions/shared.ts
@@ -57,6 +57,7 @@ export const SPECIAL_COMMANDS = {
ANIMETOSHO_OPEN: '__animetosho-open',
TSUKIHIME_OPEN: '__tsukihime-open',
RUNTIME_OPTION_CYCLE_PREFIX: '__runtime-option-cycle:',
+ PLUGIN_ACTION_PREFIX: '__plugin-action:',
REPLAY_SUBTITLE: '__replay-subtitle',
PLAY_NEXT_SUBTITLE: '__play-next-subtitle',
YOUTUBE_PICKER_OPEN: '__youtube-picker-open',
diff --git a/src/config/definitions/template-sections.ts b/src/config/definitions/template-sections.ts
index ee68378e..8ccd2f1c 100644
--- a/src/config/definitions/template-sections.ts
+++ b/src/config/definitions/template-sections.ts
@@ -92,6 +92,15 @@ const CORE_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
notes: ['Hot-reload: position changes apply to the next overlay notification.'],
key: 'notifications',
},
+ {
+ title: 'Plugins',
+ description: [
+ 'JavaScript plugins loaded from /plugins// at startup.',
+ 'Plugins run inside SubMiner with full access to your system. Only enable code you trust.',
+ ],
+ notes: ['Restart SubMiner after changing plugins.'],
+ key: 'plugins',
+ },
{
title: 'Keyboard Shortcuts',
description: ['Overlay keyboard shortcuts. Set a shortcut to null to disable.'],
diff --git a/src/config/resolve.ts b/src/config/resolve.ts
index 6e203c40..e256237f 100644
--- a/src/config/resolve.ts
+++ b/src/config/resolve.ts
@@ -4,6 +4,7 @@ import { createResolveContext } from './resolve/context';
import { applyCoreDomainConfig } from './resolve/core-domains';
import { applyImmersionTrackingConfig } from './resolve/immersion-tracking';
import { applyIntegrationConfig } from './resolve/integrations';
+import { applyPluginsConfig } from './resolve/plugins';
import { applyStatsConfig } from './resolve/stats';
import { applySubtitleDomainConfig } from './resolve/subtitle-domains';
import { applyTopLevelConfig } from './resolve/top-level';
@@ -15,6 +16,7 @@ const APPLY_RESOLVE_STEPS = [
applyIntegrationConfig,
applyImmersionTrackingConfig,
applyStatsConfig,
+ applyPluginsConfig,
applyAnkiConnectResolution,
] as const;
diff --git a/src/config/resolve/plugins.test.ts b/src/config/resolve/plugins.test.ts
new file mode 100644
index 00000000..ae6614d7
--- /dev/null
+++ b/src/config/resolve/plugins.test.ts
@@ -0,0 +1,33 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { resolveConfig } from '../resolve';
+
+test('resolveConfig keeps plugin entries and their settings', () => {
+ const { resolved, warnings } = resolveConfig({
+ plugins: {
+ 'ai-explain': { enabled: true, settings: { codexPath: 'codex', before: 2 } },
+ dormant: {},
+ },
+ });
+
+ assert.deepEqual(resolved.plugins, {
+ 'ai-explain': { enabled: true, settings: { codexPath: 'codex', before: 2 } },
+ dormant: { enabled: false, settings: {} },
+ });
+ assert.deepEqual(warnings, []);
+});
+
+test('resolveConfig warns about malformed plugin entries and keeps them disabled', () => {
+ const { resolved, warnings } = resolveConfig({
+ plugins: {
+ flag: { enabled: 'yes' as never, settings: [] as never },
+ scalar: true as never,
+ },
+ });
+
+ assert.deepEqual(resolved.plugins, { flag: { enabled: false, settings: {} } });
+ assert.deepEqual(
+ warnings.map((warning) => warning.path),
+ ['plugins.flag.enabled', 'plugins.flag.settings', 'plugins.scalar'],
+ );
+});
diff --git a/src/config/resolve/plugins.ts b/src/config/resolve/plugins.ts
new file mode 100644
index 00000000..27550a89
--- /dev/null
+++ b/src/config/resolve/plugins.ts
@@ -0,0 +1,30 @@
+import { ResolveContext } from './context';
+import { isObject } from './shared';
+
+/** Keeps well-formed `plugins.` entries; ids are validated again when the plugin loads. */
+export function applyPluginsConfig(context: ResolveContext): void {
+ const { src, resolved, warn } = context;
+ if (src.plugins === undefined) return;
+ if (!isObject(src.plugins)) {
+ warn('plugins', src.plugins, resolved.plugins, 'Expected object keyed by plugin id.');
+ return;
+ }
+
+ for (const [id, entry] of Object.entries(src.plugins)) {
+ const path = `plugins.${id}`;
+ if (!isObject(entry)) {
+ warn(path, entry, undefined, 'Expected { "enabled": boolean, "settings"?: object }.');
+ continue;
+ }
+ if (entry.enabled !== undefined && typeof entry.enabled !== 'boolean') {
+ warn(`${path}.enabled`, entry.enabled, false, 'Expected boolean.');
+ }
+ if (entry.settings !== undefined && !isObject(entry.settings)) {
+ warn(`${path}.settings`, entry.settings, {}, 'Expected object.');
+ }
+ resolved.plugins[id] = {
+ enabled: entry.enabled === true,
+ settings: isObject(entry.settings) ? { ...entry.settings } : {},
+ };
+ }
+}
diff --git a/src/config/settings/registry.ts b/src/config/settings/registry.ts
index 38ffe31b..636d05a0 100644
--- a/src/config/settings/registry.ts
+++ b/src/config/settings/registry.ts
@@ -81,6 +81,7 @@ const JSON_OBJECT_FIELDS = new Set([
'keybindings',
'controller.bindings',
'controller.profiles',
+ 'plugins',
'ankiConnect.knownWords.decks',
'subtitleStyle.css',
'subtitleStyle.secondary.css',
@@ -441,6 +442,9 @@ function categoryAndSection(path: string): { category: ConfigSettingsCategory; s
if (path.startsWith('notifications.')) {
return { category: 'behavior', section: 'Notifications' };
}
+ if (path === 'plugins') {
+ return { category: 'advanced', section: 'Plugins' };
+ }
if (path === 'mpv.aniskipButtonKey') {
return { category: 'input', section: 'Overlay Shortcuts' };
}
diff --git a/src/core/services/app-ready.test.ts b/src/core/services/app-ready.test.ts
index a51fb49d..320e8400 100644
--- a/src/core/services/app-ready.test.ts
+++ b/src/core/services/app-ready.test.ts
@@ -13,6 +13,7 @@ function makeDeps(overrides: Partial = {}) {
loadSubtitlePosition: () => calls.push('loadSubtitlePosition'),
resolveKeybindings: () => calls.push('resolveKeybindings'),
createMpvClient: () => calls.push('createMpvClient'),
+ startPlugins: () => calls.push('startPlugins'),
reloadConfig: () => calls.push('reloadConfig'),
getResolvedConfig: () => ({
websocket: { enabled: 'auto' },
@@ -162,6 +163,7 @@ test('runAppReadyRuntime skips heavy startup when shouldSkipHeavyStartup returns
assert.equal(calls.includes('loadSubtitlePosition'), false);
assert.equal(calls.includes('resolveKeybindings'), false);
assert.equal(calls.includes('createMpvClient'), false);
+ assert.equal(calls.includes('startPlugins'), false);
assert.equal(calls.includes('initRuntimeOptionsManager'), false);
assert.equal(calls.includes('createImmersionTracker'), false);
assert.equal(calls.includes('startJellyfinRemoteSession'), false);
@@ -326,6 +328,9 @@ test('runAppReadyRuntime starts background warmups after overlay startup', async
assert.ok(calls.indexOf('loadSubtitlePosition') < calls.indexOf('startBackgroundWarmups'));
assert.ok(calls.indexOf('createMpvClient') < calls.indexOf('startBackgroundWarmups'));
+ assert.ok(calls.includes('setLogLevel:info:config'));
+ assert.ok(calls.indexOf('setLogLevel:info:config') < calls.indexOf('startPlugins'));
+ assert.ok(calls.indexOf('startPlugins') < calls.indexOf('startBackgroundWarmups'));
assert.ok(calls.indexOf('initializeOverlayRuntime') < calls.indexOf('startBackgroundWarmups'));
assert.ok(calls.indexOf('startBackgroundWarmups') < calls.indexOf('handleInitialArgs'));
});
diff --git a/src/core/services/plugins/exec.test.ts b/src/core/services/plugins/exec.test.ts
new file mode 100644
index 00000000..bab461b7
--- /dev/null
+++ b/src/core/services/plugins/exec.test.ts
@@ -0,0 +1,61 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+
+import { execPluginCommand } from './exec';
+
+// The test runtime (bun or node) doubles as a portable child process.
+const runtime = process.execPath;
+
+test('pipes stdin to the child and collects stdout', async () => {
+ const result = await execPluginCommand(
+ runtime,
+ ['-e', 'process.stdin.on("data", (d) => process.stdout.write(String(d).toUpperCase()))'],
+ { stdin: '字幕 line' },
+ );
+
+ assert.equal(result.exitCode, 0);
+ assert.equal(result.stdout, '字幕 LINE');
+});
+
+test('resolves with a non-zero exit code and stderr', async () => {
+ const result = await execPluginCommand(runtime, [
+ '-e',
+ 'process.stderr.write("bad things"); process.exit(3)',
+ ]);
+
+ assert.equal(result.exitCode, 3);
+ assert.equal(result.stderr, 'bad things');
+});
+
+test('drops the AppImage library path from the child environment', async () => {
+ const previous = process.env.LD_LIBRARY_PATH;
+ process.env.LD_LIBRARY_PATH = '/tmp/.mount_SubMinXXXXXX/usr/lib';
+ try {
+ const result = await execPluginCommand(
+ runtime,
+ ['-e', 'process.stdout.write(String(process.env.LD_LIBRARY_PATH) + "|" + process.env.EXTRA)'],
+ { env: { EXTRA: 'kept' } },
+ );
+ assert.equal(result.stdout, 'undefined|kept');
+ } finally {
+ if (previous === undefined) delete process.env.LD_LIBRARY_PATH;
+ else process.env.LD_LIBRARY_PATH = previous;
+ }
+});
+
+test('aborting kills the child and rejects with AbortError', async () => {
+ const controller = new AbortController();
+ const pending = execPluginCommand(runtime, ['-e', 'setTimeout(() => {}, 60_000)'], {
+ signal: controller.signal,
+ });
+ setTimeout(() => controller.abort(), 50);
+
+ await assert.rejects(pending, { name: 'AbortError' });
+});
+
+test('rejects when the binary does not exist', async () => {
+ await assert.rejects(
+ execPluginCommand('subminer-definitely-missing-binary', []),
+ (error: NodeJS.ErrnoException) => error.code === 'ENOENT',
+ );
+});
diff --git a/src/core/services/plugins/exec.ts b/src/core/services/plugins/exec.ts
new file mode 100644
index 00000000..5e2d4679
--- /dev/null
+++ b/src/core/services/plugins/exec.ts
@@ -0,0 +1,81 @@
+import { spawn } from 'node:child_process';
+
+import { buildSystemBinaryEnv } from '../../utils/system-binary-env';
+import type { PluginExecOptions, PluginExecResult } from './types';
+
+const MAX_CAPTURED_OUTPUT_CHARS = 4 * 1024 * 1024;
+
+function appendCapped(current: string, chunk: string): string {
+ if (current.length >= MAX_CAPTURED_OUTPUT_CHARS) return current;
+ return (current + chunk).slice(0, MAX_CAPTURED_OUTPUT_CHARS);
+}
+
+/**
+ * Runs `command` without a shell 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.
+ */
+export function execPluginCommand(
+ command: string,
+ args: string[],
+ options: PluginExecOptions = {},
+): Promise {
+ return new Promise((resolve, reject) => {
+ if (options.signal?.aborted) {
+ reject(new DOMException('Command aborted.', 'AbortError'));
+ return;
+ }
+
+ const child = spawn(command, args, {
+ cwd: options.cwd,
+ env: { ...buildSystemBinaryEnv(), ...options.env },
+ stdio: ['pipe', 'pipe', 'pipe'],
+ windowsHide: true,
+ });
+
+ let stdout = '';
+ let stderr = '';
+ let settled = false;
+ let timeout: ReturnType | null = null;
+
+ const settle = (finish: () => void): void => {
+ if (settled) return;
+ settled = true;
+ if (timeout) clearTimeout(timeout);
+ options.signal?.removeEventListener('abort', onAbort);
+ finish();
+ };
+
+ const killWith = (error: Error): void => {
+ settle(() => reject(error));
+ child.kill('SIGTERM');
+ };
+
+ function onAbort(): void {
+ killWith(new DOMException('Command aborted.', 'AbortError'));
+ }
+
+ options.signal?.addEventListener('abort', onAbort, { once: true });
+ if (options.timeoutMs !== undefined && options.timeoutMs > 0) {
+ timeout = setTimeout(
+ () => killWith(new Error(`${command} timed out after ${options.timeoutMs} ms.`)),
+ options.timeoutMs,
+ );
+ }
+
+ child.stdout.setEncoding('utf8');
+ child.stderr.setEncoding('utf8');
+ child.stdout.on('data', (chunk: string) => {
+ stdout = appendCapped(stdout, chunk);
+ });
+ child.stderr.on('data', (chunk: string) => {
+ stderr = appendCapped(stderr, chunk);
+ });
+ // A child that exits without reading stdin raises EPIPE here; the exit code tells the story.
+ child.stdin.on('error', () => {});
+
+ child.on('error', (error) => settle(() => reject(error)));
+ child.on('close', (exitCode) => settle(() => resolve({ exitCode, stdout, stderr })));
+
+ child.stdin.end(options.stdin ?? '');
+ });
+}
diff --git a/src/core/services/plugins/manifest.ts b/src/core/services/plugins/manifest.ts
new file mode 100644
index 00000000..8367d9e7
--- /dev/null
+++ b/src/core/services/plugins/manifest.ts
@@ -0,0 +1,47 @@
+import path from 'node:path';
+
+import type { PluginManifest } from './types';
+
+const PLUGIN_NAME_PATTERN = /^[a-z0-9][a-z0-9._-]*$/i;
+
+/** Plugin ids double as directory names and action prefixes, and action names follow the same rule. */
+export function isValidPluginName(id: string): boolean {
+ return PLUGIN_NAME_PATTERN.test(id);
+}
+
+/** Validates parsed `plugin.json` content. Returns an error message instead of throwing. */
+export function parsePluginManifest(
+ raw: unknown,
+ fallbackName: string,
+): { manifest: PluginManifest } | { error: string } {
+ if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
+ return { error: 'plugin.json must contain a JSON object.' };
+ }
+ const value = raw as Record;
+ if (typeof value.main !== 'string' || value.main.trim().length === 0) {
+ return { error: 'plugin.json needs a "main" entry file.' };
+ }
+ if (value.name !== undefined && typeof value.name !== 'string') {
+ return { error: 'plugin.json "name" must be a string.' };
+ }
+ if (value.version !== undefined && typeof value.version !== 'string') {
+ return { error: 'plugin.json "version" must be a string.' };
+ }
+ return {
+ manifest: {
+ name: typeof value.name === 'string' && value.name.trim() ? value.name.trim() : fallbackName,
+ version: typeof value.version === 'string' ? value.version : null,
+ main: value.main.trim(),
+ },
+ };
+}
+
+/** 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);
+ const relative = path.relative(pluginDir, resolved);
+ if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) {
+ return null;
+ }
+ return resolved;
+}
diff --git a/src/core/services/plugins/plugin-host.test.ts b/src/core/services/plugins/plugin-host.test.ts
new file mode 100644
index 00000000..67ee86cf
--- /dev/null
+++ b/src/core/services/plugins/plugin-host.test.ts
@@ -0,0 +1,206 @@
+import assert from 'node:assert/strict';
+import { EventEmitter } from 'node:events';
+import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
+import os from 'node:os';
+import path from 'node:path';
+import test from 'node:test';
+
+import { createPluginHost, type PluginHostDeps } from './plugin-host';
+import type { PluginConfigEntry, PluginLogger, PluginMpvEventName } from './types';
+
+function writePlugin(root: string, id: string, files: Record): void {
+ const dir = path.join(root, id);
+ mkdirSync(dir, { recursive: true });
+ for (const [name, content] of Object.entries(files)) {
+ writeFileSync(path.join(dir, name), content);
+ }
+}
+
+function createHarness(config: Record) {
+ const root = mkdtempSync(path.join(os.tmpdir(), 'subminer-plugins-'));
+ const errors: string[] = [];
+ const notifications: string[] = [];
+ const logger: PluginLogger = {
+ debug: () => {},
+ info: () => {},
+ warn: () => {},
+ error: (message) => errors.push(message),
+ };
+ const deps: PluginHostDeps<{ marker: string }> = {
+ pluginsDir: root,
+ getPluginConfig: () => config,
+ createLogger: () => logger,
+ isMpvConnected: () => true,
+ sendMpvCommand: () => {},
+ requestMpvProperty: async () => null,
+ getCurrentSubtitle: () => null,
+ getSubtitleContext: () => ({ current: null, before: [], after: [] }),
+ notify: (message) => notifications.push(message),
+ openPanel: () => {
+ throw new Error('not used');
+ },
+ unstable: { marker: 'internals' },
+ };
+ return {
+ root,
+ errors,
+ notifications,
+ host: createPluginHost(deps),
+ cleanup: () => rmSync(root, { recursive: true, force: true }),
+ };
+}
+
+const enabled = (settings: Record = {}): PluginConfigEntry => ({
+ enabled: true,
+ settings,
+});
+
+function createFakeMpvClient() {
+ const bus = new EventEmitter();
+ return {
+ on: (event: PluginMpvEventName, listener: (payload: unknown) => void) =>
+ bus.on(event, listener),
+ off: (event: PluginMpvEventName, listener: (payload: unknown) => void) =>
+ bus.off(event, listener),
+ emit: (event: PluginMpvEventName, payload: unknown) => bus.emit(event, payload),
+ };
+}
+
+test('loads enabled plugins and runs their registered actions', async () => {
+ const h = createHarness({
+ greeter: enabled({ greeting: 'hi' }),
+ off: { enabled: false, settings: {} },
+ });
+ try {
+ writePlugin(h.root, 'greeter', {
+ 'plugin.json': JSON.stringify({ main: 'main.cjs' }),
+ 'main.cjs': `exports.activate = (ctx) => {
+ ctx.actions.register('say', () => ctx.notify(ctx.settings.greeting + ' ' + ctx.unstable.marker));
+ };`,
+ });
+ writePlugin(h.root, 'off', {
+ 'plugin.json': JSON.stringify({ main: 'main.cjs' }),
+ 'main.cjs': `throw new Error('disabled plugins must not load');`,
+ });
+
+ await h.host.loadAll();
+
+ assert.equal(h.host.runAction('greeter/say'), true);
+ assert.deepEqual(h.notifications, ['hi internals']);
+ assert.equal(h.host.runAction('greeter/missing'), false);
+ assert.deepEqual(h.errors, []);
+ } finally {
+ h.cleanup();
+ }
+});
+
+test('loads ES module plugins', async () => {
+ const h = createHarness({ esm: enabled() });
+ try {
+ writePlugin(h.root, 'esm', {
+ 'plugin.json': JSON.stringify({ main: 'main.mjs' }),
+ 'main.mjs': `export function activate(ctx) { ctx.actions.register('go', () => ctx.notify('esm')); }`,
+ });
+
+ await h.host.loadAll();
+
+ assert.equal(h.host.runAction('esm/go'), true);
+ assert.deepEqual(h.notifications, ['esm']);
+ } finally {
+ h.cleanup();
+ }
+});
+
+test('a failed activation rolls back its registrations without affecting other plugins', async () => {
+ const h = createHarness({ broken: enabled(), healthy: enabled() });
+ try {
+ writePlugin(h.root, 'broken', {
+ 'plugin.json': JSON.stringify({ main: 'main.cjs' }),
+ 'main.cjs': `exports.activate = (ctx) => {
+ ctx.actions.register('half', () => {});
+ throw new Error('boom');
+ };`,
+ });
+ writePlugin(h.root, 'healthy', {
+ 'plugin.json': JSON.stringify({ main: 'main.cjs' }),
+ 'main.cjs': `exports.activate = (ctx) => { ctx.actions.register('ok', () => {}); };`,
+ });
+
+ await h.host.loadAll();
+
+ assert.equal(h.host.runAction('broken/half'), false);
+ assert.equal(h.host.runAction('healthy/ok'), true);
+ assert.deepEqual(h.errors, ['Activation failed: boom']);
+ } finally {
+ h.cleanup();
+ }
+});
+
+test('rejects entry files outside the plugin directory', async () => {
+ const h = createHarness({ escape: enabled() });
+ try {
+ writePlugin(h.root, 'escape', {
+ 'plugin.json': JSON.stringify({ main: '../elsewhere.cjs' }),
+ });
+
+ await h.host.loadAll();
+
+ assert.equal(h.errors.length, 1);
+ assert.match(h.errors[0]!, /must stay inside/);
+ } finally {
+ h.cleanup();
+ }
+});
+
+test('mpv listener errors are contained and unload detaches listeners and calls deactivate', async () => {
+ const h = createHarness({ watcher: enabled() });
+ const client = createFakeMpvClient();
+ try {
+ writePlugin(h.root, 'watcher', {
+ 'plugin.json': JSON.stringify({ main: 'main.cjs' }),
+ 'main.cjs': `exports.activate = (ctx) => {
+ ctx.mpv.on('pause-change', ({ paused }) => {
+ ctx.notify('paused=' + paused);
+ throw new Error('listener bug');
+ });
+ return () => ctx.notify('deactivated');
+ };`,
+ });
+ h.host.attachMpvClient(client);
+ await h.host.loadAll();
+
+ client.emit('pause-change', { paused: true });
+ assert.deepEqual(h.notifications, ['paused=true']);
+ assert.deepEqual(h.errors, ['mpv pause-change listener failed: listener bug']);
+
+ await h.host.unloadAll();
+ client.emit('pause-change', { paused: false });
+ assert.deepEqual(h.notifications, ['paused=true', 'deactivated']);
+ } finally {
+ h.cleanup();
+ }
+});
+
+test('events follow a replacement mpv client', async () => {
+ const h = createHarness({ watcher: enabled() });
+ const first = createFakeMpvClient();
+ const second = createFakeMpvClient();
+ try {
+ writePlugin(h.root, 'watcher', {
+ 'plugin.json': JSON.stringify({ main: 'main.cjs' }),
+ 'main.cjs': `exports.activate = (ctx) => {
+ ctx.mpv.on('media-path-change', ({ path }) => ctx.notify(path));
+ };`,
+ });
+ h.host.attachMpvClient(first);
+ await h.host.loadAll();
+ h.host.attachMpvClient(second);
+
+ first.emit('media-path-change', { path: 'stale.mkv' });
+ second.emit('media-path-change', { path: 'current.mkv' });
+
+ assert.deepEqual(h.notifications, ['current.mkv']);
+ } finally {
+ h.cleanup();
+ }
+});
diff --git a/src/core/services/plugins/plugin-host.ts b/src/core/services/plugins/plugin-host.ts
new file mode 100644
index 00000000..6eaffb42
--- /dev/null
+++ b/src/core/services/plugins/plugin-host.ts
@@ -0,0 +1,325 @@
+import { EventEmitter } from 'node:events';
+import { promises as fs } from 'node:fs';
+import path from 'node:path';
+
+import { execPluginCommand } from './exec';
+import { isValidPluginName, parsePluginManifest, resolvePluginEntryPath } from './manifest';
+import {
+ PLUGIN_MPV_EVENT_NAMES,
+ type PluginConfigEntry,
+ type PluginContext,
+ type PluginLogger,
+ type PluginModule,
+ type PluginMpvEventName,
+ type PluginNotifyVariant,
+ type PluginPanelContent,
+ type PluginPanelHandle,
+ type PluginSubtitleContext,
+ type PluginSubtitleLine,
+} from './types';
+
+const MIN_TIMER_INTERVAL_MS = 100;
+
+interface MpvClientEvents {
+ on: (event: PluginMpvEventName, listener: (payload: unknown) => void) => void;
+ off: (event: PluginMpvEventName, listener: (payload: unknown) => void) => void;
+}
+
+export interface PluginHostDeps {
+ pluginsDir: string;
+ getPluginConfig: () => Record;
+ createLogger: (scope: string) => PluginLogger;
+ isMpvConnected: () => boolean;
+ sendMpvCommand: (command: (string | number)[]) => void;
+ requestMpvProperty: (name: string) => Promise;
+ getCurrentSubtitle: () => PluginSubtitleLine | null;
+ getSubtitleContext: (options: { before?: number; after?: number }) => PluginSubtitleContext;
+ notify: (message: string, variant: PluginNotifyVariant) => void;
+ openPanel: (content: PluginPanelContent) => PluginPanelHandle;
+ unstable: TUnstable;
+ /** Test seam; defaults to Node's `require`, which also loads synchronous ES modules. */
+ loadModule?: (entryPath: string) => PluginModule;
+ exec?: typeof execPluginCommand;
+}
+
+interface LoadedPlugin {
+ id: string;
+ disposers: Set<() => void>;
+ deactivate: (() => unknown) | null;
+ /** Aborted on unload so in-flight `exec` children die with the plugin. */
+ lifetime: AbortController;
+}
+
+function errorMessage(error: unknown): string {
+ return error instanceof Error ? error.message : String(error);
+}
+
+function isPromiseLike(value: unknown): value is PromiseLike {
+ return (
+ typeof value === 'object' &&
+ value !== null &&
+ typeof (value as { then?: unknown }).then === 'function'
+ );
+}
+
+function pickExport(
+ mod: PluginModule,
+ key: K,
+): NonNullable | null {
+ const direct = mod[key];
+ if (typeof direct === 'function') return direct as NonNullable;
+ const fromDefault = mod.default?.[key];
+ return typeof fromDefault === 'function' ? (fromDefault as NonNullable) : null;
+}
+
+/**
+ * Loads enabled plugins from `//plugin.json` and gives each one a scoped context.
+ * Every plugin callback runs behind a guard, so a throwing plugin logs instead of breaking the app.
+ * Plugins run in the main process: a synchronous busy loop still blocks it.
+ */
+export function createPluginHost(deps: PluginHostDeps) {
+ const mpvEvents = new EventEmitter();
+ mpvEvents.setMaxListeners(0);
+ const plugins = new Map();
+ const actions = new Map unknown }>();
+ const loadModule = deps.loadModule ?? ((entryPath: string) => require(entryPath) as PluginModule);
+ const exec = deps.exec ?? execPluginCommand;
+ let attachedClient: {
+ client: MpvClientEvents;
+ forwarders: Map void>;
+ } | null = null;
+ let loadPromise: Promise | null = null;
+
+ function runGuarded(log: PluginLogger, label: string, callback: () => unknown): void {
+ try {
+ const result = callback();
+ if (isPromiseLike(result)) {
+ Promise.resolve(result).catch((error) =>
+ log.error(`${label} failed: ${errorMessage(error)}`),
+ );
+ }
+ } catch (error) {
+ log.error(`${label} failed: ${errorMessage(error)}`);
+ }
+ }
+
+ function createContext(
+ plugin: LoadedPlugin,
+ dir: string,
+ entry: PluginConfigEntry,
+ log: PluginLogger,
+ ): PluginContext {
+ const track = (dispose: () => void): (() => void) => {
+ let disposed = false;
+ const disposer = (): void => {
+ if (disposed) return;
+ disposed = true;
+ plugin.disposers.delete(disposer);
+ dispose();
+ };
+ plugin.disposers.add(disposer);
+ return disposer;
+ };
+
+ return {
+ id: plugin.id,
+ dir,
+ settings: Object.freeze({ ...entry.settings }),
+ log,
+ every: (intervalMs, callback) => {
+ const handle = setInterval(
+ () => runGuarded(log, 'Timer', callback),
+ Math.max(MIN_TIMER_INTERVAL_MS, intervalMs),
+ );
+ return track(() => clearInterval(handle));
+ },
+ after: (delayMs, callback) => {
+ const handle = setTimeout(
+ () => {
+ dispose();
+ runGuarded(log, 'Timer', callback);
+ },
+ Math.max(0, delayMs),
+ );
+ const dispose = track(() => clearTimeout(handle));
+ return dispose;
+ },
+ mpv: {
+ get connected() {
+ return deps.isMpvConnected();
+ },
+ on: (event, listener) => {
+ const wrapped = (payload: unknown): void =>
+ runGuarded(log, `mpv ${event} listener`, () =>
+ listener(payload as Parameters[0]),
+ );
+ mpvEvents.on(event, wrapped);
+ return track(() => mpvEvents.off(event, wrapped));
+ },
+ command: (command) => deps.sendMpvCommand(command),
+ getProperty: (name) => deps.requestMpvProperty(name),
+ },
+ subtitles: {
+ current: () => deps.getCurrentSubtitle(),
+ context: (options = {}) => deps.getSubtitleContext(options),
+ },
+ actions: {
+ register: (name, handler) => {
+ if (!isValidPluginName(name)) {
+ throw new Error(`Invalid action name "${name}"; use letters, digits, ".", "_" or "-".`);
+ }
+ const actionId = `${plugin.id}/${name}`;
+ if (actions.has(actionId)) {
+ throw new Error(`Action "${actionId}" is already registered.`);
+ }
+ actions.set(actionId, { pluginId: plugin.id, handler });
+ return track(() => actions.delete(actionId));
+ },
+ },
+ notify: (message, options) => deps.notify(message, options?.variant ?? 'info'),
+ ui: {
+ panel: (content) => {
+ const handle = deps.openPanel({
+ title: content.title ?? plugin.id,
+ markdown: content.markdown,
+ });
+ const dispose = track(() => handle.close());
+ handle.signal.addEventListener('abort', dispose, { once: true });
+ return handle;
+ },
+ },
+ exec: (command, args, options = {}) =>
+ exec(command, args, {
+ ...options,
+ signal: options.signal
+ ? AbortSignal.any([options.signal, plugin.lifetime.signal])
+ : plugin.lifetime.signal,
+ }),
+ unstable: deps.unstable,
+ };
+ }
+
+ function disposePlugin(plugin: LoadedPlugin): void {
+ plugin.lifetime.abort();
+ for (const dispose of [...plugin.disposers]) {
+ try {
+ dispose();
+ } catch {
+ // A failing disposer must not keep the rest registered.
+ }
+ }
+ plugins.delete(plugin.id);
+ }
+
+ async function loadPlugin(id: string, entry: PluginConfigEntry): Promise {
+ const log = deps.createLogger(`plugin:${id}`);
+ if (!isValidPluginName(id)) {
+ log.error(`Invalid plugin id "${id}"; use letters, digits, ".", "_" or "-".`);
+ return;
+ }
+
+ const dir = path.join(deps.pluginsDir, id);
+ let rawManifest: unknown;
+ try {
+ rawManifest = JSON.parse(await fs.readFile(path.join(dir, 'plugin.json'), 'utf8'));
+ } catch (error) {
+ log.error(`Cannot read ${path.join(dir, 'plugin.json')}: ${errorMessage(error)}`);
+ return;
+ }
+ const parsed = parsePluginManifest(rawManifest, id);
+ if ('error' in parsed) {
+ log.error(parsed.error);
+ return;
+ }
+ const entryPath = resolvePluginEntryPath(dir, parsed.manifest.main);
+ if (!entryPath) {
+ log.error(`Entry "${parsed.manifest.main}" must stay inside ${dir}.`);
+ return;
+ }
+
+ const plugin: LoadedPlugin = {
+ id,
+ disposers: new Set(),
+ deactivate: null,
+ lifetime: new AbortController(),
+ };
+ plugins.set(id, plugin);
+ try {
+ const mod = loadModule(entryPath);
+ const activate = pickExport(mod, 'activate');
+ 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);
+ plugin.deactivate =
+ typeof returned === 'function'
+ ? (returned as () => unknown)
+ : pickExport(mod, 'deactivate');
+ const version = parsed.manifest.version ? ` v${parsed.manifest.version}` : '';
+ log.info(`Loaded ${parsed.manifest.name}${version}.`);
+ } catch (error) {
+ log.error(`Activation failed: ${errorMessage(error)}`);
+ disposePlugin(plugin);
+ }
+ }
+
+ return {
+ /** Forwards the client's events to plugin listeners, replacing any previously attached client. */
+ attachMpvClient(client: MpvClientEvents): void {
+ if (attachedClient) {
+ for (const [event, forward] of attachedClient.forwarders) {
+ attachedClient.client.off(event as PluginMpvEventName, forward);
+ }
+ }
+ const forwarders = new Map void>();
+ for (const event of PLUGIN_MPV_EVENT_NAMES) {
+ const forward = (payload: unknown): void => {
+ mpvEvents.emit(event, payload);
+ };
+ client.on(event, forward);
+ forwarders.set(event, forward);
+ }
+ attachedClient = { client, forwarders };
+ },
+
+ /** Loads every enabled plugin once; later calls return the first load. */
+ loadAll(): Promise {
+ loadPromise ??= Promise.all(
+ Object.entries(deps.getPluginConfig())
+ .filter(([, entry]) => entry.enabled)
+ .map(([id, entry]) => loadPlugin(id, entry)),
+ ).then(() => undefined);
+ return loadPromise;
+ },
+
+ async unloadAll(): Promise {
+ loadPromise = null;
+ await Promise.all(
+ [...plugins.values()].map(async (plugin) => {
+ try {
+ await plugin.deactivate?.();
+ } catch (error) {
+ deps
+ .createLogger(`plugin:${plugin.id}`)
+ .error(`deactivate failed: ${errorMessage(error)}`);
+ }
+ disposePlugin(plugin);
+ }),
+ );
+ },
+
+ /** Runs a registered `/` action. Returns false when none is registered. */
+ runAction(actionId: string): boolean {
+ const action = actions.get(actionId);
+ if (!action) return false;
+ runGuarded(
+ deps.createLogger(`plugin:${action.pluginId}`),
+ `Action ${actionId}`,
+ action.handler,
+ );
+ return true;
+ },
+ };
+}
+
+export type PluginHost = ReturnType;
diff --git a/src/core/services/plugins/subtitle-context.test.ts b/src/core/services/plugins/subtitle-context.test.ts
new file mode 100644
index 00000000..1493d021
--- /dev/null
+++ b/src/core/services/plugins/subtitle-context.test.ts
@@ -0,0 +1,63 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+
+import { buildPluginSubtitleContext } from './subtitle-context';
+
+const cues = [
+ { text: '一', startTime: 1, endTime: 2 },
+ { text: 'はい', startTime: 3, endTime: 4 },
+ { text: '二', startTime: 5, endTime: 6 },
+ { text: '三', startTime: 7, endTime: 8 },
+ { text: 'はい', startTime: 9, endTime: 10 },
+ { text: '四', startTime: 11, endTime: 12 },
+];
+
+test('returns neighbors of the matching cue, preferring the one nearest the live timing', () => {
+ // Live timing is shifted by a +1.5 s sub-delay, so it sits outside both はい cues.
+ const context = buildPluginSubtitleContext({
+ cues,
+ current: { text: 'はい', startTime: 10.5, endTime: 11.5 },
+ recentLines: [],
+ before: 2,
+ after: 1,
+ });
+
+ assert.deepEqual(
+ context.before.map((line) => line.text),
+ ['二', '三'],
+ );
+ assert.deepEqual(
+ context.after.map((line) => line.text),
+ ['四'],
+ );
+});
+
+test('falls back to recently displayed lines when the line is not in the cue list', () => {
+ const context = buildPluginSubtitleContext({
+ cues: [],
+ current: { text: '今', startTime: 20, endTime: 21 },
+ recentLines: [
+ { text: '前の前', startTime: 16, endTime: 17 },
+ { text: '前', startTime: 18, endTime: 19 },
+ { text: '今', startTime: 20, endTime: 21 },
+ ],
+ before: 2,
+ });
+
+ assert.deepEqual(
+ context.before.map((line) => line.text),
+ ['前の前', '前'],
+ );
+ assert.deepEqual(context.after, []);
+});
+
+test('returns an empty context when no subtitle is displayed', () => {
+ assert.deepEqual(
+ buildPluginSubtitleContext({
+ cues,
+ current: { text: ' ', startTime: null, endTime: null },
+ recentLines: [],
+ }),
+ { current: null, before: [], after: [] },
+ );
+});
diff --git a/src/core/services/plugins/subtitle-context.ts b/src/core/services/plugins/subtitle-context.ts
new file mode 100644
index 00000000..2e6bd51c
--- /dev/null
+++ b/src/core/services/plugins/subtitle-context.ts
@@ -0,0 +1,86 @@
+import type { PluginSubtitleContext, PluginSubtitleLine } from './types';
+
+interface CueLike {
+ text: string;
+ startTime: number;
+ endTime: number;
+}
+
+const MAX_CONTEXT_LINES = 20;
+
+function normalizeText(text: string): string {
+ return text.replace(/\s+/g, ' ').trim();
+}
+
+function clampCount(value: number | undefined, fallback: number): number {
+ if (value === undefined || !Number.isFinite(value)) return fallback;
+ return Math.min(MAX_CONTEXT_LINES, Math.max(0, Math.floor(value)));
+}
+
+function toLine(cue: CueLike): PluginSubtitleLine {
+ return { text: cue.text.trim(), startTime: cue.startTime, endTime: cue.endTime };
+}
+
+/**
+ * Locates the displayed line in the parsed cue list. Text decides, timing breaks ties: mpv's live
+ * timing can be shifted by sub-delay, so the same text nearest the reported start wins.
+ */
+function findCurrentCueIndex(cues: readonly CueLike[], current: PluginSubtitleLine): number {
+ const target = normalizeText(current.text);
+ const anchor = current.startTime;
+ let best = -1;
+ let bestDistance = Number.POSITIVE_INFINITY;
+ cues.forEach((cue, index) => {
+ if (normalizeText(cue.text) !== target) return;
+ const distance = anchor === null ? 0 : Math.abs(cue.startTime - anchor);
+ if (distance < bestDistance) {
+ best = index;
+ bestDistance = distance;
+ }
+ });
+ if (best >= 0 || anchor === null) return best;
+ return cues.findIndex((cue) => anchor >= cue.startTime && anchor < cue.endTime);
+}
+
+/**
+ * Builds the current line plus neighbors. Uses the parsed subtitle file when one is loaded and
+ * falls back to recently displayed lines (no `after` context) for streams without a cue list.
+ */
+export function buildPluginSubtitleContext(input: {
+ cues: readonly CueLike[];
+ current: PluginSubtitleLine | null;
+ recentLines: readonly PluginSubtitleLine[];
+ before?: number;
+ after?: number;
+}): PluginSubtitleContext {
+ const beforeCount = clampCount(input.before, 2);
+ const afterCount = clampCount(input.after, 1);
+ const current = input.current && normalizeText(input.current.text) ? input.current : null;
+ if (!current) return { current: null, before: [], after: [] };
+
+ const cues = input.cues
+ .filter((cue) => normalizeText(cue.text).length > 0)
+ .slice()
+ .sort((a, b) => a.startTime - b.startTime || a.endTime - b.endTime);
+ const index = findCurrentCueIndex(cues, current);
+ if (index >= 0) {
+ return {
+ current,
+ before: cues.slice(Math.max(0, index - beforeCount), index).map(toLine),
+ after: cues.slice(index + 1, index + 1 + afterCount).map(toLine),
+ };
+ }
+
+ const target = normalizeText(current.text);
+ const recent = input.recentLines.filter((line) => normalizeText(line.text).length > 0);
+ // The tracker usually already holds the current line as its newest entry.
+ const history =
+ recent.length > 0 && normalizeText(recent[recent.length - 1]!.text) === target
+ ? recent.slice(0, -1)
+ : recent;
+ return {
+ current,
+ before: beforeCount > 0 ? history.slice(-beforeCount) : [],
+ after: [],
+ };
+}
diff --git a/src/core/services/plugins/types.ts b/src/core/services/plugins/types.ts
new file mode 100644
index 00000000..f80c33ab
--- /dev/null
+++ b/src/core/services/plugins/types.ts
@@ -0,0 +1,143 @@
+import type { MpvIpcClientEventMap } from '../mpv';
+
+/** `plugin.json` at the root of a plugin directory. */
+export interface PluginManifest {
+ /** Display name; defaults to the directory name. */
+ name: string;
+ version: string | null;
+ /** Entry module, relative to the plugin directory. */
+ main: string;
+}
+
+/** One `plugins.` entry from config. */
+export interface PluginConfigEntry {
+ enabled: boolean;
+ settings: Record;
+}
+
+/** mpv events plugins may observe. Render metrics are internal and stay hidden. */
+export type PluginMpvEventMap = Omit;
+export type PluginMpvEventName = keyof PluginMpvEventMap;
+
+export const PLUGIN_MPV_EVENT_NAMES = [
+ 'connection-change',
+ 'subtitle-change',
+ 'subtitle-timing',
+ 'time-pos-change',
+ 'duration-change',
+ 'pause-change',
+ 'fullscreen-change',
+ 'secondary-subtitle-change',
+ 'subtitle-track-change',
+ 'secondary-subtitle-track-change',
+ 'secondary-subtitle-delay-change',
+ 'subtitle-track-list-change',
+ 'media-path-change',
+ 'media-title-change',
+ 'secondary-subtitle-visibility',
+ 'client-message',
+] as const satisfies readonly PluginMpvEventName[];
+
+export interface PluginSubtitleLine {
+ text: string;
+ /** Seconds on the subtitle file's timeline; null when mpv did not report timing. */
+ startTime: number | null;
+ endTime: number | null;
+}
+
+export interface PluginSubtitleContext {
+ current: PluginSubtitleLine | null;
+ /** Oldest first. */
+ before: PluginSubtitleLine[];
+ after: PluginSubtitleLine[];
+}
+
+export interface PluginPanelContent {
+ title: string;
+ /** Rendered as a safe markdown subset; raw HTML is shown as text. */
+ markdown: string;
+}
+
+export interface PluginPanelHandle {
+ /** Aborts when the user closes the panel or another panel replaces it. */
+ readonly signal: AbortSignal;
+ readonly closed: boolean;
+ update: (content: Partial) => void;
+ close: () => void;
+}
+
+export interface PluginExecOptions {
+ /** Written to the child's stdin, which is then closed. */
+ stdin?: string;
+ cwd?: string;
+ /** Merged over the parent environment. */
+ env?: Record;
+ /** Kills the child and rejects after this many milliseconds. */
+ timeoutMs?: number;
+ /** Kills the child and rejects when aborted. */
+ signal?: AbortSignal;
+}
+
+export interface PluginExecResult {
+ /** Null when the child was terminated by a signal. */
+ exitCode: number | null;
+ stdout: string;
+ stderr: string;
+}
+
+export type PluginNotifyVariant = 'info' | 'warning' | 'error';
+
+export interface PluginLogger {
+ debug: (message: string, ...meta: unknown[]) => void;
+ info: (message: string, ...meta: unknown[]) => void;
+ warn: (message: string, ...meta: unknown[]) => void;
+ error: (message: string, ...meta: unknown[]) => void;
+}
+
+type Disposer = () => void;
+
+/** The API object passed to a plugin's `activate(ctx)`. */
+export interface PluginContext {
+ readonly id: string;
+ /** Absolute path of the plugin directory. */
+ readonly dir: string;
+ /** `plugins..settings` from config. */
+ readonly settings: Readonly>;
+ readonly log: PluginLogger;
+ /** Repeating timer; cleared automatically when the plugin unloads. */
+ every: (intervalMs: number, callback: () => unknown) => Disposer;
+ /** One-shot timer; cleared automatically when the plugin unloads. */
+ after: (delayMs: number, callback: () => unknown) => Disposer;
+ readonly mpv: {
+ readonly connected: boolean;
+ on: (
+ event: E,
+ listener: (payload: PluginMpvEventMap[E]) => unknown,
+ ) => Disposer;
+ command: (command: (string | number)[]) => void;
+ getProperty: (name: string) => Promise;
+ };
+ readonly subtitles: {
+ current: () => PluginSubtitleLine | null;
+ context: (options?: { before?: number; after?: number }) => PluginSubtitleContext;
+ };
+ readonly actions: {
+ /** Bind with `["__plugin-action:/"]` in `keybindings`. */
+ register: (name: string, handler: () => unknown) => Disposer;
+ };
+ 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;
+ };
+ /** Runs a binary without a shell, with the AppImage library path removed from its environment. */
+ exec: (command: string, args: string[], options?: PluginExecOptions) => Promise;
+ /** Raw app internals. No stability guarantee across releases. */
+ readonly unstable: TUnstable;
+}
+
+export interface PluginModule {
+ activate?: (ctx: PluginContext) => unknown;
+ deactivate?: () => unknown;
+ default?: { activate?: (ctx: PluginContext) => unknown; deactivate?: () => unknown };
+}
diff --git a/src/core/services/session-actions.test.ts b/src/core/services/session-actions.test.ts
index c1d0c75c..6d1d8b60 100644
--- a/src/core/services/session-actions.test.ts
+++ b/src/core/services/session-actions.test.ts
@@ -55,6 +55,10 @@ function createDeps(overrides: Partial = {}) {
playNextSubtitle: () => calls.push('play-next'),
cycleRuntimeOption: () => ({ ok: true }),
playNextPlaylistItem: () => calls.push('playlist-next'),
+ runPluginAction: (pluginActionId) => {
+ calls.push(`plugin:${pluginActionId}`);
+ return pluginActionId === 'ai-explain/explain';
+ },
showMpvOsd: (text) => calls.push(`osd:${text}`),
...overrides,
};
@@ -81,3 +85,22 @@ test('dispatchSessionAction does not advance playlist when mark watched no-ops',
assert.deepEqual(calls, ['mark-watched']);
});
+
+test('dispatchSessionAction runs plugin actions and reports unregistered ones', async () => {
+ const { calls, deps } = createDeps();
+
+ await dispatchSessionAction(
+ { actionId: 'runPluginAction', payload: { pluginActionId: 'ai-explain/explain' } },
+ deps,
+ );
+ await dispatchSessionAction(
+ { actionId: 'runPluginAction', payload: { pluginActionId: 'ai-explain/missing' } },
+ deps,
+ );
+
+ assert.deepEqual(calls, [
+ 'plugin:ai-explain/explain',
+ 'plugin:ai-explain/missing',
+ 'osd:Plugin action not available: ai-explain/missing',
+ ]);
+});
diff --git a/src/core/services/session-actions.ts b/src/core/services/session-actions.ts
index b1815380..444f5011 100644
--- a/src/core/services/session-actions.ts
+++ b/src/core/services/session-actions.ts
@@ -32,6 +32,8 @@ export interface SessionActionExecutorDeps {
playNextSubtitle: () => void;
cycleRuntimeOption: (id: RuntimeOptionId, direction: 1 | -1) => RuntimeOptionApplyResult;
playNextPlaylistItem: () => void;
+ /** Returns false when no loaded plugin registered the action. */
+ runPluginAction: (pluginActionId: string) => boolean;
showMpvOsd: (text: string) => void;
}
@@ -151,6 +153,13 @@ export async function dispatchSessionAction(
}
return;
}
+ case 'runPluginAction': {
+ const pluginActionId = request.payload?.pluginActionId;
+ if (!pluginActionId || !deps.runPluginAction(pluginActionId)) {
+ deps.showMpvOsd(`Plugin action not available: ${pluginActionId ?? 'unknown'}`);
+ }
+ return;
+ }
default:
return assertUnreachableSessionAction(request.actionId);
}
diff --git a/src/core/services/session-bindings.test.ts b/src/core/services/session-bindings.test.ts
index 9d1cb7f1..f2ff3e8a 100644
--- a/src/core/services/session-bindings.test.ts
+++ b/src/core/services/session-bindings.test.ts
@@ -323,6 +323,31 @@ test('compileSessionBindings maps the legacy Animetosho command to the TsukiHime
);
});
+test('compileSessionBindings maps plugin action commands to a validated session action', () => {
+ const result = compileSessionBindings({
+ shortcuts: createShortcuts(),
+ keybindings: [
+ createKeybinding('Ctrl+E', [`${SPECIAL_COMMANDS.PLUGIN_ACTION_PREFIX}ai-explain/explain`]),
+ createKeybinding('Ctrl+R', [`${SPECIAL_COMMANDS.PLUGIN_ACTION_PREFIX}no-action-name`]),
+ ],
+ platform: 'linux',
+ });
+
+ const binding = result.bindings[0];
+ assert.equal(result.bindings.length, 1);
+ assert.ok(binding?.actionType === 'session-action');
+ assert.equal(binding.actionId, 'runPluginAction');
+ assert.deepEqual(binding.payload, { pluginActionId: 'ai-explain/explain' });
+ assert.deepEqual(
+ parseSessionActionDispatchRequest({ actionId: binding.actionId, payload: binding.payload }),
+ { actionId: 'runPluginAction', payload: { pluginActionId: 'ai-explain/explain' } },
+ );
+ assert.deepEqual(
+ result.warnings.map((warning) => `${warning.kind}:${warning.path}`),
+ ['unsupported:keybindings[1].command'],
+ );
+});
+
test('compileSessionBindings leaves retired subtitle-delay shift tokens as mpv commands', () => {
const result = compileSessionBindings({
shortcuts: createShortcuts(),
diff --git a/src/core/services/session-bindings.ts b/src/core/services/session-bindings.ts
index 89d17eb7..32a613ba 100644
--- a/src/core/services/session-bindings.ts
+++ b/src/core/services/session-bindings.ts
@@ -342,6 +342,16 @@ function resolveCommandBinding(
if (command.length !== 1) return null;
return { actionType: 'session-action', actionId: 'playNextSubtitle' };
}
+ if (first.startsWith(SPECIAL_COMMANDS.PLUGIN_ACTION_PREFIX)) {
+ const pluginActionId = first.slice(SPECIAL_COMMANDS.PLUGIN_ACTION_PREFIX.length);
+ const [pluginId, actionName, ...rest] = pluginActionId.split('/');
+ if (command.length !== 1 || !pluginId || !actionName || rest.length > 0) return null;
+ return {
+ actionType: 'session-action',
+ actionId: 'runPluginAction',
+ payload: { pluginActionId },
+ };
+ }
if (first.startsWith(SPECIAL_COMMANDS.RUNTIME_OPTION_CYCLE_PREFIX)) {
if (command.length !== 1) {
return null;
diff --git a/src/core/services/startup.ts b/src/core/services/startup.ts
index e7ddfe3a..6ebffa49 100644
--- a/src/core/services/startup.ts
+++ b/src/core/services/startup.ts
@@ -117,6 +117,8 @@ export interface AppReadyRuntimeDeps {
loadSubtitlePosition: () => void;
resolveKeybindings: () => void;
createMpvClient: () => void;
+ /** Loads enabled plugins; full startup only. */
+ startPlugins?: () => void;
reloadConfig: () => void;
getResolvedConfig: () => AppReadyConfigLike;
getConfigWarnings: () => ConfigValidationWarning[];
@@ -310,6 +312,7 @@ export async function runAppReadyRuntime(deps: AppReadyRuntimeDeps): Promise {
- const env = buildNotifySendEnv({
- LD_LIBRARY_PATH: '/tmp/.mount_SubMinXXXXXX/usr/lib',
- DBUS_SESSION_BUS_ADDRESS: 'unix:path=/run/user/1000/bus',
- HOME: '/home/user',
- });
-
- assert.equal(env.LD_LIBRARY_PATH, undefined);
- assert.equal(env.DBUS_SESSION_BUS_ADDRESS, 'unix:path=/run/user/1000/bus');
- assert.equal(env.HOME, '/home/user');
-});
+import { createNotifySendReplacer, resolveDefaultNotificationIconPath } from './notification';
test('default notification icon resolves packaged SubMiner asset when no per-notification icon is provided', () => {
const path = resolveDefaultNotificationIconPath({
diff --git a/src/core/utils/notification.ts b/src/core/utils/notification.ts
index 86d1f07c..cefe858d 100644
--- a/src/core/utils/notification.ts
+++ b/src/core/utils/notification.ts
@@ -3,6 +3,7 @@ import electron from 'electron';
import * as fs from 'fs';
import * as path from 'path';
import { createLogger } from '../../logger';
+import { buildSystemBinaryEnv } from './system-binary-env';
const { Notification, nativeImage } = electron;
const logger = createLogger('core:notification');
@@ -203,19 +204,8 @@ export function createNotifySendReplacer(
};
}
-/**
- * Electron AppImages export `LD_LIBRARY_PATH=/usr/lib`, whose bundled libnotify predates the
- * symbols the system notify-send links against, so an inherited environment kills the child with a
- * symbol lookup error before it can send anything. A system binary resolves its own libraries fine,
- * so the override is dropped entirely rather than filtered.
- */
-export function buildNotifySendEnv(env: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv {
- const { LD_LIBRARY_PATH: _dropped, ...rest } = env;
- return rest;
-}
-
const showLinuxReplaceableNotification = createNotifySendReplacer((args, callback) =>
- execFile('notify-send', args, { timeout: 5_000, env: buildNotifySendEnv() }, (error, stdout) =>
+ execFile('notify-send', args, { timeout: 5_000, env: buildSystemBinaryEnv() }, (error, stdout) =>
callback(error, stdout ?? ''),
),
);
diff --git a/src/core/utils/system-binary-env.test.ts b/src/core/utils/system-binary-env.test.ts
new file mode 100644
index 00000000..7bb75692
--- /dev/null
+++ b/src/core/utils/system-binary-env.test.ts
@@ -0,0 +1,16 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+
+import { buildSystemBinaryEnv } from './system-binary-env';
+
+test('system binary environment drops the AppImage library-path override', () => {
+ const env = buildSystemBinaryEnv({
+ LD_LIBRARY_PATH: '/tmp/.mount_SubMinXXXXXX/usr/lib',
+ DBUS_SESSION_BUS_ADDRESS: 'unix:path=/run/user/1000/bus',
+ HOME: '/home/user',
+ });
+
+ assert.equal(env.LD_LIBRARY_PATH, undefined);
+ assert.equal(env.DBUS_SESSION_BUS_ADDRESS, 'unix:path=/run/user/1000/bus');
+ assert.equal(env.HOME, '/home/user');
+});
diff --git a/src/core/utils/system-binary-env.ts b/src/core/utils/system-binary-env.ts
new file mode 100644
index 00000000..bd298bdc
--- /dev/null
+++ b/src/core/utils/system-binary-env.ts
@@ -0,0 +1,10 @@
+/**
+ * Electron AppImages export `LD_LIBRARY_PATH=/usr/lib`, whose bundled libraries (libnotify and
+ * friends) predate the symbols system binaries link against, so an inherited environment can kill a
+ * spawned child with a symbol lookup error. A system binary resolves its own libraries fine, so the
+ * override is dropped entirely rather than filtered.
+ */
+export function buildSystemBinaryEnv(env: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv {
+ const { LD_LIBRARY_PATH: _dropped, ...rest } = env;
+ return rest;
+}
diff --git a/src/main.ts b/src/main.ts
index ff8a36e1..0088a5f3 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -512,6 +512,13 @@ import { openSubsyncManualModal as openSubsyncManualModalRuntime } from './main/
import { openSessionHelpModal as openSessionHelpModalRuntime } from './main/runtime/session-help-open';
import { openChangelogModal as openChangelogModalRuntime } from './main/runtime/changelog-open';
import { createChangelogRuntime } from './main/runtime/changelog/changelog-runtime';
+import { createPluginHost } from './core/services/plugins/plugin-host';
+import { buildPluginSubtitleContext } from './core/services/plugins/subtitle-context';
+import type { PluginSubtitleLine } from './core/services/plugins/types';
+import {
+ createPluginPanelRuntime,
+ openPluginPanelModal,
+} from './main/runtime/plugin-panel-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';
@@ -3078,6 +3085,77 @@ function createOverlayHostedModalOpenDeps(): {
};
}
+const pluginPanelRuntime = createPluginPanelRuntime({
+ openModal: (getSnapshot) => openPluginPanelModal(createOverlayHostedModalOpenDeps(), getSnapshot),
+ // The panel may live in either window; broadcastToOverlayWindows only reaches the main one.
+ broadcast: (channel, payload) => {
+ for (const window of [overlayManager.getMainWindow(), overlayManager.getModalWindow()]) {
+ if (window && !window.isDestroyed()) window.webContents.send(channel, payload);
+ }
+ },
+ onOpenFailed: () =>
+ overlayNotificationsRuntime.showConfiguredStatusNotification('Plugin panel unavailable.', {
+ variant: 'warning',
+ }),
+});
+
+function getPluginCurrentSubtitle(): PluginSubtitleLine | null {
+ const text = appState.currentSubText.trim();
+ if (!text) return null;
+ // Same view as mining: the parsed line with its cue timings on the subtitle file's timeline.
+ const resolved = resolvePrimarySubtitle({
+ liveText: appState.mpvClient?.currentSubText ?? '',
+ currentTimeSec: Number(appState.mpvClient?.currentTimePos),
+ cues: appState.activeParsedSubtitleCues,
+ });
+ const resolvedText = resolved?.text.replace(/\n{2,}/g, '\n').trim();
+ if (
+ resolved &&
+ resolvedText &&
+ Number.isFinite(resolved.startTime) &&
+ Number.isFinite(resolved.endTime) &&
+ resolved.endTime > resolved.startTime
+ ) {
+ return { text: resolvedText, startTime: resolved.startTime, endTime: resolved.endTime };
+ }
+ const client = appState.mpvClient;
+ // The client reports a cleared sub-start as zero, so only trust timing for a live line.
+ const hasTiming = client !== null && client.currentSubEnd > client.currentSubStart;
+ return {
+ text,
+ startTime: hasTiming ? client.currentSubStart : null,
+ endTime: hasTiming ? client.currentSubEnd : null,
+ };
+}
+
+const pluginHost = createPluginHost({
+ pluginsDir: path.join(CONFIG_DIR, 'plugins'),
+ getPluginConfig: () => configService.getConfig().plugins,
+ createLogger,
+ isMpvConnected: () => appState.mpvClient?.connected === true,
+ sendMpvCommand: (command) => sendMpvCommandRuntime(appState.mpvClient, command),
+ requestMpvProperty: async (name) => {
+ if (!appState.mpvClient) throw new Error('mpv is not connected.');
+ return await appState.mpvClient.requestProperty(name);
+ },
+ getCurrentSubtitle: () => getPluginCurrentSubtitle(),
+ getSubtitleContext: (options) =>
+ buildPluginSubtitleContext({
+ cues: appState.activeParsedSubtitleCues,
+ current: getPluginCurrentSubtitle(),
+ recentLines: (appState.subtitleTimingTracker?.getRecentEntries(40) ?? []).map((entry) => ({
+ text: entry.displayText,
+ startTime: entry.startTime,
+ endTime: entry.endTime,
+ })),
+ ...options,
+ }),
+ notify: (message, variant) =>
+ overlayNotificationsRuntime.showConfiguredStatusNotification(message, { variant }),
+ openPanel: (content) => pluginPanelRuntime.open(content),
+ unstable: { appState, configService, overlayManager },
+});
+
const mediaTimingFrameExtractor = createMediaTimingFrameExtractor();
const mediaTimingReviewRuntime = createMediaTimingReviewRuntime({
getMpvClient: () => appState.mpvClient,
@@ -4583,6 +4661,9 @@ const { appReadyRuntimeRunner } = composeAppReadyRuntime({
createMpvClient: () => {
appState.mpvClient = createMpvClientRuntimeService();
},
+ startPlugins: () => {
+ void pluginHost.loadAll();
+ },
getResolvedConfig: () => configService.getConfig(),
getConfigWarnings: () => configService.getWarnings(),
logConfigWarning: (warning) => appLogger.logConfigWarning(warning),
@@ -5279,6 +5360,7 @@ function createMpvClientRuntimeService(): MpvIpcClient {
client.on('media-path-change', aniSkipRuntime.handleMediaPathChange);
client.on('time-pos-change', aniSkipRuntime.handleTimePosChange);
client.on('client-message', aniSkipRuntime.handleClientMessage);
+ pluginHost.attachMpvClient(client);
return client;
}
@@ -5860,6 +5942,7 @@ async function dispatchSessionAction(request: SessionActionDispatchRequest): Pro
},
playNextPlaylistItem: () =>
sendMpvCommandRuntime(appState.mpvClient, ['playlist-next', 'force']),
+ runPluginAction: (pluginActionId) => pluginHost.runAction(pluginActionId),
showMpvOsd: (text) => showConfiguredPlaybackFeedback(text),
});
}
@@ -5945,6 +6028,9 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
if (modal === 'media-timing-review') {
void mediaTimingReviewRuntime.dispose();
}
+ if (modal === 'plugin-panel') {
+ pluginPanelRuntime.handleModalClosed();
+ }
if (modal === 'subtitle-sidebar' && senderWindow === overlayManager.getMainWindow()) {
subtitleSidebarRequestedOpen = false;
}
@@ -6961,3 +7047,4 @@ registerSubtitleGenerationIpc({
openModal: () => openSubtitleGenerationModal(createOverlayHostedModalOpenDeps()),
});
app.on('before-quit', () => subtitleGenerationRuntime.cancel());
+app.on('before-quit', () => void pluginHost.unloadAll());
diff --git a/src/main/app-lifecycle.ts b/src/main/app-lifecycle.ts
index e8f87b70..8c38c797 100644
--- a/src/main/app-lifecycle.ts
+++ b/src/main/app-lifecycle.ts
@@ -24,6 +24,7 @@ export interface AppReadyRuntimeDepsFactoryInput {
loadSubtitlePosition: AppReadyRuntimeDeps['loadSubtitlePosition'];
resolveKeybindings: AppReadyRuntimeDeps['resolveKeybindings'];
createMpvClient: AppReadyRuntimeDeps['createMpvClient'];
+ startPlugins?: AppReadyRuntimeDeps['startPlugins'];
reloadConfig: AppReadyRuntimeDeps['reloadConfig'];
getResolvedConfig: AppReadyRuntimeDeps['getResolvedConfig'];
getConfigWarnings: AppReadyRuntimeDeps['getConfigWarnings'];
@@ -94,6 +95,7 @@ export function createAppReadyRuntimeDeps(
loadSubtitlePosition: params.loadSubtitlePosition,
resolveKeybindings: params.resolveKeybindings,
createMpvClient: params.createMpvClient,
+ startPlugins: params.startPlugins,
reloadConfig: params.reloadConfig,
getResolvedConfig: params.getResolvedConfig,
getConfigWarnings: params.getConfigWarnings,
diff --git a/src/main/runtime/app-ready-main-deps.ts b/src/main/runtime/app-ready-main-deps.ts
index 6de17228..f30c289a 100644
--- a/src/main/runtime/app-ready-main-deps.ts
+++ b/src/main/runtime/app-ready-main-deps.ts
@@ -6,6 +6,7 @@ export function createBuildAppReadyRuntimeMainDepsHandler(deps: AppReadyRuntimeD
loadSubtitlePosition: deps.loadSubtitlePosition,
resolveKeybindings: deps.resolveKeybindings,
createMpvClient: deps.createMpvClient,
+ startPlugins: deps.startPlugins,
reloadConfig: deps.reloadConfig,
getResolvedConfig: deps.getResolvedConfig,
getConfigWarnings: deps.getConfigWarnings,
diff --git a/src/main/runtime/plugin-panel-runtime.test.ts b/src/main/runtime/plugin-panel-runtime.test.ts
new file mode 100644
index 00000000..3e3f0629
--- /dev/null
+++ b/src/main/runtime/plugin-panel-runtime.test.ts
@@ -0,0 +1,81 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+
+import { IPC_CHANNELS } from '../../shared/ipc/contracts';
+import type { PluginPanelSnapshot } from '../../types/runtime';
+import { createPluginPanelRuntime } from './plugin-panel-runtime';
+
+function createHarness(openResult: Promise) {
+ const sent: Array<{ channel: string; payload: unknown }> = [];
+ const opened: PluginPanelSnapshot[] = [];
+ let openFailures = 0;
+ const runtime = createPluginPanelRuntime({
+ openModal: async (getSnapshot) => {
+ opened.push(getSnapshot());
+ return await openResult;
+ },
+ broadcast: (channel, payload) => sent.push({ channel, payload }),
+ onOpenFailed: () => {
+ openFailures += 1;
+ },
+ });
+ return { runtime, sent, opened, failures: () => openFailures };
+}
+
+test('re-sends content updated before the renderer acknowledged the open', async () => {
+ let acknowledge!: (opened: boolean) => void;
+ const h = createHarness(new Promise((resolve) => (acknowledge = resolve)));
+
+ const panel = h.runtime.open({ title: 'Explain', markdown: '_Asking..._' });
+ panel.update({ markdown: 'done' });
+ acknowledge(true);
+ await new Promise((resolve) => setImmediate(resolve));
+
+ assert.deepEqual(h.opened, [{ panelId: 1, title: 'Explain', markdown: '_Asking..._' }]);
+ assert.deepEqual(h.sent.at(-1), {
+ channel: IPC_CHANNELS.event.pluginPanelUpdate,
+ payload: { panelId: 1, title: 'Explain', markdown: 'done' },
+ });
+});
+
+test('closing from the overlay aborts the handle and ignores later updates', async () => {
+ const h = createHarness(Promise.resolve(true));
+ const panel = h.runtime.open({ title: 'Explain', markdown: '' });
+ await new Promise((resolve) => setImmediate(resolve));
+ const sentBefore = h.sent.length;
+
+ h.runtime.handleModalClosed();
+ panel.update({ markdown: 'too late' });
+
+ assert.equal(panel.signal.aborted, true);
+ assert.equal(panel.closed, true);
+ assert.equal(h.sent.length, sentBefore);
+});
+
+test('opening a new panel aborts the previous one', () => {
+ const h = createHarness(Promise.resolve(true));
+ const first = h.runtime.open({ title: 'One', markdown: '' });
+ const second = h.runtime.open({ title: 'Two', markdown: '' });
+
+ assert.equal(first.signal.aborted, true);
+ assert.equal(second.signal.aborted, false);
+});
+
+test('plugin close tells the renderer which panel to close', () => {
+ const h = createHarness(Promise.resolve(true));
+ const panel = h.runtime.open({ title: 'One', markdown: '' });
+
+ panel.close();
+
+ assert.equal(panel.signal.aborted, true);
+ assert.deepEqual(h.sent.at(-1), { channel: IPC_CHANNELS.event.pluginPanelClose, payload: 1 });
+});
+
+test('a failed open aborts the handle and reports it once', async () => {
+ const h = createHarness(Promise.resolve(false));
+ const panel = h.runtime.open({ title: 'One', markdown: '' });
+ await new Promise((resolve) => setImmediate(resolve));
+
+ assert.equal(panel.signal.aborted, true);
+ assert.equal(h.failures(), 1);
+});
diff --git a/src/main/runtime/plugin-panel-runtime.ts b/src/main/runtime/plugin-panel-runtime.ts
new file mode 100644
index 00000000..4994608f
--- /dev/null
+++ b/src/main/runtime/plugin-panel-runtime.ts
@@ -0,0 +1,111 @@
+import type { PluginPanelContent, PluginPanelHandle } from '../../core/services/plugins/types';
+import type { OverlayHostedModal } from '../../shared/ipc/contracts';
+import { IPC_CHANNELS } from '../../shared/ipc/contracts';
+import type { PluginPanelSnapshot } from '../../types/runtime';
+import { openOverlayHostedModal, retryOverlayModalOpen } from './overlay-hosted-modal-open';
+
+const PLUGIN_PANEL_MODAL: OverlayHostedModal = 'plugin-panel';
+const PLUGIN_PANEL_OPEN_TIMEOUT_MS = 1500;
+
+interface PanelState {
+ panelId: number;
+ content: PluginPanelContent;
+ controller: AbortController;
+}
+
+/** Sends the open request, retrying once like the other overlay-hosted modals. */
+export async function openPluginPanelModal(
+ deps: Parameters[0] & Parameters[0],
+ getSnapshot: () => PluginPanelSnapshot,
+): Promise {
+ return await retryOverlayModalOpen(deps, {
+ modal: PLUGIN_PANEL_MODAL,
+ timeoutMs: PLUGIN_PANEL_OPEN_TIMEOUT_MS,
+ retryWarning:
+ 'Plugin panel did not acknowledge modal open on first attempt; retrying dedicated modal window.',
+ sendOpen: () =>
+ openOverlayHostedModal(deps, {
+ channel: IPC_CHANNELS.event.pluginPanelOpen,
+ modal: PLUGIN_PANEL_MODAL,
+ payload: getSnapshot(),
+ preferModalWindow: true,
+ }),
+ });
+}
+
+/**
+ * Owns the single plugin panel. Opening a panel replaces the previous one, and each handle's
+ * signal aborts when its panel goes away so plugins can stop the work feeding it.
+ */
+export function createPluginPanelRuntime(deps: {
+ openModal: (getSnapshot: () => PluginPanelSnapshot) => Promise;
+ /** Sends to every overlay window; renderers ignore panel ids they are not showing. */
+ broadcast: (channel: string, payload: unknown) => void;
+ onOpenFailed: () => void;
+}) {
+ let nextPanelId = 0;
+ let active: PanelState | null = null;
+
+ const snapshot = (state: PanelState): PluginPanelSnapshot => ({
+ panelId: state.panelId,
+ ...state.content,
+ });
+
+ const finish = (state: PanelState): void => {
+ if (active === state) active = null;
+ state.controller.abort();
+ };
+
+ return {
+ open(content: PluginPanelContent): PluginPanelHandle {
+ if (active) finish(active);
+ const state: PanelState = {
+ panelId: ++nextPanelId,
+ content: { ...content },
+ controller: new AbortController(),
+ };
+ active = state;
+
+ const handleOpenFailure = (): void => {
+ if (state.controller.signal.aborted) return;
+ finish(state);
+ deps.onOpenFailed();
+ };
+ deps
+ .openModal(() => snapshot(state))
+ .then((opened) => {
+ if (!opened) {
+ handleOpenFailure();
+ return;
+ }
+ // Updates sent before the renderer acknowledged the open were dropped there.
+ if (!state.controller.signal.aborted) {
+ deps.broadcast(IPC_CHANNELS.event.pluginPanelUpdate, snapshot(state));
+ }
+ })
+ .catch(handleOpenFailure);
+
+ return {
+ signal: state.controller.signal,
+ get closed() {
+ return state.controller.signal.aborted;
+ },
+ update: (next) => {
+ if (state.controller.signal.aborted) return;
+ state.content = { ...state.content, ...next };
+ deps.broadcast(IPC_CHANNELS.event.pluginPanelUpdate, snapshot(state));
+ },
+ close: () => {
+ if (state.controller.signal.aborted) return;
+ finish(state);
+ deps.broadcast(IPC_CHANNELS.event.pluginPanelClose, state.panelId);
+ },
+ };
+ },
+
+ /** The user closed the panel in the overlay. */
+ handleModalClosed(): void {
+ if (active) finish(active);
+ },
+ };
+}
diff --git a/src/preload.ts b/src/preload.ts
index 45a19000..45e0f6e9 100644
--- a/src/preload.ts
+++ b/src/preload.ts
@@ -105,6 +105,7 @@ import type {
OverlayNotificationEventPayload,
OverlayNotificationPosition,
ChangelogSnapshot,
+ PluginPanelSnapshot,
MediaTimingReviewActionResult,
MediaTimingReviewOpenPayload,
MediaTimingReviewPreviewPosition,
@@ -140,6 +141,20 @@ const onOpenSessionHelpEvent = createQueuedIpcListenerWithPayload
+ >;
+ return {
+ panelId: typeof value.panelId === 'number' ? value.panelId : -1,
+ title: typeof value.title === 'string' ? value.title : '',
+ markdown: typeof value.markdown === 'string' ? value.markdown : '',
+ };
+}
+const onOpenPluginPanelEvent = createQueuedIpcListenerWithPayload(
+ IPC_CHANNELS.event.pluginPanelOpen,
+ toPluginPanelSnapshot,
+);
const onOpenCharacterDictionaryManagerEvent = createQueuedIpcListener(
IPC_CHANNELS.event.characterDictionaryManagerOpen,
);
@@ -415,6 +430,17 @@ const electronAPI: ElectronAPI = {
onOpenChangelog: onOpenChangelogEvent,
getChangelogSnapshot: (options?: { refresh?: boolean }): Promise =>
ipcRenderer.invoke(IPC_CHANNELS.request.getChangelogSnapshot, options),
+ onOpenPluginPanel: onOpenPluginPanelEvent,
+ onPluginPanelUpdate: (callback: (snapshot: PluginPanelSnapshot) => void): void => {
+ ipcRenderer.on(IPC_CHANNELS.event.pluginPanelUpdate, (_event, payload: unknown) => {
+ callback(toPluginPanelSnapshot(payload));
+ });
+ },
+ onPluginPanelClose: (callback: (panelId: number) => void): void => {
+ ipcRenderer.on(IPC_CHANNELS.event.pluginPanelClose, (_event, panelId: unknown) => {
+ if (typeof panelId === 'number') callback(panelId);
+ });
+ },
onOpenControllerSelect: onOpenControllerSelectEvent,
onOpenControllerDebug: onOpenControllerDebugEvent,
onOpenJimaku: onOpenJimakuEvent,
diff --git a/src/renderer/handlers/keyboard.test.ts b/src/renderer/handlers/keyboard.test.ts
index d23b6b74..aa4b5f7d 100644
--- a/src/renderer/handlers/keyboard.test.ts
+++ b/src/renderer/handlers/keyboard.test.ts
@@ -595,6 +595,7 @@ function createKeyboardHandlerHarness() {
changelogKeydownCount += 1;
return true;
},
+ handlePluginPanelKeydown: () => false,
openSessionHelpModal: () => {},
openControllerSelectModal: () => {
openControllerSelectCount += 1;
diff --git a/src/renderer/handlers/keyboard.ts b/src/renderer/handlers/keyboard.ts
index b1c0f47a..ce3e5c08 100644
--- a/src/renderer/handlers/keyboard.ts
+++ b/src/renderer/handlers/keyboard.ts
@@ -32,6 +32,7 @@ export function createKeyboardHandlers(
handleControllerDebugKeydown: (e: KeyboardEvent) => boolean;
handleSessionHelpKeydown: (e: KeyboardEvent) => boolean;
handleChangelogKeydown: (e: KeyboardEvent) => boolean;
+ handlePluginPanelKeydown: (e: KeyboardEvent) => boolean;
openSessionHelpModal: (opening: {
bindingKey: 'KeyH' | 'KeyK';
fallbackUsed: boolean;
@@ -1172,6 +1173,11 @@ export function createKeyboardHandlers(
return;
}
+ if (ctx.state.pluginPanelModalOpen) {
+ options.handlePluginPanelKeydown(e);
+ return;
+ }
+
if (ctx.state.runtimeOptionsModalOpen) {
options.handleRuntimeOptionsKeydown(e);
return;
diff --git a/src/renderer/index.html b/src/renderer/index.html
index fb6a8aa3..782bc385 100644
--- a/src/renderer/index.html
+++ b/src/renderer/index.html
@@ -1019,6 +1019,15 @@
+