feat(plugins): add JavaScript plugin system with overlay panel

- Load plugins from <config dir>/plugins/<id>/ when enabled under `plugins` in config
- Expose ctx with mpv events/commands, timers, subtitle context, actions, notify, exec, and panel
- Bind plugin actions via `__plugin-action:<id>/<action>` keybindings (runPluginAction session action)
- Add plugin-panel modal with markdown rendering; closing aborts the panel signal
- Add ai-explain example plugin that queries `codex exec` for the current line
- Add e2e harness `files` option and plugin e2e scenario; document plugins in docs-site and internal docs
This commit is contained in:
sudacode committed 2026-10-26 21:38:51 -07:00
1 parent d3a94536a8
commit ca534cd232
65 files changed
+2554 -38

No files matched your search

+4
View File
@@ -102,6 +102,10 @@ A stats dashboard tracks watch time, vocabulary growth, mining throughput, sessi
<td><b>WebSocket</b></td> <td><b>WebSocket</b></td>
<td>Plain subtitle feed plus a dedicated annotated feed for texthooker pages and custom tools</td> <td>Plain subtitle feed plus a dedicated annotated feed for texthooker pages and custom tools</td>
</tr> </tr>
<tr>
<td><b>Plugins</b></td>
<td>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. <a href="https://docs.subminer.moe/plugins">Plugin guide</a></td>
</tr>
</table> </table>
<br> <br>
+5
View File
@@ -0,0 +1,5 @@
type: added
area: plugins
- Added JavaScript plugins loaded at startup from `<config dir>/plugins/<id>/` and enabled under `plugins` in the config. Plugins can listen to mpv events, run timers, register actions bound with `__plugin-action:<id>/<action>` keybindings, run programs, and show markdown results in an overlay panel.
- Added an example `ai-explain` plugin that sends the current line and its neighbors to `codex exec` and shows a translation or grammar explanation.
+8
View File
@@ -218,6 +218,14 @@
"overlayPosition": "top-right" // Position for in-overlay notification cards. Values: top-left | top | top-right "overlayPosition": "top-right" // Position for in-overlay notification cards. Values: top-left | top | top-right
}, // Overlay notification display behavior. }, // Overlay notification display behavior.
// ==========================================
// Plugins
// JavaScript plugins loaded from <config dir>/plugins/<id>/ 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 <config dir>/plugins/. Each entry is { "enabled": true, "settings": { ... } }; settings are passed to the plugin as ctx.settings.
// ========================================== // ==========================================
// Keyboard Shortcuts // Keyboard Shortcuts
// Overlay keyboard shortcuts. Set a shortcut to null to disable. // Overlay keyboard shortcuts. Set a shortcut to null to disable.
+1
View File
@@ -221,6 +221,7 @@ const sidebar: DefaultTheme.SidebarItem[] = [
{ text: 'AniList', link: '/anilist-integration' }, { text: 'AniList', link: '/anilist-integration' },
{ text: 'AniSkip', link: '/aniskip-integration' }, { text: 'AniSkip', link: '/aniskip-integration' },
{ text: 'Character Dictionary', link: '/character-dictionary' }, { text: 'Character Dictionary', link: '/character-dictionary' },
{ text: 'Plugins', link: '/plugins' },
], ],
}, },
{ {
+10 -1
View File
@@ -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)). 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.<id>.enabled` | `false` | Load the plugin at startup |
| `plugins.<id>.settings` | `{}` | Passed to the plugin as `ctx.settings` |
### Auto-start overlay ### 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. 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`. - `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. - `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:<id>[: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:<id>[:next|prev]`, and `__plugin-action:<plugin id>/<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. - 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 ### Shortcuts configuration
+106
View File
@@ -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:<plugin id>/<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:<id>` 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.<id>.settings` |
| `log.debug/info/warn/error(message)` | Write to the app log |
| `every(ms, fn)`, `after(ms, fn)` | Repeating and one-shot timers. Return a function that cancels them |
| `mpv.on(event, fn)` | Listen to mpv events (below). Returns an unsubscribe function |
| `mpv.command(args)` | Send an mpv command, e.g. `['set', 'pause', 'yes']` |
| `mpv.getProperty(name)` | Read an mpv property (Promise) |
| `mpv.connected` | Whether mpv is connected |
| `subtitles.current()` | The displayed line as `{ text, startTime, endTime }`, or `null` |
| `subtitles.context({ before, after })` | `{ current, before, after }`. Neighbors come from the loaded subtitle file, or recent lines for streams |
| `actions.register(name, fn)` | Add an action, bindable as `__plugin-action:<id>/<name>` |
| `notify(message, { variant })` | Overlay notification. `variant` is `info`, `warning`, or `error` |
| `ui.panel({ title, markdown })` | Open the plugin panel. Returns `{ update(content), close(), closed, signal }` |
| `exec(command, args, options)` | Run a program without a shell. Resolves `{ exitCode, stdout, stderr }` |
| `unstable` | SubMiner internals. These change between releases without notice |
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`.
+8
View File
@@ -218,6 +218,14 @@
"overlayPosition": "top-right" // Position for in-overlay notification cards. Values: top-left | top | top-right "overlayPosition": "top-right" // Position for in-overlay notification cards. Values: top-left | top | top-right
}, // Overlay notification display behavior. }, // Overlay notification display behavior.
// ==========================================
// Plugins
// JavaScript plugins loaded from <config dir>/plugins/<id>/ 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 <config dir>/plugins/. Each entry is { "enabled": true, "settings": { ... } }; settings are passed to the plugin as ctx.settings.
// ========================================== // ==========================================
// Keyboard Shortcuts // Keyboard Shortcuts
// Overlay keyboard shortcuts. Set a shortcut to null to disable. // Overlay keyboard shortcuts. Set a shortcut to null to disable.
+2
View File
@@ -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:<plugin id>/<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. 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 ## Automatic mpv bindings
+1
View File
@@ -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. - `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/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/shared/session-key-sequences.ts` rejects sequence prefixes reserved by single-key actions. The session-binding compiler reserves configured and built-in overlay keys; `src/main/runtime/session-bindings-runtime.ts` adds active mpv bindings and publishes the effective list to both the plugin artifact and the renderer through `session-bindings:changed`. mpv no-op `ignore` bindings do not reserve prefixes.
- `src/core/services/plugins/` owns user plugins: discovery from `<config dir>/plugins/<id>/plugin.json` for ids enabled in `plugins`, the per-plugin `ctx`, guarded callbacks, and `exec` (no shell, AppImage `LD_LIBRARY_PATH` dropped). `main.ts` supplies the host's deps, attaches each new mpv client so plugin listeners survive client replacement, starts plugins from the app-ready path after logging is configured, and unloads them on `before-quit`. Plugins run in the main process with no sandbox. Plugin actions are the `runPluginAction` session action (keybinding command `__plugin-action:<id>/<name>`). `src/main/runtime/plugin-panel-runtime.ts` owns the single `plugin-panel` modal: a new panel aborts the previous handle's signal, updates broadcast to both overlay windows keyed by `panelId`, and the latest content is re-sent once the renderer acknowledges the open.
- `src/renderer/` owns overlay rendering and input behavior. - `src/renderer/` owns overlay rendering and input behavior.
- `src/config/` owns config definitions, defaults, loading, and resolution. - `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. - `src/types/` owns shared cross-runtime contracts via domain entrypoints; `src/types.ts` stays a compatibility barrel.
+1
View File
@@ -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/` - 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. - 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-*` - 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/` - Window trackers: `src/window-trackers/`
- Stats HTTP app: `src/core/services/stats-server.ts`, with route groups and shared route support - Stats HTTP app: `src/core/services/stats-server.ts`, with route groups and shared route support
in `src/core/services/stats-server/` in `src/core/services/stats-server/`
+2 -1
View File
@@ -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 per-window screenshots), mpv over its JSON IPC socket, and app commands through the
normal CLI handoff (`--mine-sentence`, `--toggle-subtitle-sidebar`, ...). normal CLI handoff (`--mine-sentence`, `--toggle-subtitle-sidebar`, ...).
- Scenarios live in `e2e/*.e2e.test.ts` and share one session per file through - 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 - 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`). `SUBMINER_E2E_KEEP=1` to keep it after any run (`app-stdio.log`, `app.log`, `mpv.log`).
+11
View File
@@ -28,6 +28,8 @@ type JsonValue = string | number | boolean | null | JsonValue[] | JsonObject;
export type E2eSessionOptions = { export type E2eSessionOptions = {
/** Deep-merged over the harness config, for scenarios that need other settings. */ /** Deep-merged over the harness config, for scenarios that need other settings. */
config?: JsonObject; config?: JsonObject;
/** Files written before launch, keyed by path relative to the config dir (e.g. plugins). */
files?: Record<string, string>;
}; };
export type AppTarget = { export type AppTarget = {
@@ -193,6 +195,15 @@ export async function startE2eSession(options: E2eSessionOptions = {}): Promise<
const config = mergeConfig(buildConfig(mpvSocketPath, anki.url), options.config ?? {}); const config = mergeConfig(buildConfig(mpvSocketPath, anki.url), options.config ?? {});
fs.writeFileSync(path.join(userDataDir, 'config.jsonc'), JSON.stringify(config, null, 2)); 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 = { const env: EnvDelta = {
set: { set: {
+150
View File
@@ -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<Started> | undefined;
function useSession(): Promise<Started> {
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<PanelState | false>(
`(() => {
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<string>(
`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' },
);
},
);
+128
View File
@@ -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 });
}
}
+5
View File
@@ -0,0 +1,5 @@
{
"name": "AI Explain",
"version": "1.0.0",
"main": "main.mjs"
}
+2
View File
@@ -37,6 +37,7 @@ const {
startupWarmups, startupWarmups,
updates, updates,
notifications, notifications,
plugins,
auto_start_overlay, auto_start_overlay,
} = CORE_DEFAULT_CONFIG; } = CORE_DEFAULT_CONFIG;
const { const {
@@ -74,6 +75,7 @@ export const DEFAULT_CONFIG: ResolvedConfig = {
startupWarmups, startupWarmups,
updates, updates,
notifications, notifications,
plugins,
subtitleStyle, subtitleStyle,
subtitleSidebar, subtitleSidebar,
auto_start_overlay, auto_start_overlay,
+2
View File
@@ -17,6 +17,7 @@ export const CORE_DEFAULT_CONFIG: Pick<
| 'startupWarmups' | 'startupWarmups'
| 'updates' | 'updates'
| 'notifications' | 'notifications'
| 'plugins'
| 'auto_start_overlay' | 'auto_start_overlay'
> = { > = {
dictionaryBackend: 'yomitan', dictionaryBackend: 'yomitan',
@@ -145,5 +146,6 @@ export const CORE_DEFAULT_CONFIG: Pick<
notifications: { notifications: {
overlayPosition: 'top-right', overlayPosition: 'top-right',
}, },
plugins: {},
auto_start_overlay: true, auto_start_overlay: true,
}; };
+7
View File
@@ -546,6 +546,13 @@ export function buildCoreConfigOptionRegistry(
defaultValue: defaultConfig.notifications.overlayPosition, defaultValue: defaultConfig.notifications.overlayPosition,
description: 'Position for in-overlay notification cards.', 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 <config dir>/plugins/. Each entry is { "enabled": true, "settings": { ... } }; settings are passed to the plugin as ctx.settings.',
},
{ {
path: 'shortcuts.multiCopyTimeoutMs', path: 'shortcuts.multiCopyTimeoutMs',
kind: 'number', kind: 'number',
+1
View File
@@ -57,6 +57,7 @@ export const SPECIAL_COMMANDS = {
ANIMETOSHO_OPEN: '__animetosho-open', ANIMETOSHO_OPEN: '__animetosho-open',
TSUKIHIME_OPEN: '__tsukihime-open', TSUKIHIME_OPEN: '__tsukihime-open',
RUNTIME_OPTION_CYCLE_PREFIX: '__runtime-option-cycle:', RUNTIME_OPTION_CYCLE_PREFIX: '__runtime-option-cycle:',
PLUGIN_ACTION_PREFIX: '__plugin-action:',
REPLAY_SUBTITLE: '__replay-subtitle', REPLAY_SUBTITLE: '__replay-subtitle',
PLAY_NEXT_SUBTITLE: '__play-next-subtitle', PLAY_NEXT_SUBTITLE: '__play-next-subtitle',
YOUTUBE_PICKER_OPEN: '__youtube-picker-open', YOUTUBE_PICKER_OPEN: '__youtube-picker-open',
@@ -92,6 +92,15 @@ const CORE_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
notes: ['Hot-reload: position changes apply to the next overlay notification.'], notes: ['Hot-reload: position changes apply to the next overlay notification.'],
key: 'notifications', key: 'notifications',
}, },
{
title: 'Plugins',
description: [
'JavaScript plugins loaded from <config dir>/plugins/<id>/ 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', title: 'Keyboard Shortcuts',
description: ['Overlay keyboard shortcuts. Set a shortcut to null to disable.'], description: ['Overlay keyboard shortcuts. Set a shortcut to null to disable.'],
+2
View File
@@ -4,6 +4,7 @@ import { createResolveContext } from './resolve/context';
import { applyCoreDomainConfig } from './resolve/core-domains'; import { applyCoreDomainConfig } from './resolve/core-domains';
import { applyImmersionTrackingConfig } from './resolve/immersion-tracking'; import { applyImmersionTrackingConfig } from './resolve/immersion-tracking';
import { applyIntegrationConfig } from './resolve/integrations'; import { applyIntegrationConfig } from './resolve/integrations';
import { applyPluginsConfig } from './resolve/plugins';
import { applyStatsConfig } from './resolve/stats'; import { applyStatsConfig } from './resolve/stats';
import { applySubtitleDomainConfig } from './resolve/subtitle-domains'; import { applySubtitleDomainConfig } from './resolve/subtitle-domains';
import { applyTopLevelConfig } from './resolve/top-level'; import { applyTopLevelConfig } from './resolve/top-level';
@@ -15,6 +16,7 @@ const APPLY_RESOLVE_STEPS = [
applyIntegrationConfig, applyIntegrationConfig,
applyImmersionTrackingConfig, applyImmersionTrackingConfig,
applyStatsConfig, applyStatsConfig,
applyPluginsConfig,
applyAnkiConnectResolution, applyAnkiConnectResolution,
] as const; ] as const;
+33
View File
@@ -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'],
);
});
+30
View File
@@ -0,0 +1,30 @@
import { ResolveContext } from './context';
import { isObject } from './shared';
/** Keeps well-formed `plugins.<id>` 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 } : {},
};
}
}
+4
View File
@@ -81,6 +81,7 @@ const JSON_OBJECT_FIELDS = new Set([
'keybindings', 'keybindings',
'controller.bindings', 'controller.bindings',
'controller.profiles', 'controller.profiles',
'plugins',
'ankiConnect.knownWords.decks', 'ankiConnect.knownWords.decks',
'subtitleStyle.css', 'subtitleStyle.css',
'subtitleStyle.secondary.css', 'subtitleStyle.secondary.css',
@@ -441,6 +442,9 @@ function categoryAndSection(path: string): { category: ConfigSettingsCategory; s
if (path.startsWith('notifications.')) { if (path.startsWith('notifications.')) {
return { category: 'behavior', section: 'Notifications' }; return { category: 'behavior', section: 'Notifications' };
} }
if (path === 'plugins') {
return { category: 'advanced', section: 'Plugins' };
}
if (path === 'mpv.aniskipButtonKey') { if (path === 'mpv.aniskipButtonKey') {
return { category: 'input', section: 'Overlay Shortcuts' }; return { category: 'input', section: 'Overlay Shortcuts' };
} }
+5
View File
@@ -13,6 +13,7 @@ function makeDeps(overrides: Partial<AppReadyRuntimeDeps> = {}) {
loadSubtitlePosition: () => calls.push('loadSubtitlePosition'), loadSubtitlePosition: () => calls.push('loadSubtitlePosition'),
resolveKeybindings: () => calls.push('resolveKeybindings'), resolveKeybindings: () => calls.push('resolveKeybindings'),
createMpvClient: () => calls.push('createMpvClient'), createMpvClient: () => calls.push('createMpvClient'),
startPlugins: () => calls.push('startPlugins'),
reloadConfig: () => calls.push('reloadConfig'), reloadConfig: () => calls.push('reloadConfig'),
getResolvedConfig: () => ({ getResolvedConfig: () => ({
websocket: { enabled: 'auto' }, 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('loadSubtitlePosition'), false);
assert.equal(calls.includes('resolveKeybindings'), false); assert.equal(calls.includes('resolveKeybindings'), false);
assert.equal(calls.includes('createMpvClient'), false); assert.equal(calls.includes('createMpvClient'), false);
assert.equal(calls.includes('startPlugins'), false);
assert.equal(calls.includes('initRuntimeOptionsManager'), false); assert.equal(calls.includes('initRuntimeOptionsManager'), false);
assert.equal(calls.includes('createImmersionTracker'), false); assert.equal(calls.includes('createImmersionTracker'), false);
assert.equal(calls.includes('startJellyfinRemoteSession'), 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('loadSubtitlePosition') < calls.indexOf('startBackgroundWarmups'));
assert.ok(calls.indexOf('createMpvClient') < 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('initializeOverlayRuntime') < calls.indexOf('startBackgroundWarmups'));
assert.ok(calls.indexOf('startBackgroundWarmups') < calls.indexOf('handleInitialArgs')); assert.ok(calls.indexOf('startBackgroundWarmups') < calls.indexOf('handleInitialArgs'));
}); });
+61
View File
@@ -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',
);
});
+81
View File
@@ -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<PluginExecResult> {
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<typeof setTimeout> | 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 ?? '');
});
}
+47
View File
@@ -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<string, unknown>;
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;
}
@@ -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<string, string>): 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<string, PluginConfigEntry>) {
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<string, unknown> = {}): 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();
}
});
+325
View File
@@ -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<TUnstable> {
pluginsDir: string;
getPluginConfig: () => Record<string, PluginConfigEntry>;
createLogger: (scope: string) => PluginLogger;
isMpvConnected: () => boolean;
sendMpvCommand: (command: (string | number)[]) => void;
requestMpvProperty: (name: string) => Promise<unknown>;
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<unknown> {
return (
typeof value === 'object' &&
value !== null &&
typeof (value as { then?: unknown }).then === 'function'
);
}
function pickExport<K extends 'activate' | 'deactivate'>(
mod: PluginModule,
key: K,
): NonNullable<PluginModule[K]> | null {
const direct = mod[key];
if (typeof direct === 'function') return direct as NonNullable<PluginModule[K]>;
const fromDefault = mod.default?.[key];
return typeof fromDefault === 'function' ? (fromDefault as NonNullable<PluginModule[K]>) : null;
}
/**
* Loads enabled plugins from `<pluginsDir>/<id>/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<TUnstable>(deps: PluginHostDeps<TUnstable>) {
const mpvEvents = new EventEmitter();
mpvEvents.setMaxListeners(0);
const plugins = new Map<string, LoadedPlugin>();
const actions = new Map<string, { pluginId: string; handler: () => unknown }>();
const loadModule = deps.loadModule ?? ((entryPath: string) => require(entryPath) as PluginModule);
const exec = deps.exec ?? execPluginCommand;
let attachedClient: {
client: MpvClientEvents;
forwarders: Map<string, (p: unknown) => void>;
} | null = null;
let loadPromise: Promise<void> | 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<TUnstable> {
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<typeof listener>[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<void> {
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<string, (payload: unknown) => 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<void> {
loadPromise ??= Promise.all(
Object.entries(deps.getPluginConfig())
.filter(([, entry]) => entry.enabled)
.map(([id, entry]) => loadPlugin(id, entry)),
).then(() => undefined);
return loadPromise;
},
async unloadAll(): Promise<void> {
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 `<plugin id>/<name>` 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<typeof createPluginHost>;
@@ -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: [] },
);
});
@@ -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: [],
};
}
+143
View File
@@ -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.<id>` entry from config. */
export interface PluginConfigEntry {
enabled: boolean;
settings: Record<string, unknown>;
}
/** mpv events plugins may observe. Render metrics are internal and stay hidden. */
export type PluginMpvEventMap = Omit<MpvIpcClientEventMap, 'subtitle-metrics-change'>;
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<PluginPanelContent>) => 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<string, string>;
/** 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<TUnstable = unknown> {
readonly id: string;
/** Absolute path of the plugin directory. */
readonly dir: string;
/** `plugins.<id>.settings` from config. */
readonly settings: Readonly<Record<string, unknown>>;
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: <E extends PluginMpvEventName>(
event: E,
listener: (payload: PluginMpvEventMap[E]) => unknown,
) => Disposer;
command: (command: (string | number)[]) => void;
getProperty: (name: string) => Promise<unknown>;
};
readonly subtitles: {
current: () => PluginSubtitleLine | null;
context: (options?: { before?: number; after?: number }) => PluginSubtitleContext;
};
readonly actions: {
/** Bind with `["__plugin-action:<plugin id>/<name>"]` 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<PluginPanelContent> & { 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<PluginExecResult>;
/** 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 };
}
+23
View File
@@ -55,6 +55,10 @@ function createDeps(overrides: Partial<SessionActionExecutorDeps> = {}) {
playNextSubtitle: () => calls.push('play-next'), playNextSubtitle: () => calls.push('play-next'),
cycleRuntimeOption: () => ({ ok: true }), cycleRuntimeOption: () => ({ ok: true }),
playNextPlaylistItem: () => calls.push('playlist-next'), playNextPlaylistItem: () => calls.push('playlist-next'),
runPluginAction: (pluginActionId) => {
calls.push(`plugin:${pluginActionId}`);
return pluginActionId === 'ai-explain/explain';
},
showMpvOsd: (text) => calls.push(`osd:${text}`), showMpvOsd: (text) => calls.push(`osd:${text}`),
...overrides, ...overrides,
}; };
@@ -81,3 +85,22 @@ test('dispatchSessionAction does not advance playlist when mark watched no-ops',
assert.deepEqual(calls, ['mark-watched']); 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',
]);
});
+9
View File
@@ -32,6 +32,8 @@ export interface SessionActionExecutorDeps {
playNextSubtitle: () => void; playNextSubtitle: () => void;
cycleRuntimeOption: (id: RuntimeOptionId, direction: 1 | -1) => RuntimeOptionApplyResult; cycleRuntimeOption: (id: RuntimeOptionId, direction: 1 | -1) => RuntimeOptionApplyResult;
playNextPlaylistItem: () => void; playNextPlaylistItem: () => void;
/** Returns false when no loaded plugin registered the action. */
runPluginAction: (pluginActionId: string) => boolean;
showMpvOsd: (text: string) => void; showMpvOsd: (text: string) => void;
} }
@@ -151,6 +153,13 @@ export async function dispatchSessionAction(
} }
return; return;
} }
case 'runPluginAction': {
const pluginActionId = request.payload?.pluginActionId;
if (!pluginActionId || !deps.runPluginAction(pluginActionId)) {
deps.showMpvOsd(`Plugin action not available: ${pluginActionId ?? 'unknown'}`);
}
return;
}
default: default:
return assertUnreachableSessionAction(request.actionId); return assertUnreachableSessionAction(request.actionId);
} }
@@ -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', () => { test('compileSessionBindings leaves retired subtitle-delay shift tokens as mpv commands', () => {
const result = compileSessionBindings({ const result = compileSessionBindings({
shortcuts: createShortcuts(), shortcuts: createShortcuts(),
+10
View File
@@ -342,6 +342,16 @@ function resolveCommandBinding(
if (command.length !== 1) return null; if (command.length !== 1) return null;
return { actionType: 'session-action', actionId: 'playNextSubtitle' }; 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 (first.startsWith(SPECIAL_COMMANDS.RUNTIME_OPTION_CYCLE_PREFIX)) {
if (command.length !== 1) { if (command.length !== 1) {
return null; return null;
+3
View File
@@ -117,6 +117,8 @@ export interface AppReadyRuntimeDeps {
loadSubtitlePosition: () => void; loadSubtitlePosition: () => void;
resolveKeybindings: () => void; resolveKeybindings: () => void;
createMpvClient: () => void; createMpvClient: () => void;
/** Loads enabled plugins; full startup only. */
startPlugins?: () => void;
reloadConfig: () => void; reloadConfig: () => void;
getResolvedConfig: () => AppReadyConfigLike; getResolvedConfig: () => AppReadyConfigLike;
getConfigWarnings: () => ConfigValidationWarning[]; getConfigWarnings: () => ConfigValidationWarning[];
@@ -310,6 +312,7 @@ export async function runAppReadyRuntime(deps: AppReadyRuntimeDeps): Promise<voi
deps.createMpvClient(); deps.createMpvClient();
deps.initRuntimeOptionsManager(); deps.initRuntimeOptionsManager();
deps.setSecondarySubMode(config.secondarySub?.defaultMode ?? deps.defaultSecondarySubMode); deps.setSecondarySubMode(config.secondarySub?.defaultMode ?? deps.defaultSecondarySubMode);
deps.startPlugins?.();
const wsConfig = config.websocket || {}; const wsConfig = config.websocket || {};
const wsEnabled = wsConfig.enabled ?? 'auto'; const wsEnabled = wsConfig.enabled ?? 'auto';
+1 -17
View File
@@ -1,22 +1,6 @@
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import test from 'node:test'; import test from 'node:test';
import { import { createNotifySendReplacer, resolveDefaultNotificationIconPath } from './notification';
buildNotifySendEnv,
createNotifySendReplacer,
resolveDefaultNotificationIconPath,
} from './notification';
test('notify-send child environment drops the AppImage library-path override', () => {
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');
});
test('default notification icon resolves packaged SubMiner asset when no per-notification icon is provided', () => { test('default notification icon resolves packaged SubMiner asset when no per-notification icon is provided', () => {
const path = resolveDefaultNotificationIconPath({ const path = resolveDefaultNotificationIconPath({
+2 -12
View File
@@ -3,6 +3,7 @@ import electron from 'electron';
import * as fs from 'fs'; import * as fs from 'fs';
import * as path from 'path'; import * as path from 'path';
import { createLogger } from '../../logger'; import { createLogger } from '../../logger';
import { buildSystemBinaryEnv } from './system-binary-env';
const { Notification, nativeImage } = electron; const { Notification, nativeImage } = electron;
const logger = createLogger('core:notification'); const logger = createLogger('core:notification');
@@ -203,19 +204,8 @@ export function createNotifySendReplacer(
}; };
} }
/**
* Electron AppImages export `LD_LIBRARY_PATH=<mount>/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) => 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 ?? ''), callback(error, stdout ?? ''),
), ),
); );
+16
View File
@@ -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');
});
+10
View File
@@ -0,0 +1,10 @@
/**
* Electron AppImages export `LD_LIBRARY_PATH=<mount>/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;
}
+87
View File
@@ -512,6 +512,13 @@ import { openSubsyncManualModal as openSubsyncManualModalRuntime } from './main/
import { openSessionHelpModal as openSessionHelpModalRuntime } from './main/runtime/session-help-open'; import { openSessionHelpModal as openSessionHelpModalRuntime } from './main/runtime/session-help-open';
import { openChangelogModal as openChangelogModalRuntime } from './main/runtime/changelog-open'; import { openChangelogModal as openChangelogModalRuntime } from './main/runtime/changelog-open';
import { createChangelogRuntime } from './main/runtime/changelog/changelog-runtime'; 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 { openCharacterDictionaryManagerModal as openCharacterDictionaryManagerModalRuntime } from './main/runtime/character-dictionary-open';
import { openControllerSelectModal as openControllerSelectModalRuntime } from './main/runtime/controller-select-open'; import { openControllerSelectModal as openControllerSelectModalRuntime } from './main/runtime/controller-select-open';
import { openControllerDebugModal as openControllerDebugModalRuntime } from './main/runtime/controller-debug-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 mediaTimingFrameExtractor = createMediaTimingFrameExtractor();
const mediaTimingReviewRuntime = createMediaTimingReviewRuntime({ const mediaTimingReviewRuntime = createMediaTimingReviewRuntime({
getMpvClient: () => appState.mpvClient, getMpvClient: () => appState.mpvClient,
@@ -4583,6 +4661,9 @@ const { appReadyRuntimeRunner } = composeAppReadyRuntime({
createMpvClient: () => { createMpvClient: () => {
appState.mpvClient = createMpvClientRuntimeService(); appState.mpvClient = createMpvClientRuntimeService();
}, },
startPlugins: () => {
void pluginHost.loadAll();
},
getResolvedConfig: () => configService.getConfig(), getResolvedConfig: () => configService.getConfig(),
getConfigWarnings: () => configService.getWarnings(), getConfigWarnings: () => configService.getWarnings(),
logConfigWarning: (warning) => appLogger.logConfigWarning(warning), logConfigWarning: (warning) => appLogger.logConfigWarning(warning),
@@ -5279,6 +5360,7 @@ function createMpvClientRuntimeService(): MpvIpcClient {
client.on('media-path-change', aniSkipRuntime.handleMediaPathChange); client.on('media-path-change', aniSkipRuntime.handleMediaPathChange);
client.on('time-pos-change', aniSkipRuntime.handleTimePosChange); client.on('time-pos-change', aniSkipRuntime.handleTimePosChange);
client.on('client-message', aniSkipRuntime.handleClientMessage); client.on('client-message', aniSkipRuntime.handleClientMessage);
pluginHost.attachMpvClient(client);
return client; return client;
} }
@@ -5860,6 +5942,7 @@ async function dispatchSessionAction(request: SessionActionDispatchRequest): Pro
}, },
playNextPlaylistItem: () => playNextPlaylistItem: () =>
sendMpvCommandRuntime(appState.mpvClient, ['playlist-next', 'force']), sendMpvCommandRuntime(appState.mpvClient, ['playlist-next', 'force']),
runPluginAction: (pluginActionId) => pluginHost.runAction(pluginActionId),
showMpvOsd: (text) => showConfiguredPlaybackFeedback(text), showMpvOsd: (text) => showConfiguredPlaybackFeedback(text),
}); });
} }
@@ -5945,6 +6028,9 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
if (modal === 'media-timing-review') { if (modal === 'media-timing-review') {
void mediaTimingReviewRuntime.dispose(); void mediaTimingReviewRuntime.dispose();
} }
if (modal === 'plugin-panel') {
pluginPanelRuntime.handleModalClosed();
}
if (modal === 'subtitle-sidebar' && senderWindow === overlayManager.getMainWindow()) { if (modal === 'subtitle-sidebar' && senderWindow === overlayManager.getMainWindow()) {
subtitleSidebarRequestedOpen = false; subtitleSidebarRequestedOpen = false;
} }
@@ -6961,3 +7047,4 @@ registerSubtitleGenerationIpc({
openModal: () => openSubtitleGenerationModal(createOverlayHostedModalOpenDeps()), openModal: () => openSubtitleGenerationModal(createOverlayHostedModalOpenDeps()),
}); });
app.on('before-quit', () => subtitleGenerationRuntime.cancel()); app.on('before-quit', () => subtitleGenerationRuntime.cancel());
app.on('before-quit', () => void pluginHost.unloadAll());
+2
View File
@@ -24,6 +24,7 @@ export interface AppReadyRuntimeDepsFactoryInput {
loadSubtitlePosition: AppReadyRuntimeDeps['loadSubtitlePosition']; loadSubtitlePosition: AppReadyRuntimeDeps['loadSubtitlePosition'];
resolveKeybindings: AppReadyRuntimeDeps['resolveKeybindings']; resolveKeybindings: AppReadyRuntimeDeps['resolveKeybindings'];
createMpvClient: AppReadyRuntimeDeps['createMpvClient']; createMpvClient: AppReadyRuntimeDeps['createMpvClient'];
startPlugins?: AppReadyRuntimeDeps['startPlugins'];
reloadConfig: AppReadyRuntimeDeps['reloadConfig']; reloadConfig: AppReadyRuntimeDeps['reloadConfig'];
getResolvedConfig: AppReadyRuntimeDeps['getResolvedConfig']; getResolvedConfig: AppReadyRuntimeDeps['getResolvedConfig'];
getConfigWarnings: AppReadyRuntimeDeps['getConfigWarnings']; getConfigWarnings: AppReadyRuntimeDeps['getConfigWarnings'];
@@ -94,6 +95,7 @@ export function createAppReadyRuntimeDeps(
loadSubtitlePosition: params.loadSubtitlePosition, loadSubtitlePosition: params.loadSubtitlePosition,
resolveKeybindings: params.resolveKeybindings, resolveKeybindings: params.resolveKeybindings,
createMpvClient: params.createMpvClient, createMpvClient: params.createMpvClient,
startPlugins: params.startPlugins,
reloadConfig: params.reloadConfig, reloadConfig: params.reloadConfig,
getResolvedConfig: params.getResolvedConfig, getResolvedConfig: params.getResolvedConfig,
getConfigWarnings: params.getConfigWarnings, getConfigWarnings: params.getConfigWarnings,
+1
View File
@@ -6,6 +6,7 @@ export function createBuildAppReadyRuntimeMainDepsHandler(deps: AppReadyRuntimeD
loadSubtitlePosition: deps.loadSubtitlePosition, loadSubtitlePosition: deps.loadSubtitlePosition,
resolveKeybindings: deps.resolveKeybindings, resolveKeybindings: deps.resolveKeybindings,
createMpvClient: deps.createMpvClient, createMpvClient: deps.createMpvClient,
startPlugins: deps.startPlugins,
reloadConfig: deps.reloadConfig, reloadConfig: deps.reloadConfig,
getResolvedConfig: deps.getResolvedConfig, getResolvedConfig: deps.getResolvedConfig,
getConfigWarnings: deps.getConfigWarnings, getConfigWarnings: deps.getConfigWarnings,
@@ -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<boolean>) {
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);
});
+111
View File
@@ -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<typeof openOverlayHostedModal>[0] & Parameters<typeof retryOverlayModalOpen>[0],
getSnapshot: () => PluginPanelSnapshot,
): Promise<boolean> {
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<boolean>;
/** 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);
},
};
}
+26
View File
@@ -105,6 +105,7 @@ import type {
OverlayNotificationEventPayload, OverlayNotificationEventPayload,
OverlayNotificationPosition, OverlayNotificationPosition,
ChangelogSnapshot, ChangelogSnapshot,
PluginPanelSnapshot,
MediaTimingReviewActionResult, MediaTimingReviewActionResult,
MediaTimingReviewOpenPayload, MediaTimingReviewOpenPayload,
MediaTimingReviewPreviewPosition, MediaTimingReviewPreviewPosition,
@@ -140,6 +141,20 @@ const onOpenSessionHelpEvent = createQueuedIpcListenerWithPayload<SessionHelpOpe
}), }),
); );
const onOpenChangelogEvent = createQueuedIpcListener(IPC_CHANNELS.event.changelogOpen); const onOpenChangelogEvent = createQueuedIpcListener(IPC_CHANNELS.event.changelogOpen);
function toPluginPanelSnapshot(payload: unknown): PluginPanelSnapshot {
const value = (typeof payload === 'object' && payload !== null ? payload : {}) as Partial<
Record<keyof PluginPanelSnapshot, unknown>
>;
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( const onOpenCharacterDictionaryManagerEvent = createQueuedIpcListener(
IPC_CHANNELS.event.characterDictionaryManagerOpen, IPC_CHANNELS.event.characterDictionaryManagerOpen,
); );
@@ -415,6 +430,17 @@ const electronAPI: ElectronAPI = {
onOpenChangelog: onOpenChangelogEvent, onOpenChangelog: onOpenChangelogEvent,
getChangelogSnapshot: (options?: { refresh?: boolean }): Promise<ChangelogSnapshot> => getChangelogSnapshot: (options?: { refresh?: boolean }): Promise<ChangelogSnapshot> =>
ipcRenderer.invoke(IPC_CHANNELS.request.getChangelogSnapshot, options), 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, onOpenControllerSelect: onOpenControllerSelectEvent,
onOpenControllerDebug: onOpenControllerDebugEvent, onOpenControllerDebug: onOpenControllerDebugEvent,
onOpenJimaku: onOpenJimakuEvent, onOpenJimaku: onOpenJimakuEvent,
+1
View File
@@ -595,6 +595,7 @@ function createKeyboardHandlerHarness() {
changelogKeydownCount += 1; changelogKeydownCount += 1;
return true; return true;
}, },
handlePluginPanelKeydown: () => false,
openSessionHelpModal: () => {}, openSessionHelpModal: () => {},
openControllerSelectModal: () => { openControllerSelectModal: () => {
openControllerSelectCount += 1; openControllerSelectCount += 1;
+6
View File
@@ -32,6 +32,7 @@ export function createKeyboardHandlers(
handleControllerDebugKeydown: (e: KeyboardEvent) => boolean; handleControllerDebugKeydown: (e: KeyboardEvent) => boolean;
handleSessionHelpKeydown: (e: KeyboardEvent) => boolean; handleSessionHelpKeydown: (e: KeyboardEvent) => boolean;
handleChangelogKeydown: (e: KeyboardEvent) => boolean; handleChangelogKeydown: (e: KeyboardEvent) => boolean;
handlePluginPanelKeydown: (e: KeyboardEvent) => boolean;
openSessionHelpModal: (opening: { openSessionHelpModal: (opening: {
bindingKey: 'KeyH' | 'KeyK'; bindingKey: 'KeyH' | 'KeyK';
fallbackUsed: boolean; fallbackUsed: boolean;
@@ -1172,6 +1173,11 @@ export function createKeyboardHandlers(
return; return;
} }
if (ctx.state.pluginPanelModalOpen) {
options.handlePluginPanelKeydown(e);
return;
}
if (ctx.state.runtimeOptionsModalOpen) { if (ctx.state.runtimeOptionsModalOpen) {
options.handleRuntimeOptionsKeydown(e); options.handleRuntimeOptionsKeydown(e);
return; return;
+9
View File
@@ -1019,6 +1019,15 @@
</div> </div>
</div> </div>
</div> </div>
<div id="pluginPanelModal" class="modal hidden" aria-hidden="true">
<div class="modal-content plugin-panel-content">
<div class="modal-header">
<div id="pluginPanelTitle" class="modal-title"></div>
<button id="pluginPanelClose" class="modal-close" type="button">Close</button>
</div>
<div id="pluginPanelBody" class="modal-body plugin-panel-body" tabindex="0"></div>
</div>
</div>
<div id="playlistBrowserModal" class="modal hidden" aria-hidden="true"> <div id="playlistBrowserModal" class="modal hidden" aria-hidden="true">
<div class="modal-content playlist-browser-content"> <div class="modal-content playlist-browser-content">
<div class="modal-header"> <div class="modal-header">
@@ -0,0 +1,70 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { parseInlineMarkdown, parsePluginPanelMarkdown } from './plugin-panel-render';
test('parses the block structure of a typical grammar explanation', () => {
const blocks = parsePluginPanelMarkdown(
[
'## Translation',
'I already told you,',
"didn't I?",
'',
'- **言った** past of 言う',
'- `じゃない` seeks agreement',
'',
'1. first',
'2. second',
'',
'> 言ったじゃない',
'---',
'```',
'<b>kept literal</b>',
'```',
].join('\n'),
);
assert.deepEqual(
blocks.map((block) => block.kind),
['heading', 'paragraph', 'list', 'list', 'quote', 'rule', 'code'],
);
assert.deepEqual(blocks[1], {
kind: 'paragraph',
lines: [
[{ kind: 'text', value: 'I already told you,' }],
[{ kind: 'text', value: "didn't I?" }],
],
});
assert.deepEqual(blocks[2], {
kind: 'list',
ordered: false,
items: [
[
{ kind: 'strong', value: '言った' },
{ kind: 'text', value: ' past of 言う' },
],
[
{ kind: 'code', value: 'じゃない' },
{ kind: 'text', value: ' seeks agreement' },
],
],
});
assert.deepEqual(blocks[6], { kind: 'code', value: '<b>kept literal</b>' });
});
test('inline parsing keeps HTML and unmatched markers as text', () => {
assert.deepEqual(parseInlineMarkdown('<img src=x> and *em* and 2 * 3'), [
{ kind: 'text', value: '<img src=x> and ' },
{ kind: 'em', value: 'em' },
{ kind: 'text', value: ' and 2 * 3' },
]);
});
test('underscores inside words stay literal while delimited underscore emphasis still works', () => {
assert.deepEqual(parseInlineMarkdown('set ctx_settings_value and _note_ or __bold__'), [
{ kind: 'text', value: 'set ctx_settings_value and ' },
{ kind: 'em', value: 'note' },
{ kind: 'text', value: ' or ' },
{ kind: 'strong', value: 'bold' },
]);
});
+204
View File
@@ -0,0 +1,204 @@
export type PluginPanelInline =
| { kind: 'text'; value: string }
| { kind: 'code'; value: string }
| { kind: 'strong'; value: string }
| { kind: 'em'; value: string };
export type PluginPanelBlock =
| { kind: 'heading'; level: number; inline: PluginPanelInline[] }
| { kind: 'paragraph'; lines: PluginPanelInline[][] }
| { kind: 'list'; ordered: boolean; items: PluginPanelInline[][] }
| { kind: 'quote'; lines: PluginPanelInline[][] }
| { kind: 'code'; value: string }
| { kind: 'rule' };
// Underscore emphasis needs non-word characters around it, so snake_case names stay literal.
const INLINE_PATTERN =
/`([^`]+)`|\*\*([^*]+)\*\*|(?<!\w)__([^_]+)__(?!\w)|\*([^*\s][^*]*)\*|(?<!\w)_([^_\s][^_]*)_(?!\w)/g;
export function parseInlineMarkdown(text: string): PluginPanelInline[] {
const tokens: PluginPanelInline[] = [];
let lastIndex = 0;
for (const match of text.matchAll(INLINE_PATTERN)) {
const index = match.index ?? 0;
if (index > lastIndex) tokens.push({ kind: 'text', value: text.slice(lastIndex, index) });
if (match[1] !== undefined) tokens.push({ kind: 'code', value: match[1] });
else if (match[2] !== undefined || match[3] !== undefined) {
tokens.push({ kind: 'strong', value: (match[2] ?? match[3])! });
} else tokens.push({ kind: 'em', value: (match[4] ?? match[5])! });
lastIndex = index + match[0].length;
}
if (lastIndex < text.length) tokens.push({ kind: 'text', value: text.slice(lastIndex) });
return tokens;
}
const HEADING = /^(#{1,6})\s+(.*)$/;
const UNORDERED_ITEM = /^\s*[-*+]\s+(.*)$/;
const ORDERED_ITEM = /^\s*\d+[.)]\s+(.*)$/;
const QUOTE = /^>\s?(.*)$/;
const RULE = /^\s*([-*_])(\s*\1){2,}\s*$/;
const FENCE = /^\s*```/;
/**
* Parses the markdown subset model replies lean on: headings, lists, quotes, fenced code, rules,
* and paragraphs with code/bold/italic spans. Single newlines inside a paragraph are kept as
* line breaks. Anything else, raw HTML included, stays literal text.
*/
export function parsePluginPanelMarkdown(markdown: string): PluginPanelBlock[] {
const blocks: PluginPanelBlock[] = [];
const lines = markdown.replace(/\r\n?/g, '\n').split('\n');
let index = 0;
while (index < lines.length) {
const line = lines[index]!;
if (!line.trim()) {
index += 1;
continue;
}
if (FENCE.test(line)) {
const body: string[] = [];
index += 1;
while (index < lines.length && !FENCE.test(lines[index]!)) {
body.push(lines[index]!);
index += 1;
}
index += 1;
blocks.push({ kind: 'code', value: body.join('\n') });
continue;
}
const heading = HEADING.exec(line);
if (heading) {
blocks.push({
kind: 'heading',
level: heading[1]!.length,
inline: parseInlineMarkdown(heading[2]!.trim()),
});
index += 1;
continue;
}
if (RULE.test(line)) {
blocks.push({ kind: 'rule' });
index += 1;
continue;
}
const ordered = ORDERED_ITEM.test(line);
if (ordered || UNORDERED_ITEM.test(line)) {
const pattern = ordered ? ORDERED_ITEM : UNORDERED_ITEM;
const items: PluginPanelInline[][] = [];
while (index < lines.length) {
const item = pattern.exec(lines[index]!);
if (!item) break;
items.push(parseInlineMarkdown(item[1]!.trim()));
index += 1;
}
blocks.push({ kind: 'list', ordered, items });
continue;
}
if (QUOTE.test(line)) {
const quoted: PluginPanelInline[][] = [];
while (index < lines.length) {
const quote = QUOTE.exec(lines[index]!);
if (!quote) break;
quoted.push(parseInlineMarkdown(quote[1]!));
index += 1;
}
blocks.push({ kind: 'quote', lines: quoted });
continue;
}
const paragraph: PluginPanelInline[][] = [];
while (index < lines.length) {
const next = lines[index]!;
if (
!next.trim() ||
FENCE.test(next) ||
HEADING.test(next) ||
RULE.test(next) ||
UNORDERED_ITEM.test(next) ||
ORDERED_ITEM.test(next) ||
QUOTE.test(next)
) {
break;
}
paragraph.push(parseInlineMarkdown(next.trim()));
index += 1;
}
blocks.push({ kind: 'paragraph', lines: paragraph });
}
return blocks;
}
function appendInline(parent: HTMLElement, tokens: PluginPanelInline[]): void {
for (const token of tokens) {
if (token.kind === 'text') {
parent.append(document.createTextNode(token.value));
continue;
}
const tag = token.kind === 'code' ? 'code' : token.kind;
const element = document.createElement(tag);
element.textContent = token.value;
parent.append(element);
}
}
function appendLines(parent: HTMLElement, lines: PluginPanelInline[][]): void {
lines.forEach((line, index) => {
if (index > 0) parent.append(document.createElement('br'));
appendInline(parent, line);
});
}
/** Builds DOM nodes from parsed blocks. Only text nodes carry content, so nothing is interpreted. */
export function renderPluginPanelMarkdown(container: HTMLElement, markdown: string): void {
container.replaceChildren();
for (const block of parsePluginPanelMarkdown(markdown)) {
switch (block.kind) {
case 'heading': {
// The panel title is the top heading, so content headings start one step down.
const element = document.createElement(`h${Math.min(6, block.level + 2)}`);
appendInline(element, block.inline);
container.append(element);
break;
}
case 'paragraph': {
const element = document.createElement('p');
appendLines(element, block.lines);
container.append(element);
break;
}
case 'list': {
const list = document.createElement(block.ordered ? 'ol' : 'ul');
for (const item of block.items) {
const element = document.createElement('li');
appendInline(element, item);
list.append(element);
}
container.append(list);
break;
}
case 'quote': {
const element = document.createElement('blockquote');
appendLines(element, block.lines);
container.append(element);
break;
}
case 'code': {
const pre = document.createElement('pre');
const code = document.createElement('code');
code.textContent = block.value;
pre.append(code);
container.append(pre);
break;
}
case 'rule':
container.append(document.createElement('hr'));
break;
}
}
}
+136
View File
@@ -0,0 +1,136 @@
import type { PluginPanelSnapshot } from '../../types/runtime';
import type { ModalStateReader, RendererContext } from '../context';
import { createModalFocusGuard } from './modal-focus-guard';
import { renderPluginPanelMarkdown } from './plugin-panel-render';
const SCROLL_STEP_PX = 60;
/** Read-only panel whose content plugins push from the main process through `ctx.ui.panel`. */
export function createPluginPanelModal(
ctx: RendererContext,
options: {
modalStateReader: Pick<ModalStateReader, 'isAnyModalOpen'>;
syncSettingsModalSubtitleSuppression: () => void;
},
) {
let priorFocus: Element | null = null;
let panelId: number | null = null;
const focus = createModalFocusGuard({
isOpen: () => ctx.state.pluginPanelModalOpen,
getModalRoot: () => ctx.dom.pluginPanelModal,
getPreferredFocusTargets: () => [ctx.dom.pluginPanelBody],
getFallbackFocusTarget: () => ctx.dom.pluginPanelClose,
isModalLayer: ctx.platform.isModalLayer,
});
function render(snapshot: PluginPanelSnapshot): void {
ctx.dom.pluginPanelTitle.textContent = snapshot.title;
renderPluginPanelMarkdown(ctx.dom.pluginPanelBody, snapshot.markdown);
}
function openPluginPanelModal(snapshot: PluginPanelSnapshot): void {
panelId = snapshot.panelId;
render(snapshot);
ctx.dom.pluginPanelBody.scrollTop = 0;
if (ctx.state.pluginPanelModalOpen) return;
priorFocus = document.activeElement;
ctx.state.pluginPanelModalOpen = true;
options.syncSettingsModalSubtitleSuppression();
ctx.dom.overlay.classList.add('interactive');
ctx.dom.pluginPanelModal.classList.remove('hidden');
ctx.dom.pluginPanelModal.setAttribute('aria-hidden', 'false');
ctx.dom.pluginPanelModal.setAttribute('tabindex', '-1');
if (ctx.platform.shouldToggleMouseIgnore) {
window.electronAPI.setIgnoreMouseEvents(false);
}
focus.attach();
focus.requestOverlayFocus();
window.focus();
focus.enforceModalFocus();
}
function updatePluginPanel(snapshot: PluginPanelSnapshot): void {
if (!ctx.state.pluginPanelModalOpen || snapshot.panelId !== panelId) return;
render(snapshot);
}
function closePluginPanelModal(): void {
if (!ctx.state.pluginPanelModalOpen) return;
ctx.state.pluginPanelModalOpen = false;
panelId = null;
options.syncSettingsModalSubtitleSuppression();
ctx.dom.pluginPanelModal.classList.add('hidden');
ctx.dom.pluginPanelModal.setAttribute('aria-hidden', 'true');
window.electronAPI.notifyOverlayModalClosed('plugin-panel');
if (!ctx.state.isOverSubtitle && !options.modalStateReader.isAnyModalOpen()) {
ctx.dom.overlay.classList.remove('interactive');
}
focus.detach();
if (priorFocus instanceof HTMLElement && priorFocus.isConnected) {
priorFocus.focus({ preventScroll: true });
} else if (ctx.dom.overlay instanceof HTMLElement) {
ctx.dom.overlay.focus({ preventScroll: true });
}
if (ctx.platform.shouldToggleMouseIgnore) {
if (!ctx.state.isOverSubtitle && !options.modalStateReader.isAnyModalOpen()) {
window.electronAPI.setIgnoreMouseEvents(true, { forward: true });
} else {
window.electronAPI.setIgnoreMouseEvents(false);
}
}
}
/** The plugin closed its panel; ignore requests for a panel that was already replaced. */
function closePluginPanelFromMain(closedPanelId: number): void {
if (closedPanelId === panelId) closePluginPanelModal();
}
function handlePluginPanelKeydown(e: KeyboardEvent): boolean {
if (!ctx.state.pluginPanelModalOpen) return false;
if (e.key === 'Escape') {
e.preventDefault();
closePluginPanelModal();
return true;
}
const body = ctx.dom.pluginPanelBody;
const key = e.key.toLowerCase();
if (key === 'arrowdown' || key === 'j') {
e.preventDefault();
body.scrollBy({ top: SCROLL_STEP_PX });
} else if (key === 'arrowup' || key === 'k') {
e.preventDefault();
body.scrollBy({ top: -SCROLL_STEP_PX });
} else if (key === 'pagedown' || key === ' ') {
e.preventDefault();
body.scrollBy({ top: body.clientHeight * 0.9 });
} else if (key === 'pageup') {
e.preventDefault();
body.scrollBy({ top: -body.clientHeight * 0.9 });
}
return true;
}
function wireDomEvents(): void {
ctx.dom.pluginPanelClose.addEventListener('click', () => {
closePluginPanelModal();
});
}
return {
closePluginPanelModal,
closePluginPanelFromMain,
handlePluginPanelKeydown,
openPluginPanelModal,
updatePluginPanel,
wireDomEvents,
};
}
+6 -4
View File
@@ -3,6 +3,7 @@ import type {
CompiledSessionActionBinding, CompiledSessionActionBinding,
CompiledSessionBinding, CompiledSessionBinding,
SessionActionId, SessionActionId,
SessionActionPayload,
SessionKeyModifier, SessionKeyModifier,
SessionKeySpec, SessionKeySpec,
} from '../../types'; } from '../../types';
@@ -189,10 +190,7 @@ function formatSessionKeySpec(key: SessionKeySpec): string {
.join(' + '); .join(' + ');
} }
function describeSessionAction( function describeSessionAction(actionId: SessionActionId, payload?: SessionActionPayload): string {
actionId: SessionActionId,
payload?: { runtimeOptionId?: string; direction?: 1 | -1 },
): string {
switch (actionId) { switch (actionId) {
case 'toggleStatsOverlay': case 'toggleStatsOverlay':
return 'Toggle stats overlay'; return 'Toggle stats overlay';
@@ -254,6 +252,8 @@ function describeSessionAction(
return `Cycle runtime option ${payload?.runtimeOptionId ?? 'option'} ${ return `Cycle runtime option ${payload?.runtimeOptionId ?? 'option'} ${
payload?.direction === -1 ? 'previous' : 'next' payload?.direction === -1 ? 'previous' : 'next'
}`; }`;
case 'runPluginAction':
return `Plugin action ${payload?.pluginActionId ?? ''}`.trim();
} }
} }
@@ -297,6 +297,8 @@ function sectionForSessionBinding(binding: CompiledSessionBinding): string {
return 'Playback and navigation'; return 'Playback and navigation';
case 'cycleRuntimeOption': case 'cycleRuntimeOption':
return 'Runtime settings'; return 'Runtime settings';
case 'runPluginAction':
return 'Plugins';
} }
} }
@@ -101,6 +101,7 @@ export async function setup() {
handleControllerDebugKeydown: () => false, handleControllerDebugKeydown: () => false,
handleSessionHelpKeydown: () => false, handleSessionHelpKeydown: () => false,
handleChangelogKeydown: () => false, handleChangelogKeydown: () => false,
handlePluginPanelKeydown: () => false,
openSessionHelpModal: () => {}, openSessionHelpModal: () => {},
getPlaybackPaused: async () => false, getPlaybackPaused: async () => false,
}); });
+25
View File
@@ -39,6 +39,7 @@ import { prepareForKikuFieldGroupingOpen } from './kiku-open.js';
import { createPlaylistBrowserModal } from './modals/playlist-browser.js'; import { createPlaylistBrowserModal } from './modals/playlist-browser.js';
import { createSessionHelpModal } from './modals/session-help.js'; import { createSessionHelpModal } from './modals/session-help.js';
import { createChangelogModal } from './modals/changelog.js'; import { createChangelogModal } from './modals/changelog.js';
import { createPluginPanelModal } from './modals/plugin-panel.js';
import { createSubtitleSidebarModal } from './modals/subtitle-sidebar.js'; import { createSubtitleSidebarModal } from './modals/subtitle-sidebar.js';
import { wireSubtitleSidebarSelection } from './modals/subtitle-sidebar-selection.js'; import { wireSubtitleSidebarSelection } from './modals/subtitle-sidebar-selection.js';
import { isControllerInteractionBlocked } from './controller-interaction-blocking.js'; import { isControllerInteractionBlocked } from './controller-interaction-blocking.js';
@@ -178,6 +179,12 @@ const modalDescriptors = [
close: () => changelogModal.closeChangelogModal(), close: () => changelogModal.closeChangelogModal(),
suppressesSubtitles: true, suppressesSubtitles: true,
}, },
{
id: 'plugin-panel',
isOpen: () => ctx.state.pluginPanelModalOpen,
close: () => pluginPanelModal.closePluginPanelModal(),
suppressesSubtitles: true,
},
] satisfies readonly ModalDescriptor<OverlayHostedModal>[]; ] satisfies readonly ModalDescriptor<OverlayHostedModal>[];
const modalRegistry = createModalRegistry(modalDescriptors); const modalRegistry = createModalRegistry(modalDescriptors);
@@ -253,6 +260,10 @@ const changelogModal = createChangelogModal(ctx, {
modalStateReader: { isAnyModalOpen }, modalStateReader: { isAnyModalOpen },
syncSettingsModalSubtitleSuppression, syncSettingsModalSubtitleSuppression,
}); });
const pluginPanelModal = createPluginPanelModal(ctx, {
modalStateReader: { isAnyModalOpen },
syncSettingsModalSubtitleSuppression,
});
const subtitleSidebarModal = createSubtitleSidebarModal(ctx, { const subtitleSidebarModal = createSubtitleSidebarModal(ctx, {
modalStateReader: { isAnyModalOpen }, modalStateReader: { isAnyModalOpen },
shouldRestoreOpenOnStartup: async () => shouldRestoreOpenOnStartup: async () =>
@@ -316,6 +327,7 @@ const keyboardHandlers = createKeyboardHandlers(ctx, {
handleControllerDebugKeydown: controllerDebugModal.handleControllerDebugKeydown, handleControllerDebugKeydown: controllerDebugModal.handleControllerDebugKeydown,
handleSessionHelpKeydown: sessionHelpModal.handleSessionHelpKeydown, handleSessionHelpKeydown: sessionHelpModal.handleSessionHelpKeydown,
handleChangelogKeydown: changelogModal.handleChangelogKeydown, handleChangelogKeydown: changelogModal.handleChangelogKeydown,
handlePluginPanelKeydown: pluginPanelModal.handlePluginPanelKeydown,
// Route through main so it can gate help commands on mpv playback state; // Route through main so it can gate help commands on mpv playback state;
// onOpenSessionHelp re-resolves the chord hint when the open event arrives. // onOpenSessionHelp re-resolves the chord hint when the open event arrives.
openSessionHelpModal: () => { openSessionHelpModal: () => {
@@ -590,6 +602,18 @@ function registerModalOpenHandlers(): void {
window.electronAPI.notifyOverlayModalOpened('changelog'); window.electronAPI.notifyOverlayModalOpened('changelog');
}); });
}); });
window.electronAPI.onOpenPluginPanel((snapshot) => {
runGuarded('plugin-panel:open', () => {
pluginPanelModal.openPluginPanelModal(snapshot);
window.electronAPI.notifyOverlayModalOpened('plugin-panel');
});
});
window.electronAPI.onPluginPanelUpdate((snapshot) => {
runGuarded('plugin-panel:update', () => pluginPanelModal.updatePluginPanel(snapshot));
});
window.electronAPI.onPluginPanelClose((panelId) => {
runGuarded('plugin-panel:close', () => pluginPanelModal.closePluginPanelFromMain(panelId));
});
window.electronAPI.onOpenControllerSelect(() => { window.electronAPI.onOpenControllerSelect(() => {
runGuarded('controller-select:open', () => { runGuarded('controller-select:open', () => {
if (controllerSelectModal.openControllerSelectModal()) { if (controllerSelectModal.openControllerSelectModal()) {
@@ -882,6 +906,7 @@ async function init(): Promise<void> {
controllerDebugModal.wireDomEvents(); controllerDebugModal.wireDomEvents();
sessionHelpModal.wireDomEvents(); sessionHelpModal.wireDomEvents();
changelogModal.wireDomEvents(); changelogModal.wireDomEvents();
pluginPanelModal.wireDomEvents();
subtitleSidebarModal.wireDomEvents(); subtitleSidebarModal.wireDomEvents();
characterDictionaryModal.wireDomEvents(); characterDictionaryModal.wireDomEvents();
window.addEventListener('beforeunload', () => { window.addEventListener('beforeunload', () => {
+2
View File
@@ -108,6 +108,7 @@ export type RendererState = {
sessionHelpSelectedIndex: number; sessionHelpSelectedIndex: number;
changelogModalOpen: boolean; changelogModalOpen: boolean;
changelogSelectedIndex: number; changelogSelectedIndex: number;
pluginPanelModalOpen: boolean;
playlistBrowserModalOpen: boolean; playlistBrowserModalOpen: boolean;
playlistBrowserSnapshot: PlaylistBrowserSnapshot | null; playlistBrowserSnapshot: PlaylistBrowserSnapshot | null;
playlistBrowserStatus: string; playlistBrowserStatus: string;
@@ -247,6 +248,7 @@ export function createRendererState(): RendererState {
sessionHelpSelectedIndex: 0, sessionHelpSelectedIndex: 0,
changelogModalOpen: false, changelogModalOpen: false,
changelogSelectedIndex: 0, changelogSelectedIndex: 0,
pluginPanelModalOpen: false,
playlistBrowserModalOpen: false, playlistBrowserModalOpen: false,
playlistBrowserSnapshot: null, playlistBrowserSnapshot: null,
playlistBrowserStatus: '', playlistBrowserStatus: '',
+88
View File
@@ -4433,6 +4433,94 @@ body.subtitle-sidebar-embedded-open #subtitleSidebarContent {
color: var(--ctp-subtext0); color: var(--ctp-subtext0);
} }
.plugin-panel-content {
width: min(760px, 94%);
max-height: 86%;
}
.plugin-panel-body {
display: block;
overflow-y: auto;
max-height: calc(86vh - 110px);
padding-right: 6px;
font-size: 15px;
line-height: 1.6;
color: var(--ctp-text);
outline: none;
user-select: text;
}
.plugin-panel-body > :first-child {
margin-top: 0;
}
.plugin-panel-body p,
.plugin-panel-body ul,
.plugin-panel-body ol,
.plugin-panel-body blockquote,
.plugin-panel-body pre {
margin: 0 0 12px;
}
.plugin-panel-body h3,
.plugin-panel-body h4,
.plugin-panel-body h5,
.plugin-panel-body h6 {
margin: 16px 0 8px;
color: var(--ctp-blue);
font-size: 15px;
font-weight: 700;
}
.plugin-panel-body h3 {
font-size: 17px;
}
.plugin-panel-body ul,
.plugin-panel-body ol {
padding-left: 22px;
}
.plugin-panel-body li + li {
margin-top: 4px;
}
.plugin-panel-body strong {
color: var(--ctp-peach);
}
.plugin-panel-body code {
padding: 1px 5px;
border-radius: 5px;
background: rgba(54, 58, 79, 0.85);
font-size: 0.92em;
}
.plugin-panel-body pre {
padding: 10px 12px;
border-radius: 8px;
background: rgba(30, 32, 48, 0.9);
overflow-x: auto;
white-space: pre-wrap;
}
.plugin-panel-body pre code {
padding: 0;
background: none;
}
.plugin-panel-body blockquote {
padding-left: 12px;
border-left: 3px solid rgba(138, 173, 244, 0.5);
color: var(--ctp-subtext1);
}
.plugin-panel-body hr {
margin: 14px 0;
border: none;
border-top: 1px solid rgba(110, 115, 141, 0.3);
}
@media (max-width: 640px) { @media (max-width: 640px) {
.changelog-list { .changelog-list {
max-height: calc(86vh - 200px); max-height: calc(86vh - 200px);
+9
View File
@@ -180,6 +180,11 @@ export type RendererDom = {
changelogStatus: HTMLDivElement; changelogStatus: HTMLDivElement;
changelogList: HTMLDivElement; changelogList: HTMLDivElement;
pluginPanelModal: HTMLDivElement;
pluginPanelTitle: HTMLDivElement;
pluginPanelClose: HTMLButtonElement;
pluginPanelBody: HTMLDivElement;
sessionHelpModal: HTMLDivElement; sessionHelpModal: HTMLDivElement;
sessionHelpClose: HTMLButtonElement; sessionHelpClose: HTMLButtonElement;
sessionHelpShortcut: HTMLDivElement; sessionHelpShortcut: HTMLDivElement;
@@ -448,6 +453,10 @@ export function resolveRendererDom(): RendererDom {
changelogWarning: getRequiredElement<HTMLDivElement>('changelogWarning'), changelogWarning: getRequiredElement<HTMLDivElement>('changelogWarning'),
changelogStatus: getRequiredElement<HTMLDivElement>('changelogStatus'), changelogStatus: getRequiredElement<HTMLDivElement>('changelogStatus'),
changelogList: getRequiredElement<HTMLDivElement>('changelogList'), changelogList: getRequiredElement<HTMLDivElement>('changelogList'),
pluginPanelModal: getRequiredElement<HTMLDivElement>('pluginPanelModal'),
pluginPanelTitle: getRequiredElement<HTMLDivElement>('pluginPanelTitle'),
pluginPanelClose: getRequiredElement<HTMLButtonElement>('pluginPanelClose'),
pluginPanelBody: getRequiredElement<HTMLDivElement>('pluginPanelBody'),
sessionHelpModal: getRequiredElement<HTMLDivElement>('sessionHelpModal'), sessionHelpModal: getRequiredElement<HTMLDivElement>('sessionHelpModal'),
sessionHelpClose: getRequiredElement<HTMLButtonElement>('sessionHelpClose'), sessionHelpClose: getRequiredElement<HTMLButtonElement>('sessionHelpClose'),
sessionHelpShortcut: getRequiredElement<HTMLDivElement>('sessionHelpShortcut'), sessionHelpShortcut: getRequiredElement<HTMLDivElement>('sessionHelpShortcut'),
+4
View File
@@ -18,6 +18,7 @@ export const OVERLAY_HOSTED_MODALS = [
'session-help', 'session-help',
'character-dictionary', 'character-dictionary',
'changelog', 'changelog',
'plugin-panel',
] as const; ] as const;
export type OverlayHostedModal = (typeof OVERLAY_HOSTED_MODALS)[number]; export type OverlayHostedModal = (typeof OVERLAY_HOSTED_MODALS)[number];
@@ -160,6 +161,9 @@ export const IPC_CHANNELS = {
lookupWindowToggleRequested: 'lookup-window-toggle:requested', lookupWindowToggleRequested: 'lookup-window-toggle:requested',
sessionHelpOpen: 'session-help:open', sessionHelpOpen: 'session-help:open',
changelogOpen: 'changelog:open', changelogOpen: 'changelog:open',
pluginPanelOpen: 'plugin-panel:open',
pluginPanelUpdate: 'plugin-panel:update',
pluginPanelClose: 'plugin-panel:close',
characterDictionaryManagerOpen: 'character-dictionary:manager-open', characterDictionaryManagerOpen: 'character-dictionary:manager-open',
controllerSelectOpen: 'controller-select:open', controllerSelectOpen: 'controller-select:open',
controllerDebugOpen: 'controller-debug:open', controllerDebugOpen: 'controller-debug:open',
+10
View File
@@ -50,6 +50,7 @@ const SESSION_ACTION_IDS: SessionActionId[] = [
'replayCurrentSubtitle', 'replayCurrentSubtitle',
'playNextSubtitle', 'playNextSubtitle',
'cycleRuntimeOption', 'cycleRuntimeOption',
'runPluginAction',
]; ];
const RUNTIME_OPTION_IDS: RuntimeOptionId[] = [ const RUNTIME_OPTION_IDS: RuntimeOptionId[] = [
@@ -111,6 +112,15 @@ function parseSessionActionPayload(
}; };
} }
if (actionId === 'runPluginAction') {
if (!isObject(value)) return null;
if (Object.keys(value).some((key) => key !== 'pluginActionId')) return null;
if (typeof value.pluginActionId !== 'string' || !value.pluginActionId.includes('/')) {
return null;
}
return { pluginActionId: value.pluginActionId };
}
return value === undefined ? undefined : null; return value === undefined ? undefined : null;
} }
+9
View File
@@ -142,6 +142,13 @@ export interface RawShortcutsConfig extends ShortcutsConfig {
export type DictionaryBackend = 'yomitan' | 'hachidori'; export type DictionaryBackend = 'yomitan' | 'hachidori';
/** One `plugins.<id>` entry; the id names the directory under `<config dir>/plugins/`. */
export interface PluginConfig {
enabled?: boolean;
/** Passed to the plugin as `ctx.settings`. */
settings?: Record<string, unknown>;
}
export interface Config { export interface Config {
dictionaryBackend?: DictionaryBackend; dictionaryBackend?: DictionaryBackend;
subtitlePosition?: SubtitlePosition; subtitlePosition?: SubtitlePosition;
@@ -176,6 +183,7 @@ export interface Config {
stats?: StatsConfig; stats?: StatsConfig;
updates?: UpdatesConfig; updates?: UpdatesConfig;
notifications?: NotificationsConfig; notifications?: NotificationsConfig;
plugins?: Record<string, PluginConfig>;
logging?: { logging?: {
level?: 'debug' | 'info' | 'warn' | 'error'; level?: 'debug' | 'info' | 'warn' | 'error';
rotation?: LogRotation; rotation?: LogRotation;
@@ -427,6 +435,7 @@ export interface ResolvedConfig {
}; };
updates: Required<UpdatesConfig>; updates: Required<UpdatesConfig>;
notifications: Required<NotificationsConfig>; notifications: Required<NotificationsConfig>;
plugins: Record<string, Required<PluginConfig>>;
logging: { logging: {
level: 'debug' | 'info' | 'warn' | 'error'; level: 'debug' | 'info' | 'warn' | 'error';
rotation: LogRotation; rotation: LogRotation;
+14 -2
View File
@@ -386,6 +386,13 @@ export interface ConfigHotReloadPayload {
secondarySubMode: SecondarySubMode; secondarySubMode: SecondarySubMode;
} }
/** Content of the shared plugin panel. `panelId` changes each time a plugin opens a new panel. */
export interface PluginPanelSnapshot {
panelId: number;
title: string;
markdown: string;
}
export interface SessionActionDispatchRequest { export interface SessionActionDispatchRequest {
actionId: SessionActionId; actionId: SessionActionId;
payload?: SessionActionPayload; payload?: SessionActionPayload;
@@ -536,6 +543,9 @@ export interface ElectronAPI {
onOpenSessionHelp: (callback: (payload: SessionHelpOpenPayload) => void) => void; onOpenSessionHelp: (callback: (payload: SessionHelpOpenPayload) => void) => void;
onOpenChangelog: (callback: () => void) => void; onOpenChangelog: (callback: () => void) => void;
getChangelogSnapshot: (options?: { refresh?: boolean }) => Promise<ChangelogSnapshot>; getChangelogSnapshot: (options?: { refresh?: boolean }) => Promise<ChangelogSnapshot>;
onOpenPluginPanel: (callback: (snapshot: PluginPanelSnapshot) => void) => void;
onPluginPanelUpdate: (callback: (snapshot: PluginPanelSnapshot) => void) => void;
onPluginPanelClose: (callback: (panelId: number) => void) => void;
onOpenControllerSelect: (callback: () => void) => void; onOpenControllerSelect: (callback: () => void) => void;
onOpenControllerDebug: (callback: () => void) => void; onOpenControllerDebug: (callback: () => void) => void;
onOpenJimaku: (callback: () => void) => void; onOpenJimaku: (callback: () => void) => void;
@@ -613,7 +623,8 @@ export interface ElectronAPI {
| 'subtitle-sidebar' | 'subtitle-sidebar'
| 'session-help' | 'session-help'
| 'character-dictionary' | 'character-dictionary'
| 'changelog', | 'changelog'
| 'plugin-panel',
) => void; ) => void;
notifyOverlayModalOpened: ( notifyOverlayModalOpened: (
modal: modal:
@@ -632,7 +643,8 @@ export interface ElectronAPI {
| 'subtitle-sidebar' | 'subtitle-sidebar'
| 'session-help' | 'session-help'
| 'character-dictionary' | 'character-dictionary'
| 'changelog', | 'changelog'
| 'plugin-panel',
) => void; ) => void;
reportOverlayContentBounds: (measurement: OverlayContentMeasurement) => void; reportOverlayContentBounds: (measurement: OverlayContentMeasurement) => void;
onSessionBindingsChanged: (callback: (bindings: CompiledSessionBinding[]) => void) => void; onSessionBindingsChanged: (callback: (bindings: CompiledSessionBinding[]) => void) => void;
+4 -1
View File
@@ -29,7 +29,8 @@ export type SessionActionId =
| 'openPlaylistBrowser' | 'openPlaylistBrowser'
| 'replayCurrentSubtitle' | 'replayCurrentSubtitle'
| 'playNextSubtitle' | 'playNextSubtitle'
| 'cycleRuntimeOption'; | 'cycleRuntimeOption'
| 'runPluginAction';
export interface SessionKeySpec { export interface SessionKeySpec {
code: string; code: string;
@@ -53,6 +54,8 @@ export interface SessionActionPayload {
count?: number; count?: number;
runtimeOptionId?: string; runtimeOptionId?: string;
direction?: 1 | -1; direction?: 1 | -1;
/** `<plugin id>/<action name>` for runPluginAction. */
pluginActionId?: string;
} }
type CompiledSessionBindingBase = { type CompiledSessionBindingBase = {