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