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/assets/plugins/ai-explain/main.js b/assets/plugins/ai-explain/main.js new file mode 100644 index 00000000..84a4649e --- /dev/null +++ b/assets/plugins/ai-explain/main.js @@ -0,0 +1,134 @@ +// 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`. +const { mkdtemp, readFile, rm } = require('node:fs/promises'); +const os = require('node:os'); +const path = require('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.', + }, +}; + +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 }); + } +} + +module.exports = { activate }; diff --git a/assets/plugins/ai-explain/plugin.json b/assets/plugins/ai-explain/plugin.json new file mode 100644 index 00000000..2dec6dd7 --- /dev/null +++ b/assets/plugins/ai-explain/plugin.json @@ -0,0 +1,6 @@ +{ + "name": "AI Explain", + "version": "1.0.0", + "apiVersion": 1, + "main": "main.js" +} diff --git a/assets/plugins/anki-sync/main.js b/assets/plugins/anki-sync/main.js new file mode 100644 index 00000000..b39ab397 --- /dev/null +++ b/assets/plugins/anki-sync/main.js @@ -0,0 +1,61 @@ +// Runs AnkiConnect's `sync` (AnkiWeb) after you mine cards. A burst of mining becomes one sync: +// it waits until no card has been mined for `delayMs`, and syncs right away when playback ends. +// Also registers a `sync` action for a manual sync. + +const DEFAULT_SETTINGS = { + delayMs: 30_000, + syncOnEnd: true, + timeoutMs: 60_000, + notify: true, +}; + +const END_REASONS = new Set(['eof', 'stop', 'quit']); + +function activate(ctx) { + const settings = { ...DEFAULT_SETTINGS, ...ctx.settings }; + // Cards mined since the last sync started. + let dirty = false; + let syncing = false; + let cancelTimer = null; + + function schedule() { + cancelTimer?.(); + cancelTimer = ctx.after(settings.delayMs, flush); + } + + async function flush() { + cancelTimer?.(); + cancelTimer = null; + // A sync already running picks up new cards when it finishes. + if (!dirty || syncing) return; + dirty = false; + syncing = true; + try { + await ctx.anki.invoke('sync', {}, { timeoutMs: settings.timeoutMs }); + ctx.log.info('Synced Anki.'); + if (settings.notify) ctx.notify('Anki synced'); + } catch (error) { + ctx.log.warn(`Anki sync failed: ${error.message}`); + ctx.notify(`Anki sync failed: ${error.message}`, { variant: 'error' }); + } finally { + syncing = false; + if (dirty) schedule(); + } + } + + ctx.anki.on('card-mined', () => { + dirty = true; + schedule(); + }); + + ctx.mpv.on('end-file', ({ reason }) => { + if (settings.syncOnEnd && END_REASONS.has(reason)) return flush(); + }); + + ctx.actions.register('sync', () => { + dirty = true; + return flush(); + }); +} + +module.exports = { activate }; diff --git a/assets/plugins/anki-sync/plugin.json b/assets/plugins/anki-sync/plugin.json new file mode 100644 index 00000000..565b4533 --- /dev/null +++ b/assets/plugins/anki-sync/plugin.json @@ -0,0 +1,6 @@ +{ + "name": "Anki Sync", + "version": "1.0.0", + "apiVersion": 1, + "main": "main.js" +} diff --git a/assets/plugins/auto-jimaku/main.js b/assets/plugins/auto-jimaku/main.js new file mode 100644 index 00000000..1744e621 --- /dev/null +++ b/assets/plugins/auto-jimaku/main.js @@ -0,0 +1,51 @@ +// Opens the Jimaku subtitle search when a file starts without a Japanese subtitle track. +// It waits a few seconds after the file loads so external subtitles mpv or SubMiner add count. + +const DEFAULT_SETTINGS = { + // Built-in action to run, e.g. `openTsukihime` or `openSubtitleSelection`. + action: 'openJimaku', + // Track `lang` codes that count as Japanese. Track titles with "Japanese" or 日本語 count too. + languages: ['ja', 'jp', 'jpn'], + delayMs: 3_000, + // Also check http(s) streams; Jimaku can only search by name, which streams rarely have. + streams: false, +}; + +const JAPANESE_TITLE = /japanese|日本語/i; + +function activate(ctx) { + const settings = { ...DEFAULT_SETTINGS, ...ctx.settings }; + const languages = new Set(settings.languages.map((lang) => lang.toLowerCase())); + let cancelCheck = null; + let playbackId = null; + + function hasJapaneseSubtitle(trackList) { + return trackList.some( + (track) => + track?.type === 'sub' && + (languages.has(String(track.lang ?? '').toLowerCase()) || + JAPANESE_TITLE.test(String(track.title ?? ''))), + ); + } + + async function check(media) { + cancelCheck = null; + const trackList = await ctx.mpv.getProperty('track-list'); + if (ctx.media.current()?.playbackId !== media.playbackId || !Array.isArray(trackList)) return; + if (hasJapaneseSubtitle(trackList)) return; + ctx.log.info(`No Japanese subtitles for ${media.path}; running ${settings.action}.`); + await ctx.actions.run(settings.action); + } + + ctx.media.on('change', (media) => { + if (media?.playbackId === playbackId) return; + playbackId = media?.playbackId ?? null; + cancelCheck?.(); + cancelCheck = null; + if (!media) return; + if (!settings.streams && /^[a-z]+:\/\//i.test(media.path)) return; + cancelCheck = ctx.after(settings.delayMs, () => check(media)); + }); +} + +module.exports = { activate }; diff --git a/assets/plugins/auto-jimaku/plugin.json b/assets/plugins/auto-jimaku/plugin.json new file mode 100644 index 00000000..2b720859 --- /dev/null +++ b/assets/plugins/auto-jimaku/plugin.json @@ -0,0 +1,6 @@ +{ + "name": "Auto Jimaku", + "version": "1.0.0", + "apiVersion": 1, + "main": "main.js" +} diff --git a/assets/plugins/episode-tags/main.js b/assets/plugins/episode-tags/main.js new file mode 100644 index 00000000..74151a6b --- /dev/null +++ b/assets/plugins/episode-tags/main.js @@ -0,0 +1,110 @@ +// Tags each mined card with the show and episode it came from, e.g. `show::Frieren::S01E07`. +// The show and episode are parsed from the file name, or from mpv's media title for streams. +const path = require('node:path'); + +const DEFAULT_SETTINGS = { + // Placeholders: {show}, {season}, {episode}, and {ep} (S01E07, or E07 without a season). + // A tag stops before the first `::` level whose placeholder has no value. + tags: ['show::{show}::{ep}'], + notify: false, +}; + +// Tried in order; the first match wins. Runs on names with brackets and dots already removed. +const EPISODE_PATTERNS = [ + /^(?.*?)\s*S(?\d{1,2})\s?E(?\d{1,4})\b/i, + /^(?.*?)\s*(?:Season\s?(?\d{1,2})\s*)?(?:Episode|Ep)\s?(?\d{1,4})\b/i, + /^(?.*?)\s+-\s+(?\d{1,4})(?:v\d)?\b/, + /^(?.*?)\s+(?\d{1,4})(?:v\d)?$/, + /^(?.*?)\s*第(?\d{1,4})話/, +]; + +// `[Group] Show Name - 07 (1080p).mkv` -> { show: 'Show Name', season: null, episode: '07' } +function parseMediaName(name) { + const cleaned = name + .replace(/\.[a-z0-9]{2,4}$/i, '') + .replace(/\[[^\]]*\]|\([^)]*\)|【[^】]*】/g, ' ') + .replace(/[._]/g, ' ') + .replace(/\s+/g, ' ') + .trim(); + for (const pattern of EPISODE_PATTERNS) { + const groups = cleaned.match(pattern)?.groups; + const show = groups?.show.replace(/[\s-]+$/, ''); + if (!show) continue; + return { show, season: pad(groups.season), episode: pad(groups.episode) }; + } + return { show: cleaned || null, season: null, episode: null }; +} + +function pad(number) { + return number ? String(Number(number)).padStart(2, '0') : null; +} + +// Anki tags can't hold spaces, and a `::` in a value would add a hierarchy level. +function tagValue(value) { + return value.replace(/::?/g, ' ').trim().replace(/\s+/g, '_').replace(/"/g, ''); +} + +function renderTag(template, values) { + const levels = []; + for (const level of template.split('::')) { + let missing = false; + const rendered = level.replace(/\{(\w+)\}/g, (_, key) => { + const value = values[key]; + if (!value) missing = true; + return value ? tagValue(value) : ''; + }); + if (missing) break; + levels.push(rendered); + } + return levels.join('::'); +} + +function mediaName(mediaPath, title) { + // A stream URL's last segment is usually an id, so streams use the title mpv shows. + if (typeof mediaPath === 'string' && mediaPath && !/^[a-z]+:\/\//i.test(mediaPath)) { + return path.basename(mediaPath); + } + return typeof title === 'string' && title ? title : null; +} + +function renderTags(templates, name) { + const { show, season, episode } = parseMediaName(name); + const ep = episode ? `${season ? `S${season}` : ''}E${episode}` : null; + const values = { show, season, episode, ep }; + return [...new Set(templates.map((template) => renderTag(template, values)))].filter(Boolean); +} + +function activate(ctx) { + const settings = { ...DEFAULT_SETTINGS, ...ctx.settings }; + ctx.anki.on('card-mined', async ({ notes }) => { + // Notes from the same file share one addTags call. + const notesByName = new Map(); + for (const { noteId, provenance } of notes) { + const name = mediaName(provenance?.media?.path, provenance?.media?.title); + if (!name) { + ctx.log.debug(`Note ${noteId} has no known media source; not tagging it.`); + continue; + } + if (!notesByName.has(name)) notesByName.set(name, new Set()); + notesByName.get(name).add(noteId); + } + + for (const [name, notes] of notesByName) { + const tags = renderTags(settings.tags, name); + if (tags.length === 0) { + ctx.log.debug(`No tags for "${name}".`); + continue; + } + try { + await ctx.anki.invoke('addTags', { notes: [...notes], tags: tags.join(' ') }); + ctx.log.info(`Tagged ${notes.size} note(s): ${tags.join(' ')}`); + if (settings.notify) ctx.notify(`Tagged ${tags.join(' ')}`); + } catch (error) { + ctx.log.warn(`Tagging failed: ${error.message}`); + ctx.notify(`Episode tagging failed: ${error.message}`, { variant: 'error' }); + } + } + }); +} + +module.exports = { activate }; diff --git a/assets/plugins/episode-tags/plugin.json b/assets/plugins/episode-tags/plugin.json new file mode 100644 index 00000000..6b83a66c --- /dev/null +++ b/assets/plugins/episode-tags/plugin.json @@ -0,0 +1,6 @@ +{ + "name": "Episode Tags", + "version": "1.0.0", + "apiVersion": 1, + "main": "main.js" +} diff --git a/changes/plugin-system.md b/changes/plugin-system.md new file mode 100644 index 00000000..43e860ef --- /dev/null +++ b/changes/plugin-system.md @@ -0,0 +1,10 @@ +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 or run with `--plugin-action`, run built-in actions, react to mined Anki cards and call AnkiConnect, start mpv, run programs, keep state in a per-plugin data folder, and show results in an overlay panel or their own window. +- Added an example `ai-explain` plugin that sends the current line and its neighbors to `codex exec` and shows a translation or grammar explanation. +- Added an example `anki-sync` plugin that syncs Anki to AnkiWeb after you mine cards, once mining goes quiet or playback ends. +- Added an example `episode-tags` plugin that tags mined cards with the show and episode, such as `show::Frieren::S01E07`. +- Added an example `auto-jimaku` plugin that opens the Jimaku search when a file starts without a Japanese subtitle track. +- Added a versioned plugin API with standalone authoring types, cached playback snapshots, lifecycle cancellation and cleanup, and awaitable mpv commands. Async activation is bounded and actions wait only for their own plugin. Shutdown skips pending loads, runs each deactivation once, and blocks calls from disposed plugins. +- Mining events preserve original and kept note ids with the media and subtitle captured when mining starts, so delayed enrichment and merges into older notes keep the source episode. Stats-dashboard cards do not trigger plugin events. Plugin subtitle lookups and mining provenance account for subtitle delay and return timing on the subtitle file's timeline. diff --git a/config.example.jsonc b/config.example.jsonc index 1dba331f..cf2a4e8c 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 84be8109..ddbdb91a 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..a0e6a6c9 --- /dev/null +++ b/docs-site/plugins.md @@ -0,0 +1,199 @@ +# 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"] }, + ], + } + ``` + + To run an action from a script or a window-manager shortcut, use `subminer app --plugin-action ai-explain/explain`. It goes to the running SubMiner, or starts one if none is running. + +4. Restart SubMiner. Plugin changes apply on the next start; keybinding changes apply immediately. + +Load errors and plugin log output go to the app log with a `plugin:` scope. + +## Example: explain the current line with Codex + +The repository ships [`assets/plugins/ai-explain`](https://github.com/ksyasuda/SubMiner/tree/main/assets/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. + +## Example: sync Anki after mining + +[`assets/plugins/anki-sync`](https://github.com/ksyasuda/SubMiner/tree/main/assets/plugins/anki-sync) runs AnkiConnect's `sync` after you mine cards, so they reach AnkiWeb without opening Anki. It waits until you stop mining for a while, so a burst of cards becomes one sync. It also adds a `sync` action you can bind to a key. + +| Setting | Default | What it does | +| ----------- | ------- | ------------------------------------------------------- | +| `delayMs` | `30000` | Sync after no card has been mined for this long | +| `syncOnEnd` | `true` | Sync right away when playback ends or mpv quits | +| `timeoutMs` | `60000` | Give up on a sync after this long | +| `notify` | `true` | Show a notification after each sync. Failures always do | + +Anki must be running and logged in to AnkiWeb. Cards mined from the stats dashboard don't trigger a sync. + +## Example: tag cards with the episode + +[`assets/plugins/episode-tags`](https://github.com/ksyasuda/SubMiner/tree/main/assets/plugins/episode-tags) tags each mined card with the show and episode it came from, such as `show::Sousou_no_Frieren::E07`. It reads them from the file name (`[Group] Show - 07 (1080p).mkv`, `Show.S01E07.mkv`, `Show 第7話.mkv`), or from the title mpv shows for streams. It uses the source captured when mining started, so delayed media and field grouping into an older note keep the correct episode. Notes with unknown provenance, including cards discovered by polling, are left alone. Spaces become `_`. + +| Setting | Default | What it does | +| -------- | ------------------------ | ----------------------------------------------- | +| `tags` | `["show::{show}::{ep}"]` | Tags to add, one template each | +| `notify` | `false` | Show a notification with the tags for each card | + +Templates can use `{show}`, `{season}`, `{episode}`, and `{ep}` (`S01E07`, or `E07` when there is no season). When a placeholder has no value, the tag stops before that `::` level, so a movie gets `show::`. The parsing is a guess from the name: a title ending in a number, like `Mob Psycho 100`, reads that number as the episode. + +## Example: open Jimaku when a file has no Japanese subtitles + +[`assets/plugins/auto-jimaku`](https://github.com/ksyasuda/SubMiner/tree/main/assets/plugins/auto-jimaku) opens the Jimaku search a few seconds after a file starts if none of its subtitle tracks is Japanese. A track counts as Japanese when its language code is in `languages`, or its title contains "Japanese" or 日本語. + +| Setting | Default | What it does | +| ----------- | --------------------- | --------------------------------------------------------------- | +| `action` | `"openJimaku"` | Built-in action to run, e.g. `openTsukihime` | +| `languages` | `["ja", "jp", "jpn"]` | Track language codes that count as Japanese | +| `delayMs` | `3000` | Wait this long after the file starts, so external subs load | +| `streams` | `false` | Also check `http(s)` streams, which Jimaku usually can't search | + +## Write a plugin + +A plugin folder needs a `plugin.json`: + +```json +{ "name": "My Plugin", "version": "1.0.0", "apiVersion": 1, "main": "main.js" } +``` + +`apiVersion` declares the required SubMiner plugin API version. The current version is `1`; omitted values also mean v1 for existing plugins. An unsupported version is rejected before the plugin's entry module runs. `version` is the plugin's own version and does not control compatibility. + +`main` points to a CommonJS module (`.js`) or an ES module (`.mjs`) that exports `activate(ctx)`. Activation may be asynchronous and has a 10-second timeout. A plugin's actions wait for its own activation, so a slow plugin does not hold up other plugins. If activation fails or times out, its registrations are removed and its signal aborts. Synchronous code still shares the app's main thread. + +Activation may return a cleanup function, or the module can export `deactivate()`. On quit, SubMiner aborts `ctx.signal` first and waits up to 2 seconds in total for loading and deactivation. A plugin still reading its files is skipped. Everything a plugin registers through `ctx` is cleaned up automatically. A late activation cannot leave new listeners, timers, or actions behind, and its returned cleanup still runs once. Deactivation can call Anki, mpv, built-in actions, and notifications until disposal. After disposal, those calls throw or reject, including work that resumes after an activation or deactivation timeout. + +```js +function activate(ctx) { + ctx.media.on('change', (media) => { + if (media) ctx.log.info(`Now playing ${media.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_' }); + }); +} + +module.exports = { activate }; +``` + +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. + +### Types and cleanup + +The public types live in [`src/plugin-api.ts`](https://github.com/ksyasuda/SubMiner/blob/main/src/plugin-api.ts). A build emits `dist/plugin-api.d.ts`; copy that file into your plugin as `subminer.d.ts`, or copy the source file with that name. It has no imports from SubMiner internals. Electron's types must be available to your editor because `ui.openWindow` returns a `BrowserWindow`. + +JavaScript plugins can use the types through JSDoc: + +```js +/** @param {import('./subminer').PluginContext} ctx */ +function activate(ctx) { + const { ipcMain } = require('electron'); + ipcMain.handle(`${ctx.id}:info`, () => ({ media: ctx.media.current() })); + ctx.onDispose(() => ipcMain.removeHandler(`${ctx.id}:info`)); +} + +module.exports = { activate }; +``` + +Use `ctx.signal` with `fetch` or other cancellable work. It aborts when unloading starts, before `deactivate` runs. `ctx.onDispose(fn)` registers synchronous cleanup for resources outside `ctx`, such as file watchers and Electron IPC handlers. The returned function runs the cleanup early, at most once. Registering cleanup after unloading starts runs it immediately. Timers and listeners registered after that point are removed immediately too. Use the returned activation cleanup or `deactivate` for asynchronous teardown, within the shared quit timeout. + +### `ctx` reference + +| Member | What it does | +| -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- | +| `id`, `dir`, `settings` | Plugin id, plugin folder path, and `plugins.<id>.settings` | +| `apiVersion` | The host's plugin API version, currently `1` | +| `signal`, `onDispose(fn)` | Lifetime cancellation and cleanup for resources created outside `ctx` | +| `dataDir` | `plugin-data/<id>/` next to your config file, for caches and state. Exists before `activate` runs | +| `log.debug/info/warn/error(message)` | Write to the app log | +| `every(ms, fn)`, `after(ms, fn)` | Repeating and one-shot timers. Return a function that cancels them | +| `mpv.on(event, fn)` | Listen to mpv events (below). Returns an unsubscribe function | +| `mpv.command(args)` | Send an mpv command, e.g. `['set', 'pause', 'yes']` | +| `mpv.request(args)` | Await an mpv command result; rejects on command error, disconnect, or timeout | +| `mpv.getProperty(name)` | Read an mpv property (Promise) | +| `mpv.connected` | Whether mpv is connected | +| `mpv.ensureRunning()` | Connect to mpv, starting an idle mpv if none is running. Resolves `false` if that fails | +| `media.current()` | Cached `{ playbackId, path, title }`, or `null` when there is no connected media | +| `media.on('change', fn, options)` | Subscribe to playback snapshots; immediately delivers the current snapshot unless `emitCurrent: false` | +| `anki.on(event, fn)` | Listen to Anki events (below). Returns an unsubscribe function | +| `anki.invoke(action, params, options)` | Send an AnkiConnect action to `ankiConnect.url`, e.g. `('sync')`. Resolves its result (below) | +| `subtitles.current()` | The displayed line as `{ text, startTime, endTime }`, or `null` | +| `subtitles.context({ before, after })` | `{ current, before, after }`. Neighbors come from the loaded subtitle file, or recent lines for streams | +| `actions.register(name, fn)` | Add an action, bindable as `__plugin-action:<id>/<name>` | +| `actions.run(actionId, payload)` | Run a built-in action, the same ids keybindings use, e.g. `openJimaku` or `mineSentence` | +| `notify(message, { variant })` | Overlay notification. `variant` is `info`, `warning`, or `error` | +| `ui.panel({ title, markdown })` | Open the plugin panel. Returns `{ update(content), close(), closed, signal }` | +| `ui.openWindow({ html, preload })` | Open a window from the plugin folder (below). Returns the Electron `BrowserWindow` | +| `exec(command, args, options)` | Run a program without a shell (Windows `.cmd`/`.bat` shims go through `cmd.exe`). Resolves `{ exitCode, stdout, stderr }` | +| `unstable` | SubMiner internals. These change between releases without notice | + +A panel's `signal` aborts when the user closes it or another panel replaces it. Pass it to `exec` to stop work nobody will see. Closing or replacing a panel also cancels pending open requests, including while its window loads. 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. + +`ui.openWindow` takes `html` and an optional `preload`, both relative to the plugin folder, plus `title`, `width`, and `height`. Opening the same `html` again focuses the open window. The page runs with context isolation and no Node access. The preload is sandboxed too: it can `require('electron')` but nothing else, so bundle anything more into it. Expose what the page needs from the preload with `contextBridge`, and handle its messages with `ipcMain` from `require('electron')` in the plugin. Prefix channel names with the plugin id. Plugin windows close when SubMiner quits. + +`media` snapshots are immutable. `playbackId` changes on a new load, including reopening the same path. A title may arrive later with the same id. Disconnecting or ending playback delivers `null`. Use the id to discard stale asynchronous results. Snapshots describe cached playback state; they do not promise that external subtitle discovery has finished. + +`mpv.request` accepts string, number, and boolean arguments, e.g. `await ctx.mpv.request(['set_property', 'pause', true])`. It returns mpv's result data and rejects after 4 seconds. A timeout does not cancel the command in mpv, so a slow command may still complete. `mpv.command` remains available for commands whose result you do not need. + +mpv events: `connection-change`, `subtitle-change`, `subtitle-timing`, `time-pos-change`, `duration-change`, `pause-change`, `fullscreen-change`, `media-path-change`, `media-title-change`, `subtitle-track-change`, `subtitle-track-list-change`, `secondary-subtitle-change`, `secondary-subtitle-track-change`, `secondary-subtitle-delay-change`, `secondary-subtitle-visibility`, `client-message`, `file-loaded`, and `end-file`. `file-loaded` has no payload. `end-file` gives `{ reason, playlistEntryId, error }`, where `reason` is `eof`, `stop`, `quit`, `error`, `redirect`, or `unknown`. + +Anki events: `card-mined` gives `{ noteIds, notes }` once SubMiner has finished filling in a mined card (sentence, audio, image, and field grouping). For YouTube cards whose media waits on the download, it fires once that media is added or the download fails. With `ankiConnect.behavior.autoUpdateNewCards` off, it fires as soon as SubMiner sees the card. If field grouping merged the card into an older one, `noteIds` contains the older card's id. + +Each `notes` entry has `{ originalNoteId, noteId, provenance }`. `originalNoteId` identifies the note before merges; `noteId` is the note that kept its content. `provenance` is `{ minedAt, media, subtitle }`, captured before asynchronous creation or the AnkiConnect proxy's upstream request. `minedAt` is Unix time in milliseconds. `media` uses the snapshot format above, and `subtitle` uses `{ text, startTime, endTime }`. Either may be `null`. Polling cannot establish the original source, so its provenance is `null` rather than the currently playing file. + +`subtitles.current()` resolves parsed cues using the current subtitle delay and returns their times on the subtitle file's timeline. + +Sentence-card provenance also uses the subtitle file's timeline. Multi-line mining converts the history range using the subtitle delay when mining starts; changing the delay between those lines can affect the reported range. + +Several original notes can merge into one kept note. `notes` preserves each source, while `noteIds` contains unique kept ids. Existing listeners using only `noteIds` continue to work. Cards mined from the stats dashboard do not emit this event. + +`anki.invoke` takes `{ timeoutMs }` (default 10 seconds), rejects on an Anki error, and never retries, so an action like `sync` or `addNote` runs at most once. diff --git a/docs-site/public/config.example.jsonc b/docs-site/public/config.example.jsonc index 1dba331f..cf2a4e8c 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 <config dir>/plugins/<id>/ at startup. + // Plugins run inside SubMiner with full access to your system. Only enable code you trust. + // Restart SubMiner after changing plugins. + // ========================================== + "plugins": {}, // Plugins to load at startup, keyed by directory name under <config dir>/plugins/. Each entry is { "enabled": true, "settings": { ... } }; settings are passed to the plugin as ctx.settings. + // ========================================== // Keyboard Shortcuts // 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:<plugin id>/<action>"]` as the command. + Mouse button names are `MBTN_LEFT`, `MBTN_MID`, `MBTN_RIGHT`, `MBTN_BACK`, and `MBTN_FORWARD`. Scroll wheel names are `WHEEL_UP`, `WHEEL_DOWN`, `WHEEL_LEFT`, and `WHEEL_RIGHT`. See [keybindings](/configuration#keybindings) and [shortcuts configuration](/configuration#shortcuts-configuration) in the config reference. ## Automatic mpv bindings diff --git a/docs-site/usage.md b/docs-site/usage.md index 674c21d1..f32dcb6f 100644 --- a/docs-site/usage.md +++ b/docs-site/usage.md @@ -104,6 +104,7 @@ subminer doctor # check dependencies, config, and the mpv soc subminer generate-subs video.mkv # make Japanese subtitles from the audio subminer logs -e # export a log ZIP for bug reports subminer app --setup # reopen first-run setup +subminer app --plugin-action <id>/<name> # run a plugin action subminer -u # update SubMiner ``` diff --git a/docs-site/websocket-texthooker-api.md b/docs-site/websocket-texthooker-api.md index 31183713..f8cc01af 100644 --- a/docs-site/websocket-texthooker-api.md +++ b/docs-site/websocket-texthooker-api.md @@ -206,7 +206,7 @@ The same pattern works for n8n or Zapier workflows, Discord notifiers, or append ## mpv script messages -SubMiner has no in-app plugin SDK. Besides the streams above, you can drive it from other mpv scripts and from the [launcher CLI](/launcher-script). +SubMiner also supports [JavaScript app plugins](/plugins) for playback events, mining events, actions, and custom UI. Besides the streams above, you can drive it from other mpv scripts and from the [launcher CLI](/launcher-script). The mpv plugin accepts these script messages: diff --git a/docs/architecture/README.md b/docs/architecture/README.md index 0392a434..b16d0395 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 `<config dir>/plugins/<id>/plugin.json` for ids enabled in `plugins`, the per-plugin `ctx`, guarded callbacks, and `exec` (no shell, AppImage `LD_LIBRARY_PATH` dropped). `main.ts` supplies the host's deps, attaches each new mpv client so plugin listeners survive client replacement, starts plugins from the app-ready path after logging is configured, and unloads them first in the `will-quit` cleanup, before any teardown. Plugins run in the main process with no sandbox. `src/plugin-api.ts` is the standalone public v1 type contract; internal mpv event and session-action types derive from it. Manifests may declare `apiVersion`, which is checked before loading code. Async activation is bounded and actions wait only for their own plugin. The host owns cached media snapshots and playback ids, including repeated loads of the same path. `ctx.signal` aborts as unloading starts; `ctx.onDispose` manages outside resources and late registrations are disposed immediately. Awaitable `ctx.mpv.request` uses the existing request/response transport. Plugin actions are the `runPluginAction` session action (keybinding command `__plugin-action:<id>/<name>`, CLI `--plugin-action <id>/<name>`); `runAction` waits for an in-progress load so a cold-start CLI action is not dropped. Each plugin gets `<config dir>/plugin-data/<id>/` as `ctx.dataDir`. `ctx.anki` carries the `card-mined` event with unique kept `noteIds` and per-original-note records. Sentence-card creation snapshots media and subtitle context before asynchronous work; the proxy snapshots before forwarding an upstream request. `AnkiIntegration` keeps provenance under original ids through merges and queued YouTube jobs; polling uses null provenance. The event is fed by `AnkiIntegration.setCardsReadyCallback`: polling, the AnkiConnect proxy, and sentence-card creation report a note once its enrichment finishes (or when they see it, if auto-update is off); `AnkiIntegration` holds a sentence card's report until field grouping finishes, and the pending YouTube media queue reports notes whose media it is still waiting on (directly or through the note field grouping merged them into), which is later than `recordCardsMined` (stats counts at add time). Stats-dashboard mining does not emit it. `ctx.anki.invoke` sends one action to the effective `ankiConnect.url` with no retries. `src/main/runtime/plugin-window-runtime.ts` owns plugin windows (`ctx.ui.openWindow`): one window per plugin + HTML path, raised above the overlay like the settings windows, destroyed on unload. `src/main/runtime/plugin-panel-runtime.ts` owns the single `plugin-panel` modal: a new panel aborts the previous handle's signal, updates broadcast to both overlay windows keyed by `panelId`, and the latest content is re-sent once the renderer acknowledges the open. - `src/renderer/` owns overlay rendering and input behavior. - `src/config/` owns config definitions, defaults, loading, and resolution. - `src/types/` owns shared cross-runtime contracts via domain entrypoints; `src/types.ts` stays a compatibility barrel. diff --git a/docs/architecture/domains.md b/docs/architecture/domains.md index 343343b9..bc29ba91 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/main/runtime/plugin-window-runtime.ts` (plugin windows), `src/renderer/modals/plugin-panel*.ts`; examples in `assets/plugins/` - Window trackers: `src/window-trackers/` - Stats HTTP app: `src/core/services/stats-server.ts`, with route groups and shared route support in `src/core/services/stats-server/` diff --git a/docs/workflow/verification.md b/docs/workflow/verification.md index a58cfba8..99671eee 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`, `mpv-stdio.log`). diff --git a/e2e/harness/session.ts b/e2e/harness/session.ts index 8022535c..d4b29e8c 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<string, string>; }; export type AppTarget = { @@ -57,6 +59,8 @@ export type E2eSession = { mainProcess: () => Promise<CdpPage>; /** Runs a CLI command (e.g. `--mine-sentence`) against the running instance. */ runAppCommand: (...args: string[]) => Promise<void>; + /** After loading a file: waits for the app's autoplay resume, then leaves mpv paused. */ + settleAfterResume: () => Promise<void>; dispose: () => Promise<void>; }; @@ -155,6 +159,30 @@ async function openPage(cdpPort: number, urlPart: string): Promise<CdpPage> { return page; } +// mpv starts paused and the app resumes each newly loaded file once the overlay +// and tokenizer are ready, so that resume marks the file as ready. Scenarios +// expect a paused player, and the app retries its resume for a moment, so keep +// pausing until the pause has held for longer than that retry window. +async function settleAfterResume(mpv: MpvClient): Promise<void> { + await waitUntil(async () => (await mpv.command('get_property', 'pause')) === false, { + description: 'SubMiner to resume playback once the file is ready', + timeoutMs: 30_000, + }); + let pausedSince: number | null = null; + await waitUntil( + async () => { + if ((await mpv.command('get_property', 'pause')) !== true) { + pausedSince = null; + await mpv.command('set_property', 'pause', true); + return false; + } + pausedSince ??= Date.now(); + return Date.now() - pausedSince >= 1_500; + }, + { description: 'playback to stay paused after the resume', timeoutMs: 40_000 }, + ); +} + export async function startE2eSession(options: E2eSessionOptions = {}): Promise<E2eSession> { if (!fs.existsSync(path.join(repoRoot, 'dist', 'main-entry.js'))) { throw new Error('dist/ is missing. Run `bun run build` before the e2e harness.'); @@ -193,6 +221,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: { @@ -287,27 +324,7 @@ export async function startE2eSession(options: E2eSessionOptions = {}): Promise< teardown.push(mpv.close); const app = await launchApp(['--start']); - // mpv starts paused and the app resumes it once the overlay and tokenizer - // are ready, so that resume marks the end of startup. - await waitUntil(async () => (await mpv.command('get_property', 'pause')) === false, { - description: 'SubMiner to resume playback after overlay startup', - timeoutMs: 30_000, - }); - // Hand scenarios a paused player. The app retries its resume for a moment, - // so keep pausing until the pause has held for longer than that retry window. - let pausedSince: number | null = null; - await waitUntil( - async () => { - if ((await mpv.command('get_property', 'pause')) !== true) { - pausedSince = null; - await mpv.command('set_property', 'pause', true); - return false; - } - pausedSince ??= Date.now(); - return Date.now() - pausedSince >= 1_500; - }, - { description: 'playback to stay paused after startup', timeoutMs: 40_000 }, - ); + await settleAfterResume(mpv); const pages: CdpPage[] = []; teardown.push(() => pages.forEach((page) => page.close())); @@ -331,6 +348,7 @@ export async function startE2eSession(options: E2eSessionOptions = {}): Promise< return page; }, runAppCommand: (...args) => runAppCommand(target, args), + settleAfterResume: () => settleAfterResume(mpv), dispose, }; } catch (error) { diff --git a/e2e/plugins.e2e.test.ts b/e2e/plugins.e2e.test.ts new file mode 100644 index 00000000..88496ce7 --- /dev/null +++ b/e2e/plugins.e2e.test.ts @@ -0,0 +1,346 @@ +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 = ` +const { ipcMain } = require('electron'); + +exports.activate = (ctx) => { + ipcMain.handle('e2e-probe:info', () => ({ dataDir: ctx.dataDir, greeting: ctx.settings.greeting })); + ctx.onDispose(() => ipcMain.removeHandler('e2e-probe:info')); + ctx.media.on('change', (media) => ctx.log.info('probe media ' + JSON.stringify(media))); + ctx.anki.on('card-mined', ({ notes }) => ctx.log.info('probe mined ' + JSON.stringify(notes))); + ctx.actions.register('subtitle', () => ctx.log.info('probe subtitle ' + JSON.stringify(ctx.subtitles.current()))); + ctx.actions.register('request', async () => { + const title = await ctx.mpv.request(['get_property', 'media-title']); + ctx.log.info('probe request ' + JSON.stringify(title)); + try { await ctx.mpv.request(['subminer-no-such-command']); } + catch { ctx.log.info('probe request rejected'); } + }); + ctx.actions.register('window', () => { + ctx.ui.openWindow({ html: 'ui/index.html', preload: 'preload.js', title: 'E2E Probe Window' }); + }); + ctx.mpv.on('end-file', ({ reason }) => ctx.log.info('probe end-file ' + reason)); + ctx.actions.register('show', () => { + const { before, current, after } = ctx.subtitles.context({ before: 1, after: 1 }); + const panel = ctx.ui.panel({ title: 'Probe', markdown: '_working_' }); + 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 })); + }); +}; +`; + +const PROBE_PRELOAD = ` +const { contextBridge, ipcRenderer } = require('electron'); +contextBridge.exposeInMainWorld('probe', { info: () => ipcRenderer.invoke('e2e-probe:info') }); +`; + +const PROBE_WINDOW_HTML = `<!doctype html> +<html> + <head><title>E2E Probe Window + +
loading
+ + + +`; + +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, + 'plugins/e2e-probe/preload.js': PROBE_PRELOAD, + 'plugins/e2e-probe/ui/index.html': PROBE_WINDOW_HTML, + }, + }); + 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' }, + ); + }, +); + +test( + 'a CLI plugin action opens a plugin window whose preload talks to the plugin', + { timeout: TEST_TIMEOUT_MS }, + async () => { + const { session } = await useSession(); + + await session.runAppCommand('--plugin-action', 'e2e-probe/window'); + + const page = await session.openPage('plugins/e2e-probe/ui/index.html'); + const info = await page.waitFor<{ dataDir: string; greeting: string } | false>( + `(() => { + const text = document.getElementById('out')?.textContent; + return text && text !== 'loading' ? JSON.parse(text) : false; + })()`, + { description: 'plugin window to read plugin info over IPC' }, + ); + + assert.equal(info.greeting, 'hello'); + assert.equal(path.basename(info.dataDir), 'e2e-probe'); + assert.equal(path.basename(path.dirname(info.dataDir)), 'plugin-data'); + assert.equal(fs.statSync(info.dataDir).isDirectory(), true); + }, +); + +test('plugins receive mpv end-file events', { timeout: TEST_TIMEOUT_MS }, async () => { + const { session } = await useSession(); + const mediaPath = await session.mpv.command('get_property', 'path'); + + // Replacing the playing file ends it with reason "stop". + await session.mpv.command('loadfile', String(mediaPath), 'replace'); + + await waitUntil( + () => + fs.readFileSync(path.join(session.root, 'app.log'), 'utf8').includes('probe end-file stop'), + { description: 'the plugin to log the end-file reason' }, + ); + // Later tests need the replacement loaded and paused: a sub-add during the reload lands on + // the outgoing file, and the app's autoplay resume would keep cues moving under them. + await session.settleAfterResume(); +}); + +test( + 'plugin subtitle lookup uses the subtitle clock for repeated retimed lines', + { timeout: TEST_TIMEOUT_MS }, + async () => { + const { session, overlay } = await useSession(); + // Unlike any fixture cue, so the overlay showing it proves main received this line rather + // than still displaying one from an earlier test. + const text = '今日は猫が好きです。'; + const subtitlePath = path.join(session.root, 'repeated.srt'); + fs.writeFileSync( + subtitlePath, + `1\n00:00:01,000 --> 00:00:03,000\n${text}\n\n2\n00:00:06,000 --> 00:00:08,000\n${text}\n`, + ); + const originalSid = await session.mpv.command('get_property', 'sid'); + // Runs the probe action and returns the cue it logged on its nth run. + const reportSubtitle = async (run: number): Promise => { + await session.runAppCommand('--plugin-action', 'e2e-probe/subtitle'); + const line = await waitUntil( + () => + fs + .readFileSync(path.join(session.root, 'app.log'), 'utf8') + .split('\n') + .filter((entry) => entry.includes('probe subtitle '))[run - 1], + { description: `the plugin to report its current subtitle (run ${run})` }, + ); + assert.ok(line); + return JSON.parse(line.slice(line.indexOf('probe subtitle ') + 'probe subtitle '.length)); + }; + const seekToCue = async (time: number, cueIndex: number): Promise => { + await session.mpv.command('seek', time, 'absolute+exact'); + // time-pos can activate the sidebar while mpv is still rendering the seek with + // sub-text cleared. Wait for the seek, then for main to receive the live text. + await waitUntil( + async () => (await session.mpv.command('get_property', 'seeking')) === false, + { + description: `the seek to ${time}s to finish`, + }, + ); + await overlay.waitFor( + `(async () => { + const text = await window.electronAPI.getCurrentSubtitleRaw(); + return text === ${JSON.stringify(text)} && + document.querySelectorAll('#subtitleSidebarList .subtitle-sidebar-item')[${cueIndex}]?.classList.contains('active') === true; + })()`, + { description: `delayed cue ${cueIndex + 1} to be active with live subtitle text` }, + ); + }; + try { + await session.mpv.command('seek', 0, 'absolute+exact'); + await session.mpv.command('sub-add', subtitlePath, 'select'); + await session.mpv.command('set_property', 'sub-delay', 5); + await session.runAppCommand('--toggle-subtitle-sidebar'); + await overlay.waitFor( + `document.querySelectorAll('#subtitleSidebarList .subtitle-sidebar-item').length === 2`, + { description: 'the repeated subtitle cues to be parsed' }, + ); + await seekToCue(7, 0); + assert.deepEqual(await reportSubtitle(1), { text, startTime: 1, endTime: 3 }); + + // Same text again at 6-8 on the subtitle clock, so only the timings tell the cues apart. + await seekToCue(12, 1); + assert.deepEqual(await reportSubtitle(2), { text, startTime: 6, endTime: 8 }); + } finally { + await session.mpv.command('set_property', 'sub-delay', 0); + await session.mpv.command('set_property', 'sid', originalSid); + await session.runAppCommand('--toggle-subtitle-sidebar'); + } + }, +); + +test( + 'plugin requests report command failures and mined notes carry the captured source', + { timeout: TEST_TIMEOUT_MS }, + async () => { + const { session, overlay } = await useSession(); + const cue = FIXTURE_CUES[1]; + await session.mpv.command('seek', cue.at, 'absolute+exact'); + await overlay.waitFor( + `document.getElementById('subtitleRoot').textContent === ${JSON.stringify(cue.text)}`, + { description: 'the mined subtitle to be visible' }, + ); + await session.runAppCommand('--plugin-action', 'e2e-probe/request'); + await waitUntil( + () => + fs + .readFileSync(path.join(session.root, 'app.log'), 'utf8') + .includes('probe request rejected'), + { description: 'mpv to reject an invalid plugin request' }, + ); + await session.runAppCommand('--mine-sentence'); + const log = await waitUntil( + () => { + const text = fs.readFileSync(path.join(session.root, 'app.log'), 'utf8'); + return text.includes('probe mined ') ? text : null; + }, + { description: 'the mining event to reach the plugin' }, + ); + assert.ok(log); + const line = log.split('\n').find((entry) => entry.includes('probe mined ')); + assert.ok(line); + const notes: unknown = JSON.parse( + line.slice(line.indexOf('probe mined ') + 'probe mined '.length), + ); + assert.ok(Array.isArray(notes)); + assert.equal(notes.length, 1); + const note: unknown = notes[0]; + assert.partialDeepStrictEqual(note, { + provenance: { + subtitle: { text: cue.text }, + media: { path: await session.mpv.command('get_property', 'path') }, + }, + }); + assert.ok(note && typeof note === 'object' && 'originalNoteId' in note && 'noteId' in note); + assert.equal(note.originalNoteId, note.noteId); + }, +); diff --git a/src/anki-integration.test.ts b/src/anki-integration.test.ts index 90a118b6..14547fa7 100644 --- a/src/anki-integration.test.ts +++ b/src/anki-integration.test.ts @@ -6,6 +6,7 @@ import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; import { AnkiIntegration } from './anki-integration'; +import type { PluginMinedNote, PluginMiningProvenance } from './plugin-api'; import type { MediaInput } from './media-input'; import { AnkiConnectConfig } from './types'; @@ -82,6 +83,8 @@ type IntegrationInternals = { fields: Record; }) => void; rememberMergedNoteIds: (deletedNoteId: number, keptNoteId: number) => void; + reportCardsReady: (noteIds: number[]) => void; + rememberMiningProvenance: (noteIds: number[], provenance: PluginMiningProvenance | null) => void; }; /** Named stand-ins for AnkiIntegration's long positional constructor arguments. */ @@ -273,6 +276,44 @@ test('AnkiIntegration resolves merged-away note ids to the kept note id', () => assert.equal(integration.resolveCurrentNoteId(444), 444); }); +test('AnkiIntegration reports ready cards under the note that kept them after a merge', () => { + const integration = createIntegration({}); + const ready: number[][] = []; + integration.setCardsReadyCallback((noteIds) => ready.push(noteIds)); + const internals = internalsOf(integration); + internals.rememberMergedNoteIds(111, 222); + + internals.reportCardsReady([111, 222, 444]); + + assert.deepEqual(ready, [[222, 444]]); +}); + +test('ready events preserve each mining source when multiple notes merge into an older note', () => { + const integration = createIntegration({}); + const ready: PluginMinedNote[][] = []; + integration.setCardsReadyCallback((_ids, notes) => ready.push(notes)); + const internals = internalsOf(integration); + const provenance = { + minedAt: 1234, + media: { playbackId: 1, path: 'Show - 02.mkv', title: null }, + subtitle: { text: 'テスト', startTime: 1, endTime: 2 }, + }; + internals.rememberMiningProvenance([111, 222], provenance); + provenance.media.path = 'Show - 03.mkv'; + internals.rememberMergedNoteIds(111, 7); + internals.rememberMergedNoteIds(222, 7); + internals.reportCardsReady([111, 222]); + assert.deepEqual( + ready[0]?.map((note) => [note.originalNoteId, note.noteId, note.provenance?.media?.path]), + [ + [111, 7, 'Show - 02.mkv'], + [222, 7, 'Show - 02.mkv'], + ], + ); + internals.reportCardsReady([333]); + assert.equal(ready[1]?.[0]?.provenance, null); +}); + // --- Sentence highlighting -------------------------------------------------- const MPV_SENTENCE = '先日 貴様らが潜入した キールダンジョンから―'; @@ -404,6 +445,57 @@ test('AnkiIntegration triggers field grouping after a local duplicate sentence c assert.equal(groupingTriggered, 1); }); +test('AnkiIntegration reports a new sentence card ready only after field grouping', async () => { + const integration = createIntegration({ + isKiku: { enabled: true, fieldGrouping: 'manual' }, + }); + const events: string[] = []; + integration.setCardsReadyCallback((noteIds) => events.push(`ready ${noteIds.join(',')}`)); + const internals = internalsOf(integration); + internals.cardCreationService = { + createSentenceCard: async () => { + integration.trackDuplicateNoteIdsForNote(42, [7]); + internals.reportCardsReady([42]); + return true; + }, + }; + internals.fieldGroupingService = { + triggerFieldGroupingForLastAddedCard: async () => { + events.push('group'); + }, + }; + + assert.equal(await integration.createSentenceCard('duplicate sentence', 0, 1), true); + assert.deepEqual(events, ['group', 'ready 42']); +}); + +test('AnkiIntegration reports a card with queued YouTube media ready once the media lands', async () => { + const events: string[] = []; + const integration = createIntegration({ + fields: { audio: 'ExpressionAudio', image: 'Picture' }, + media: { imageFormat: 'jpg' }, + behavior: { notificationType: 'osd' }, + }); + installFakeAnki(integration, { + noteFieldNames: ['ExpressionAudio', 'Picture'], + mediaGenerator: { + generateAudio: async () => Buffer.from('audio'), + generateScreenshot: async () => Buffer.from('image'), + }, + }); + integration.setCardsReadyCallback((noteIds) => events.push(`ready ${noteIds.join(',')}`)); + const internals = internalsOf(integration); + internals.queuePendingYoutubeMediaUpdate(youtubeJob({ noteId: 101 })); + // Note 7 was merged into 101, so it waits on 101's media too. + internals.rememberMergedNoteIds(7, 101); + + internals.reportCardsReady([101, 7, 5]); + assert.deepEqual(events, ['ready 5']); + + await integration.handleYoutubeMediaCacheReady('https://youtu.be/abc123', '/tmp/media.mkv'); + assert.deepEqual(events, ['ready 5', 'ready 101']); +}); + test('AnkiIntegration marks partial update notifications as failures in OSD mode', async () => { const osdMessages: string[] = []; const integration = createIntegration( diff --git a/src/anki-integration.ts b/src/anki-integration.ts index d004ef22..6568e839 100644 --- a/src/anki-integration.ts +++ b/src/anki-integration.ts @@ -17,6 +17,7 @@ */ import { AnkiConnectClient } from './anki-connect'; +import type { PluginMinedNote, PluginMiningProvenance } from './plugin-api'; import { SubtitleTimingTracker } from './subtitle-timing-tracker'; import { MediaGenerator } from './media-generator'; import path from 'path'; @@ -241,6 +242,11 @@ export class AnkiIntegration { private runtime: AnkiIntegrationRuntime; private aiConfig: AiConfig; private recordCardsMinedCallback: ((count: number, noteIds?: number[]) => void) | null = null; + private cardsReadyCallback: ((noteIds: number[], notes: PluginMinedNote[]) => void) | null = null; + private miningProvenanceProvider: (() => PluginMiningProvenance | null) | null = null; + private miningProvenance = new Map(); + // Ready reports collected while createSentenceCard still has field grouping to run. + private heldReadyNoteIds: number[] | null = null; private knownWordCacheUpdatedCallback: (() => void) | null = null; private consumeSubtitleMiningContextCallback: (() => SubtitleMiningContext | null) | null = null; private generateSentenceFuriganaCallback: @@ -337,6 +343,74 @@ export class AnkiIntegration { } } + private reportCardsReady(noteIds: number[]): void { + if (this.heldReadyNoteIds) { + this.heldReadyNoteIds.push(...noteIds); + return; + } + // Notes still waiting on queued YouTube media, directly or through the note they were + // merged into, are reported by the queue once it settles. + const queue = this.pendingYoutubeMediaQueue; + this.emitCardsReady( + noteIds.filter( + (noteId) => + !queue.hasPendingNote(noteId) && !queue.hasPendingNote(this.resolveCurrentNoteId(noteId)), + ), + ); + } + + private emitCardsReady(noteIds: number[]): void { + if (noteIds.length === 0) return; + // Field grouping may have merged a note away; report the note that kept its content. + const currentNoteIds = [...new Set(noteIds.map((noteId) => this.resolveCurrentNoteId(noteId)))]; + const notes: PluginMinedNote[] = []; + for (const [originalNoteId, provenance] of this.miningProvenance) { + const noteId = this.resolveCurrentNoteId(originalNoteId); + if ( + !currentNoteIds.includes(noteId) || + this.pendingYoutubeMediaQueue.hasPendingNote(originalNoteId) || + this.pendingYoutubeMediaQueue.hasPendingNote(noteId) + ) + continue; + notes.push({ originalNoteId, noteId, provenance }); + this.miningProvenance.delete(originalNoteId); + } + for (const originalNoteId of noteIds) { + const noteId = this.resolveCurrentNoteId(originalNoteId); + if ( + !notes.some((note) => note.noteId === noteId) && + ![...this.miningProvenance.keys()].some((id) => this.resolveCurrentNoteId(id) === noteId) + ) { + notes.push({ originalNoteId, noteId, provenance: null }); + } + } + if (notes.length === 0) return; + try { + this.cardsReadyCallback?.([...new Set(notes.map((note) => note.noteId))], notes); + } catch (error) { + log.warn('cardsReady callback failed:', (error as Error).message); + } + } + + private rememberMiningProvenance( + noteIds: number[], + provenance: PluginMiningProvenance | null, + ): void { + for (const noteId of noteIds) { + if (!this.miningProvenance.get(noteId)) { + this.miningProvenance.set( + noteId, + provenance && + Object.freeze({ + ...provenance, + media: provenance.media && Object.freeze({ ...provenance.media }), + subtitle: provenance.subtitle && Object.freeze({ ...provenance.subtitle }), + }), + ); + } + } + } + private getMediaResolverOptions(): MediaGenerationInputResolverOptions { const options: MediaGenerationInputResolverOptions = { logDebug: (message) => log.debug(message), @@ -415,6 +489,7 @@ export class AnkiIntegration { showStatusNotification: (message) => this.showStatusNotification(message), showNotification: (noteId, label, errorSuffix) => this.showNotification(noteId, label, errorSuffix), + onUpdateSettled: (noteId) => this.reportCardsReady([noteId]), logInfo: (...args) => log.info(args[0] as string, ...args.slice(1)), logWarn: (...args) => log.warn(args[0] as string, ...args.slice(1)), logError: (...args) => log.error(args[0] as string, ...args.slice(1)), @@ -445,6 +520,10 @@ export class AnkiIntegration { recordCardsAdded: (count, noteIds) => { this.recordCardsMinedSafely(count, noteIds, 'polling'); }, + onCardsReady: (noteIds) => { + this.rememberMiningProvenance(noteIds, null); + this.reportCardsReady(noteIds); + }, isUpdateInProgress: () => this.updateInProgress, setUpdateInProgress: (value) => { this.updateInProgress = value; @@ -464,11 +543,14 @@ export class AnkiIntegration { const { AnkiConnectProxyServer } = require('./anki-integration/anki-connect-proxy') as typeof import('./anki-integration/anki-connect-proxy'); return new AnkiConnectProxyServer({ + captureMiningProvenance: () => this.miningProvenanceProvider?.() ?? null, + onNotesAdded: (noteIds, provenance) => this.rememberMiningProvenance(noteIds, provenance), shouldAutoUpdateNewCards: () => this.config.behavior?.autoUpdateNewCards !== false, processNewCard: (noteId: number) => this.processNewCard(noteId), recordCardsAdded: (count, noteIds) => { this.recordCardsMinedSafely(count, noteIds, 'proxy'); }, + onCardsReady: (noteIds) => this.reportCardsReady(noteIds), trackAddedDuplicateNoteIds: (noteId, duplicateNoteIds) => { this.trackDuplicateNoteIdsForNote(noteId, duplicateNoteIds); }, @@ -594,6 +676,9 @@ export class AnkiIntegration { recordCardsMinedCallback: (count, noteIds) => { this.recordCardsMinedSafely(count, noteIds, 'card creation'); }, + captureMiningProvenance: () => this.miningProvenanceProvider?.() ?? null, + onNoteCreated: (noteId, provenance) => this.rememberMiningProvenance([noteId], provenance), + onCardsReady: (noteIds) => this.reportCardsReady(noteIds), reviewMediaTiming: (request) => this.reviewMediaTiming(request), }); } @@ -1544,25 +1629,36 @@ export class AnkiIntegration { secondarySubText?: string, ): Promise { const trackedDuplicateNoteIdsBeforeCreate = new Set(this.trackedDuplicateNoteIds.keys()); - const created = await this.cardCreationService.createSentenceCard( - sentence, - startTime, - endTime, - secondarySubText, - ); - if ( - created && - this.shouldTriggerFieldGroupingAfterLocalSentenceCardCreate( - trackedDuplicateNoteIdsBeforeCreate, - ) - ) { - try { - await this.fieldGroupingService.triggerFieldGroupingForLastAddedCard(); - } catch (error) { - log.warn('Failed to trigger field grouping after sentence card creation:', error); + // Field grouping below can still rewrite the new card, so hold its ready report until then. + const ownsHold = this.heldReadyNoteIds === null; + if (ownsHold) this.heldReadyNoteIds = []; + try { + const created = await this.cardCreationService.createSentenceCard( + sentence, + startTime, + endTime, + secondarySubText, + ); + if ( + created && + this.shouldTriggerFieldGroupingAfterLocalSentenceCardCreate( + trackedDuplicateNoteIdsBeforeCreate, + ) + ) { + try { + await this.fieldGroupingService.triggerFieldGroupingForLastAddedCard(); + } catch (error) { + log.warn('Failed to trigger field grouping after sentence card creation:', error); + } + } + return created; + } finally { + if (ownsHold) { + const held = this.heldReadyNoteIds ?? []; + this.heldReadyNoteIds = null; + this.reportCardsReady(held); } } - return created; } trackDuplicateNoteIdsForNote(noteId: number, duplicateNoteIds: number[]): void { @@ -1781,6 +1877,21 @@ export class AnkiIntegration { this.recordCardsMinedCallback = callback; } + /** + * Called once SubMiner has finished writing a mined card (fields, media including queued YouTube + * media, and field grouping), or as soon as polling or the proxy sees it when auto-update is off. + * Ids merged away by field grouping are reported as the note that kept them. + */ + setCardsReadyCallback( + callback: ((noteIds: number[], notes: PluginMinedNote[]) => void) | null, + ): void { + this.cardsReadyCallback = callback; + } + + setMiningProvenanceProvider(provider: (() => PluginMiningProvenance | null) | null): void { + this.miningProvenanceProvider = provider; + } + setKnownWordCacheUpdatedCallback(callback: (() => void) | null): void { this.knownWordCacheUpdatedCallback = callback; } diff --git a/src/anki-integration/anki-connect-proxy.test.ts b/src/anki-integration/anki-connect-proxy.test.ts index 8f811fae..fd1eeab7 100644 --- a/src/anki-integration/anki-connect-proxy.test.ts +++ b/src/anki-integration/anki-connect-proxy.test.ts @@ -3,6 +3,7 @@ import http from 'node:http'; import { once } from 'node:events'; import test from 'node:test'; import { AnkiConnectProxyServer } from './anki-connect-proxy'; +import type { PluginMiningProvenance } from '../plugin-api'; async function waitForCondition( condition: () => boolean, @@ -17,17 +18,23 @@ async function waitForCondition( throw new Error('Timed out waiting for condition'); } -test('proxy enqueues addNote result for enrichment', async () => { +test('proxy enqueues addNote result for enrichment and reports it ready afterwards', async () => { const processed: number[] = []; const recordedCards: number[] = []; + const events: string[] = []; const proxy = new AnkiConnectProxyServer({ shouldAutoUpdateNewCards: () => true, processNewCard: async (noteId) => { processed.push(noteId); + events.push(`process ${noteId}`); + throw new Error('enrichment failed'); }, recordCardsAdded: (count) => { recordedCards.push(count); }, + onCardsReady: (noteIds) => { + events.push(`ready ${noteIds.join(',')}`); + }, logInfo: () => undefined, logWarn: () => undefined, logError: () => undefined, @@ -42,9 +49,44 @@ test('proxy enqueues addNote result for enrichment', async () => { Buffer.from(JSON.stringify({ result: 42, error: null }), 'utf8'), ); - await waitForCondition(() => processed.length === 1); + await waitForCondition(() => events.length === 2); assert.deepEqual(processed, [42]); assert.deepEqual(recordedCards, [1]); + assert.deepEqual(events, ['process 42', 'ready 42']); +}); + +test('proxy reports an added note ready right away when auto-update is off', () => { + const processed: number[] = []; + const recordedCards: number[] = []; + const ready: number[][] = []; + const proxy = new AnkiConnectProxyServer({ + shouldAutoUpdateNewCards: () => false, + processNewCard: async (noteId) => { + processed.push(noteId); + }, + recordCardsAdded: (count) => { + recordedCards.push(count); + }, + onCardsReady: (noteIds) => { + ready.push(noteIds); + }, + logInfo: () => undefined, + logWarn: () => undefined, + logError: () => undefined, + }); + + ( + proxy as unknown as { + maybeEnqueueFromRequest: (request: Record, responseBody: Buffer) => void; + } + ).maybeEnqueueFromRequest( + { action: 'addNote' }, + Buffer.from(JSON.stringify({ result: 42, error: null }), 'utf8'), + ); + + assert.deepEqual(ready, [[42]]); + assert.deepEqual(processed, []); + assert.deepEqual(recordedCards, []); }); test('proxy enqueues addNote bare numeric response for enrichment', async () => { @@ -360,8 +402,11 @@ test('proxy tracks duplicate note ids from addNote request metadata before enric assert.deepEqual(processed, [42]); }); -test('proxy strips SubMiner duplicate metadata before forwarding upstream addNote request', async () => { +test('proxy strips duplicate metadata and captures mining provenance before the upstream request', async () => { let upstreamBody = ''; + let mediaPath = 'Show - 02.mkv'; + let captureCount = 0; + const sources: Array = []; const upstream = http.createServer(async (req, res) => { upstreamBody = await new Promise((resolve) => { const chunks: Buffer[] = []; @@ -369,6 +414,7 @@ test('proxy strips SubMiner duplicate metadata before forwarding upstream addNot req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8'))); }); res.statusCode = 200; + mediaPath = 'Show - 03.mkv'; res.setHeader('content-type', 'application/json'); res.end(JSON.stringify({ result: 42, error: null })); }); @@ -380,6 +426,15 @@ test('proxy strips SubMiner duplicate metadata before forwarding upstream addNot const tracked: Array<{ noteId: number; duplicateNoteIds: number[] }> = []; const proxy = new AnkiConnectProxyServer({ + captureMiningProvenance: () => { + captureCount++; + return { + minedAt: 1234, + media: { playbackId: 1, path: mediaPath, title: null }, + subtitle: null, + }; + }, + onNotesAdded: (_ids, provenance) => sources.push(provenance), shouldAutoUpdateNewCards: () => true, processNewCard: async () => undefined, trackAddedDuplicateNoteIds: (noteId, duplicateNoteIds) => { @@ -434,6 +489,16 @@ test('proxy strips SubMiner duplicate metadata before forwarding upstream addNot await waitForCondition(() => tracked.length === 1); assert.equal(upstreamBody.includes('subminerDuplicateNoteIds'), false); assert.deepEqual(tracked, [{ noteId: 42, duplicateNoteIds: [7, 18] }]); + assert.equal(sources[0]?.media?.path, 'Show - 02.mkv'); + assert.equal(captureCount, 1); + const preflight = await fetch(`http://127.0.0.1:${proxyPort}`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ action: 'canAddNotes', version: 6, params: { notes: [] } }), + }); + assert.equal(preflight.status, 200); + await preflight.json(); + assert.equal(captureCount, 1); } finally { proxy.stop(); upstream.close(); diff --git a/src/anki-integration/anki-connect-proxy.ts b/src/anki-integration/anki-connect-proxy.ts index 91c25c1d..7905c8bd 100644 --- a/src/anki-integration/anki-connect-proxy.ts +++ b/src/anki-integration/anki-connect-proxy.ts @@ -1,5 +1,6 @@ import http, { IncomingMessage, ServerResponse } from 'node:http'; import axios, { AxiosInstance } from 'axios'; +import type { PluginMiningProvenance } from '../plugin-api'; interface StartProxyOptions { host: string; @@ -13,9 +14,13 @@ interface AnkiConnectEnvelope { } export interface AnkiConnectProxyServerDeps { + captureMiningProvenance?: () => PluginMiningProvenance | null; + onNotesAdded?: (noteIds: number[], provenance: PluginMiningProvenance | null) => void; shouldAutoUpdateNewCards: () => boolean; processNewCard: (noteId: number) => Promise; recordCardsAdded?: (count: number, noteIds: number[]) => void; + /** Called once SubMiner is done with an added note: after enrichment, or right away when it is off. */ + onCardsReady?: (noteIds: number[]) => void; trackAddedDuplicateNoteIds?: (noteId: number, duplicateNoteIds: number[]) => void; getDeck?: () => string | undefined; findNotes?: ( @@ -186,6 +191,12 @@ export class AnkiConnectProxyServer { typeof req.headers['content-type'] === 'string' ? req.headers['content-type'] : 'application/json'; + const action = requestJson?.action; + const provenance = + typeof action === 'string' && + ['addNote', 'addNotes', 'multi', 'updateNoteFields'].includes(action) + ? (this.deps.captureMiningProvenance?.() ?? null) + : null; const upstreamResponse = await this.client.request({ url: targetUrl, method: req.method, @@ -203,7 +214,7 @@ export class AnkiConnectProxyServer { res.end(responseBody); if (req.method === 'POST') { - this.maybeEnqueueFromRequest(requestJson, responseBody); + this.maybeEnqueueFromRequest(requestJson, responseBody, provenance); } } catch (error) { this.deps.logWarn('[anki-proxy] Failed to forward request:', (error as Error).message); @@ -215,16 +226,19 @@ export class AnkiConnectProxyServer { private maybeEnqueueFromRequest( requestJson: Record | null, responseBody: Buffer, + provenance: PluginMiningProvenance | null = null, ): void { - if (!requestJson || !this.deps.shouldAutoUpdateNewCards()) { + if (!requestJson) { return; } + const autoUpdate = this.deps.shouldAutoUpdateNewCards(); const action = typeof requestJson.action === 'string' ? requestJson.action : String(requestJson.action ?? ''); if (action === 'updateNoteFields') { + if (!autoUpdate) return; const params = requestJson.params; if ( !params || @@ -246,6 +260,7 @@ export class AnkiConnectProxyServer { response.result !== null ) return; + this.deps.onNotesAdded?.(this.collectSingleResultId(note.id), provenance); this.enqueueNotes(this.collectSingleResultId(note.id), false); return; } @@ -277,9 +292,16 @@ export class AnkiConnectProxyServer { params.subminerEnrich === false ) { // Stats owns the saved sentence and media; the live mpv context is unrelated. - if (noteIds.length > 0) this.deps.recordCardsAdded?.(noteIds.length, noteIds); + if (autoUpdate && noteIds.length > 0) this.deps.recordCardsAdded?.(noteIds.length, noteIds); return; } + if (!autoUpdate) { + // Nothing to enrich, so the added notes are already in their final state. + this.deps.onNotesAdded?.(noteIds, provenance); + if (noteIds.length > 0) this.deps.onCardsReady?.(noteIds); + return; + } + this.deps.onNotesAdded?.(noteIds, provenance); this.maybeTrackDuplicateNoteIds(requestJson, action, responseResult); if (noteIds.length === 0 && shouldFallbackToLatestAdded) { void this.enqueueMostRecentAddedNote(); @@ -534,6 +556,7 @@ export class AnkiConnectProxyServer { this.pendingNoteIdSet.delete(noteId); if (!this.deps.shouldAutoUpdateNewCards()) { + this.deps.onCardsReady?.([noteId]); continue; } @@ -547,6 +570,7 @@ export class AnkiConnectProxyServer { ); } finally { this.inFlightNoteIds.delete(noteId); + this.deps.onCardsReady?.([noteId]); } } } finally { diff --git a/src/anki-integration/card-creation.test.ts b/src/anki-integration/card-creation.test.ts index cf2d4002..9c9229b1 100644 --- a/src/anki-integration/card-creation.test.ts +++ b/src/anki-integration/card-creation.test.ts @@ -11,12 +11,37 @@ import { } from './card-creation-test-utils'; import { toMpvEdlValue } from './mpv-edl-test-utils'; import type { MediaInput } from '../media-generator'; +import type { PluginMiningProvenance } from '../plugin-api'; const YOUTUBE_URL = 'https://www.youtube.com/watch?v=abc123'; const mediaInputPath = (mediaInput: MediaInput): string => typeof mediaInput === 'string' ? mediaInput : mediaInput.path; +test('sentence provenance is captured before asynchronous creation and uses the mined line', async () => { + let currentPath = 'Show - 02.mkv'; + const captured: Array = []; + const service = new CardCreationService( + createCardCreationDeps({ + captureMiningProvenance: () => ({ + minedAt: 1234, + media: { playbackId: 1, path: currentPath, title: null }, + subtitle: null, + }), + client: createFakeClient({ + addNote: async () => { + currentPath = 'Show - 03.mkv'; + return 42; + }, + }), + onNoteCreated: (_noteId, provenance) => captured.push(provenance), + }), + ); + assert.equal(await service.createSentenceCard('テスト', 1, 2), true); + assert.equal(captured[0]?.media?.path, 'Show - 02.mkv'); + assert.deepEqual(captured[0]?.subtitle, { text: 'テスト', startTime: 1, endTime: 2 }); +}); + test('CardCreationService counts locally created sentence cards', async () => { const minedCards: Array<{ count: number; noteIds?: number[] }> = []; const service = new CardCreationService( @@ -31,6 +56,58 @@ test('CardCreationService counts locally created sentence cards', async () => { assert.deepEqual(minedCards, [{ count: 1, noteIds: [42] }]); }); +for (const delay of [5, -5]) { + test(`sentence provenance stays on the subtitle clock with delay ${delay}`, async () => { + let captured: PluginMiningProvenance | null = null; + const audioRanges: number[][] = []; + const defaults = createCardCreationDeps(); + const service = new CardCreationService( + createCardCreationDeps({ + getConfig: () => createAnkiConfig({ media: { generateAudio: true } }), + getMpvClient: () => ({ ...defaults.getMpvClient()!, currentSubDelay: delay }), + captureMiningProvenance: () => ({ minedAt: 1234, media: null, subtitle: null }), + onNoteCreated: (_id, provenance) => { + captured = provenance; + }, + mediaGenerator: createFakeMediaGenerator({ + generateAudio: async (_path, start, end) => { + audioRanges.push([start, end]); + return Buffer.from('audio'); + }, + }), + }), + ); + assert.equal(await service.createSentenceCard('first\nsecond', 10 + delay, 12 + delay), true); + assert.partialDeepStrictEqual(captured, { + subtitle: { text: 'first\nsecond', startTime: 10, endTime: 12 }, + }); + assert.deepEqual(audioRanges, [[10 + delay, 12 + delay]]); + }); +} + +test('CardCreationService reports a created card ready after its fields are written', async () => { + const events: string[] = []; + const service = new CardCreationService( + createCardCreationDeps({ + client: createFakeClient({ + notesInfo: async () => [{ noteId: 42, fields: { Sentence: { value: 'existing' } } }], + updateNoteFields: async () => { + events.push('update'); + }, + }), + setCardTypeFields: (updatedFields) => { + updatedFields.CardType = 'sentence'; + }, + onCardsReady: (noteIds) => { + events.push(`ready ${noteIds.join(',')}`); + }, + }), + ); + + assert.equal(await service.createSentenceCard('テスト', 0, 1), true); + assert.deepEqual(events, ['update', 'ready 42']); +}); + const THROWING_HOOKS: Array<{ name: string; hook: Partial; diff --git a/src/anki-integration/card-creation.ts b/src/anki-integration/card-creation.ts index 954be4f7..8f5d88e9 100644 --- a/src/anki-integration/card-creation.ts +++ b/src/anki-integration/card-creation.ts @@ -10,8 +10,9 @@ import { type MediaTimingReviewRequest, type WordCardKind, } from '../types/anki'; -import { shiftSubtitleRangeToVideoClock } from '../core/services/subtitle-clock'; +import { shiftSubtitleRangeToVideoClock, toSubtitleClock } from '../core/services/subtitle-clock'; import { createLogger } from '../logger'; +import type { PluginMiningProvenance } from '../plugin-api'; import type { MediaInput } from '../media-input'; import { SubtitleTimingTracker } from '../subtitle-timing-tracker'; import { AiConfig } from '../types/integrations'; @@ -102,6 +103,8 @@ interface CardCreationMediaGenerator { } interface CardCreationDeps { + captureMiningProvenance?: () => PluginMiningProvenance | null; + onNoteCreated?: (noteId: number, provenance: PluginMiningProvenance | null) => void; getConfig: () => AnkiConnectConfig; getAiConfig: () => AiConfig; getTimingTracker: () => SubtitleTimingTracker; @@ -153,6 +156,8 @@ interface CardCreationDeps { trackLastAddedDuplicateNoteIds?: (noteId: number, duplicateNoteIds: number[]) => void; findDuplicateNoteIds?: (expression: string, noteInfo: CardCreationNoteInfo) => Promise; recordCardsMinedCallback?: (count: number, noteIds?: number[]) => void; + /** Called once card creation is done writing a card; queued YouTube media may still be pending. */ + onCardsReady?: (noteIds: number[]) => void; reviewMediaTiming?: ( request: Omit, ) => Promise; @@ -603,6 +608,15 @@ export class CardCreationService { } const maxMediaDuration = this.deps.getConfig().media?.maxMediaDuration ?? 30; + const captured = this.deps.captureMiningProvenance?.() ?? null; + const provenance = captured && { + ...captured, + subtitle: { + text: sentence, + startTime: toSubtitleClock(startTime, mpvClient.currentSubDelay ?? 0), + endTime: toSubtitleClock(endTime, mpvClient.currentSubDelay ?? 0), + }, + }; const cappedEndTime = clampMediaEndTime(startTime, endTime, maxMediaDuration); if (cappedEndTime !== endTime) { log.warn( @@ -611,6 +625,7 @@ export class CardCreationService { endTime = cappedEndTime; } + let createdNoteId: number | null = null; try { return await this.deps.withUpdateProgress('Creating sentence card', async () => { const timingDecision = this.deps.reviewMediaTiming @@ -723,6 +738,8 @@ export class CardCreationService { fields, this.getConfiguredAnkiTags(), ); + createdNoteId = noteId; + this.deps.onNoteCreated?.(noteId, provenance); log.info('Created sentence card:', noteId); } catch (error) { log.error('Failed to create sentence card:', (error as Error).message); @@ -887,6 +904,8 @@ export class CardCreationService { log.error('Error creating sentence card:', (error as Error).message); this.deps.showUpdateResult(`Sentence card failed: ${(error as Error).message}`, false); return false; + } finally { + if (createdNoteId !== null) this.deps.onCardsReady?.([createdNoteId]); } } diff --git a/src/anki-integration/pending-youtube-media-queue.ts b/src/anki-integration/pending-youtube-media-queue.ts index 59802c6d..660ab593 100644 --- a/src/anki-integration/pending-youtube-media-queue.ts +++ b/src/anki-integration/pending-youtube-media-queue.ts @@ -56,6 +56,8 @@ export interface PendingYoutubeMediaQueueDeps { ) => string; showStatusNotification: (message: string) => void; showNotification: (noteId: number, label: string | number, errorSuffix?: string) => Promise; + /** Called once a queued note's media was applied or dropped, so the card is in its final state. */ + onUpdateSettled?: (noteId: number) => void; logInfo: (message: string, ...args: unknown[]) => void; logWarn: (message: string, ...args: unknown[]) => void; logError: (message: string, ...args: unknown[]) => void; @@ -79,9 +81,18 @@ function shouldGenerateImage(config: AnkiConnectConfig): boolean { export class PendingYoutubeMediaQueue { private updates: PendingYoutubeMediaUpdate[] = []; + // Jobs taken off the queue whose media is still being applied or dropped. + private settlingNoteIds = new Set(); constructor(private readonly deps: PendingYoutubeMediaQueueDeps) {} + /** True while the note still waits on queued media. */ + hasPendingNote(noteId: number): boolean { + return ( + this.settlingNoteIds.has(noteId) || this.updates.some((update) => update.noteId === noteId) + ); + } + enqueue(job: PendingYoutubeMediaUpdate): void { if (!job.generateAudio && !job.generateImage) { return; @@ -195,6 +206,8 @@ export class PendingYoutubeMediaQueue { 'Failed to apply queued YouTube media update:', error instanceof Error ? error.message : String(error), ); + } finally { + this.settle(job); } } @@ -234,16 +247,24 @@ export class PendingYoutubeMediaQueue { 'Failed to show queued YouTube media failure notification:', error instanceof Error ? error.message : String(error), ); + } finally { + this.settle(job); } } } + private settle(job: PendingYoutubeMediaUpdate): void { + this.settlingNoteIds.delete(job.noteId); + this.deps.onUpdateSettled?.(job.noteId); + } + private takeMatchingUpdates(sourceUrl: string): PendingYoutubeMediaUpdate[] { const matched: PendingYoutubeMediaUpdate[] = []; const remaining: PendingYoutubeMediaUpdate[] = []; for (const job of this.updates) { if (youtubeMediaUrlsMatch(job.sourceUrl, sourceUrl)) { matched.push(job); + this.settlingNoteIds.add(job.noteId); } else { remaining.push(job); } diff --git a/src/anki-integration/polling.test.ts b/src/anki-integration/polling.test.ts index 550ba2bf..5166f98c 100644 --- a/src/anki-integration/polling.test.ts +++ b/src/anki-integration/polling.test.ts @@ -3,9 +3,10 @@ import test from 'node:test'; import { PollingRunner } from './polling'; -test('polling runner records newly added cards after initialization', async () => { +test('polling runner records new cards and reports each ready after enrichment', async () => { const originalDateNow = Date.now; const recordedCards: number[] = []; + const events: string[] = []; let tracked = new Set(); const responses = [ [10, 11], @@ -16,12 +17,17 @@ test('polling runner records newly added cards after initialization', async () = const runner = new PollingRunner({ getDeck: () => 'Mining', getPollingRate: () => 250, - findNotes: async () => responses.shift() ?? [], + findNotes: async (query) => (query.includes('tag:') ? [] : (responses.shift() ?? [])), shouldAutoUpdateNewCards: () => true, - processNewCard: async () => undefined, + processNewCard: async (noteId) => { + events.push(`process ${noteId}`); + }, recordCardsAdded: (count) => { recordedCards.push(count); }, + onCardsReady: (noteIds) => { + events.push(`ready ${noteIds.join(',')}`); + }, isUpdateInProgress: () => false, setUpdateInProgress: () => undefined, getTrackedNoteIds: () => tracked, @@ -38,7 +44,132 @@ test('polling runner records newly added cards after initialization', async () = await runner.pollOnce(); assert.deepEqual(recordedCards, [2]); + assert.deepEqual(events, ['process 12', 'ready 12', 'process 13', 'ready 13']); } finally { Date.now = originalDateNow; } }); + +test('polling runner keeps going after one note fails to enrich', async () => { + const originalDateNow = Date.now; + const events: string[] = []; + const warnings: unknown[][] = []; + let tracked = new Set(); + const responses = [[10], [10, 12, 13]]; + try { + Date.now = () => 120_000; + const runner = new PollingRunner({ + getDeck: () => undefined, + getPollingRate: () => 250, + findNotes: async (query) => (query.includes('tag:') ? [] : (responses.shift() ?? [])), + shouldAutoUpdateNewCards: () => true, + processNewCard: async (noteId) => { + events.push(`process ${noteId}`); + if (noteId === 12) throw new Error('overlay callback failed'); + }, + onCardsReady: (noteIds) => { + events.push(`ready ${noteIds.join(',')}`); + }, + isUpdateInProgress: () => false, + setUpdateInProgress: () => undefined, + getTrackedNoteIds: () => tracked, + setTrackedNoteIds: (noteIds) => { + tracked = noteIds; + }, + showStatusNotification: () => undefined, + logDebug: () => undefined, + logInfo: () => undefined, + logWarn: (...args) => { + warnings.push(args); + }, + }); + + await runner.pollOnce(); + await runner.pollOnce(); + + assert.deepEqual(events, ['process 12', 'ready 12', 'process 13', 'ready 13']); + assert.deepEqual(warnings, [['Failed to auto-enrich note 12:', 'overlay callback failed']]); + } finally { + Date.now = originalDateNow; + } +}); + +function createStatsHarness(autoUpdate: boolean, sourceLookupFails = false) { + let currentIds: number[] = []; + let tracked = new Set(); + const recorded: Array<{ count: number; noteIds: number[] }> = []; + const processed: number[] = []; + const ready: number[][] = []; + const warnings: unknown[][] = []; + const queries: string[] = []; + const runner = new PollingRunner({ + getDeck: () => 'Mining', + getPollingRate: () => 250, + findNotes: async (query) => { + queries.push(query); + if (!query.includes('tag:')) return currentIds; + if (sourceLookupFails) throw new Error('source lookup failed'); + return [2]; + }, + shouldAutoUpdateNewCards: () => autoUpdate, + processNewCard: async (noteId) => { + processed.push(noteId); + }, + recordCardsAdded: (count, noteIds) => { + recorded.push({ count, noteIds }); + }, + onCardsReady: (noteIds) => { + ready.push(noteIds); + }, + isUpdateInProgress: () => false, + setUpdateInProgress: () => {}, + getTrackedNoteIds: () => tracked, + setTrackedNoteIds: (noteIds) => { + tracked = noteIds; + }, + showStatusNotification: () => assert.fail('source lookup failures must not back off polling'), + logDebug: () => {}, + logInfo: () => {}, + logWarn: (...args) => { + warnings.push(args); + }, + }); + return { + recorded, + processed, + ready, + warnings, + queries, + poll: async (noteIds: number[]) => { + currentIds = noteIds; + await runner.pollOnce(); + }, + }; +} + +for (const autoUpdate of [true, false]) { + test(`stats cards count without triggering plugins when auto-update is ${autoUpdate}`, async () => { + const h = createStatsHarness(autoUpdate); + await h.poll([]); + await h.poll([1, 2]); + assert.deepEqual(h.recorded, [{ count: 2, noteIds: [1, 2] }]); + assert.deepEqual(h.processed, autoUpdate ? [1, 2] : []); + assert.deepEqual(h.ready, [[1]]); + assert.ok(h.queries.includes('nid:1,2 tag:"SubMiner::Stats"')); + assert.deepEqual(h.warnings, []); + }); +} + +test('a failed source lookup skips plugin events without stopping counting or enrichment', async () => { + const h = createStatsHarness(true, true); + await h.poll([]); + await h.poll([1, 2]); + await h.poll([1, 2, 3]); + assert.deepEqual(h.processed, [1, 2, 3]); + assert.deepEqual(h.recorded, [ + { count: 2, noteIds: [1, 2] }, + { count: 1, noteIds: [3] }, + ]); + assert.deepEqual(h.ready, []); + assert.equal(h.warnings.length, 2); +}); diff --git a/src/anki-integration/polling.ts b/src/anki-integration/polling.ts index 32e14e3e..fde0d29a 100644 --- a/src/anki-integration/polling.ts +++ b/src/anki-integration/polling.ts @@ -1,3 +1,5 @@ +import { STATS_MINING_TAG } from '../shared/anki-source'; + export interface PollingRunnerDeps { getDeck: () => string | undefined; getPollingRate: () => number; @@ -10,6 +12,8 @@ export interface PollingRunnerDeps { shouldAutoUpdateNewCards: () => boolean; processNewCard: (noteId: number) => Promise; recordCardsAdded?: (count: number, noteIds: number[]) => void; + /** Called once SubMiner is done with a new note: after enrichment, or at detection when it is off. */ + onCardsReady?: (noteIds: number[]) => void; isUpdateInProgress: () => boolean; setUpdateInProgress: (value: boolean) => void; getTrackedNoteIds: () => Set; @@ -83,11 +87,39 @@ export class PollingRunner { this.deps.setTrackedNoteIds(previousNoteIds); this.deps.recordCardsAdded?.(newNoteIds.length, newNoteIds); + // Stats cards still count and update known words, but do not trigger plugins. + const pluginNoteIds = new Set(); + if (this.deps.onCardsReady) { + try { + const statsNoteIds = new Set( + await this.deps.findNotes(`nid:${newNoteIds.join(',')} tag:"${STATS_MINING_TAG}"`, { + maxRetries: 0, + }), + ); + for (const noteId of newNoteIds) { + if (!statsNoteIds.has(noteId)) pluginNoteIds.add(noteId); + } + } catch (error) { + this.deps.logWarn( + 'Skipping plugin events because note sources could not be checked:', + error, + ); + } + } + if (this.deps.shouldAutoUpdateNewCards()) { for (const noteId of newNoteIds) { - await this.deps.processNewCard(noteId); + try { + await this.deps.processNewCard(noteId); + } catch (error) { + // One failed note must not leave the rest of the batch unprocessed. + this.deps.logWarn(`Failed to auto-enrich note ${noteId}:`, (error as Error).message); + } finally { + if (pluginNoteIds.has(noteId)) this.deps.onCardsReady?.([noteId]); + } } } else { + if (pluginNoteIds.size > 0) this.deps.onCardsReady?.([...pluginNoteIds]); this.deps.logInfo( 'New card detected (auto-update disabled). Press Ctrl+V to update from clipboard.', ); diff --git a/src/cli/args.test.ts b/src/cli/args.test.ts index 4f5e6143..fedbe1e5 100644 --- a/src/cli/args.test.ts +++ b/src/cli/args.test.ts @@ -340,6 +340,14 @@ test('hasExplicitCommand and shouldStartApp preserve command intent', () => { assert.equal(shouldStartApp(sessionAction), true); assert.equal(commandNeedsOverlayRuntime(sessionAction), true); + const pluginAction = parseArgs(['--plugin-action', 'anime/open']); + assert.equal(pluginAction.pluginAction, 'anime/open'); + assert.equal(parseArgs(['--plugin-action=anime/open']).pluginAction, 'anime/open'); + assert.equal(hasExplicitCommand(pluginAction), true); + assert.equal(shouldStartApp(pluginAction), true); + // The plugin decides what it needs; a window-only action must not force the overlay up. + assert.equal(commandNeedsOverlayRuntime(pluginAction), false); + const toggleStatsOverlayRuntime = parseArgs(['--toggle-stats-overlay']); assert.equal(commandNeedsOverlayRuntime(toggleStatsOverlayRuntime), true); diff --git a/src/cli/args.ts b/src/cli/args.ts index da3579a2..f81c6bd3 100644 --- a/src/cli/args.ts +++ b/src/cli/args.ts @@ -48,6 +48,8 @@ export interface CliArgs { cycleRuntimeOptionId?: string; cycleRuntimeOptionDirection?: 1 | -1; sessionAction?: SessionActionDispatchRequest; + /** `/` to run, like a `__plugin-action:` keybinding. */ + pluginAction?: string; copySubtitleCount?: number; mineSentenceCount?: number; anilistStatus: boolean; @@ -338,6 +340,12 @@ export function parseArgs(argv: string[]): CliArgs { } else if (arg === '--session-action') { const parsed = parseSessionAction(readValue(argv[i + 1])); if (parsed) args.sessionAction = parsed; + } else if (arg.startsWith('--plugin-action=')) { + const value = arg.slice('--plugin-action='.length).trim(); + if (value) args.pluginAction = value; + } else if (arg === '--plugin-action') { + const value = readValue(argv[i + 1])?.trim(); + if (value) args.pluginAction = value; } else if (arg.startsWith('--copy-subtitle-count=')) { const value = Number(arg.split('=', 2)[1]); if (Number.isInteger(value) && value > 0) args.copySubtitleCount = value; @@ -601,6 +609,7 @@ export function hasExplicitCommand(args: CliArgs): boolean { args.playbackFeedback !== undefined || args.cycleRuntimeOptionId !== undefined || args.sessionAction !== undefined || + args.pluginAction !== undefined || args.copySubtitleCount !== undefined || args.mineSentenceCount !== undefined || args.anilistStatus || @@ -682,6 +691,7 @@ export function isStandaloneTexthookerCommand(args: CliArgs): boolean { args.playbackFeedback === undefined && args.cycleRuntimeOptionId === undefined && args.sessionAction === undefined && + args.pluginAction === undefined && args.copySubtitleCount === undefined && args.mineSentenceCount === undefined && !args.anilistStatus && @@ -752,6 +762,7 @@ export function shouldStartApp(args: CliArgs): boolean { args.playbackFeedback !== undefined || args.cycleRuntimeOptionId !== undefined || args.sessionAction !== undefined || + args.pluginAction !== undefined || args.copySubtitleCount !== undefined || args.mineSentenceCount !== undefined || args.dictionary || @@ -819,6 +830,7 @@ export function shouldRunDictionarySettingsOnlyStartup(args: CliArgs): boolean { args.playbackFeedback === undefined && args.cycleRuntimeOptionId === undefined && args.sessionAction === undefined && + args.pluginAction === undefined && args.copySubtitleCount === undefined && args.mineSentenceCount === undefined && !args.anilistStatus && diff --git a/src/cli/help.ts b/src/cli/help.ts index b3223e76..a7af588d 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -46,6 +46,7 @@ ${B}Mining${R} --open-session-help Open session help modal --open-controller-select Open controller select modal --open-controller-debug Open controller debug modal + --plugin-action ${D}/${R} Run a plugin action ${B}AniList${R} --anilist-setup Open AniList authentication flow diff --git a/src/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/cli-command.test.ts b/src/core/services/cli-command.test.ts index f935ae4c..fc1b8689 100644 --- a/src/core/services/cli-command.test.ts +++ b/src/core/services/cli-command.test.ts @@ -737,6 +737,23 @@ test('handleCliCommand dispatches generic session action payloads', async () => }); }); +test('handleCliCommand runs plugin actions through the session action dispatcher', async () => { + let request: unknown = null; + const { deps } = createDeps({ + dispatchSessionAction: async (nextRequest) => { + request = nextRequest; + }, + }); + + handleCliCommand(makeArgs({ pluginAction: 'anime/open' }), 'initial', deps); + await new Promise((resolve) => setImmediate(resolve)); + + assert.deepEqual(request, { + actionId: 'runPluginAction', + payload: { pluginActionId: 'anime/open' }, + }); +}); + test('handleCliCommand dispatches mark-watched session action', async () => { let request: unknown = null; const { deps } = createDeps({ diff --git a/src/core/services/cli-command.ts b/src/core/services/cli-command.ts index d91e1bd9..17e9bbd2 100644 --- a/src/core/services/cli-command.ts +++ b/src/core/services/cli-command.ts @@ -422,6 +422,12 @@ export function handleCliCommand( `sessionAction:${args.sessionAction.actionId}`, 'Session action failed', ); + } else if (args.pluginAction) { + dispatchCliSessionAction( + { actionId: 'runPluginAction', payload: { pluginActionId: args.pluginAction } }, + `pluginAction:${args.pluginAction}`, + 'Plugin action failed', + ); } else if (args.toggle || args.toggleVisibleOverlay) { deps.toggleVisibleOverlay(); } else if (args.togglePrimarySubtitleBar) { diff --git a/src/core/services/ipc.test.ts b/src/core/services/ipc.test.ts index e63bdc9c..0f4c0ea2 100644 --- a/src/core/services/ipc.test.ts +++ b/src/core/services/ipc.test.ts @@ -576,8 +576,8 @@ test('registerIpcHandlers ignores malformed fire-and-forget payloads', () => { const openedModals: unknown[] = []; registerIpcHandlers( { - onOverlayModalClosed: (modal) => { - closedModals.push(modal); + onOverlayModalClosed: (modal, _senderWindow, instanceId) => { + closedModals.push(instanceId === undefined ? modal : [modal, instanceId]); }, onOverlayModalOpened: (modal) => { openedModals.push(modal); @@ -638,7 +638,9 @@ test('registerIpcHandlers ignores malformed fire-and-forget payloads', () => { handlers.on.get(IPC_CHANNELS.command.overlayModalClosed)!({}, 'not-a-modal'); handlers.on.get(IPC_CHANNELS.command.overlayModalClosed)!({}, 'subsync'); handlers.on.get(IPC_CHANNELS.command.overlayModalClosed)!({}, 'kiku'); - assert.deepEqual(closedModals, ['subsync', 'kiku']); + handlers.on.get(IPC_CHANNELS.command.overlayModalClosed)!({}, 'plugin-panel', 4); + handlers.on.get(IPC_CHANNELS.command.overlayModalClosed)!({}, 'plugin-panel', 'bad'); + assert.deepEqual(closedModals, ['subsync', 'kiku', ['plugin-panel', 4], 'plugin-panel']); handlers.on.get(IPC_CHANNELS.command.overlayModalOpened)!({}, 'bad'); handlers.on.get(IPC_CHANNELS.command.overlayModalOpened)!({}, 'subsync'); diff --git a/src/core/services/ipc.ts b/src/core/services/ipc.ts index ae9cc6f8..4e22d4cd 100644 --- a/src/core/services/ipc.ts +++ b/src/core/services/ipc.ts @@ -50,6 +50,7 @@ export interface IpcServiceDeps { onOverlayModalClosed: ( modal: OverlayHostedModal, senderWindow: ElectronBrowserWindow | null, + instanceId?: number, ) => void; onOverlayModalOpened?: ( modal: OverlayHostedModal, @@ -317,6 +318,7 @@ export interface IpcDepsRuntimeOptions { onOverlayModalClosed: ( modal: OverlayHostedModal, senderWindow: ElectronBrowserWindow | null, + instanceId?: number, ) => void; onOverlayModalOpened?: ( modal: OverlayHostedModal, @@ -527,13 +529,20 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar }, ); - ipc.on(IPC_CHANNELS.command.overlayModalClosed, (event: unknown, modal: unknown) => { - const parsedModal = parseOverlayHostedModal(modal); - if (!parsedModal) return; - const senderWindow = - electron.BrowserWindow?.fromWebContents((event as IpcMainEvent).sender) ?? null; - deps.onOverlayModalClosed(parsedModal, senderWindow); - }); + ipc.on( + IPC_CHANNELS.command.overlayModalClosed, + (event: unknown, modal: unknown, instanceId: unknown) => { + const parsedModal = parseOverlayHostedModal(modal); + if (!parsedModal) return; + const senderWindow = + electron.BrowserWindow?.fromWebContents((event as IpcMainEvent).sender) ?? null; + deps.onOverlayModalClosed( + parsedModal, + senderWindow, + Number.isInteger(instanceId) ? (instanceId as number) : undefined, + ); + }, + ); ipc.on(IPC_CHANNELS.command.overlayModalOpened, (event: unknown, modal: unknown) => { const parsedModal = parseOverlayHostedModal(modal); if (!parsedModal) return; diff --git a/src/core/services/mpv-protocol.test.ts b/src/core/services/mpv-protocol.test.ts index 490038d1..2cd47155 100644 --- a/src/core/services/mpv-protocol.test.ts +++ b/src/core/services/mpv-protocol.test.ts @@ -486,3 +486,19 @@ test('dispatchMpvProtocolMessage emits client-message string args', async () => assert.deepEqual(received, [{ args: ['subminer-skip-intro', 'extra'] }]); }); + +test('dispatchMpvProtocolMessage emits end-file with reason, entry id, and error', async () => { + const received: unknown[] = []; + const { deps } = createDeps({ emitEndFile: (payload) => received.push(payload) }); + + await dispatchMpvProtocolMessage( + { event: 'end-file', reason: 'error', playlist_entry_id: 3, file_error: 'loading failed' }, + deps, + ); + await dispatchMpvProtocolMessage({ event: 'end-file', reason: 'something-new' }, deps); + + assert.deepEqual(received, [ + { reason: 'error', playlistEntryId: 3, error: 'loading failed' }, + { reason: 'unknown', playlistEntryId: null, error: null }, + ]); +}); diff --git a/src/core/services/mpv-protocol.ts b/src/core/services/mpv-protocol.ts index d0bbb7bb..897fbf3c 100644 --- a/src/core/services/mpv-protocol.ts +++ b/src/core/services/mpv-protocol.ts @@ -1,6 +1,7 @@ import { MpvSubtitleRenderMetrics } from '../../types'; import { sanitizeMediaTitle } from '../../shared/media-identity'; import { widenHalfwidthKana } from '../utils/halfwidth-kana'; +import type { PluginMpvEventMap } from '../../plugin-api'; import { toVideoClock } from './subtitle-clock'; export type MpvMessage = { @@ -10,8 +11,26 @@ export type MpvMessage = { args?: unknown; request_id?: number; error?: string; + reason?: unknown; + playlist_entry_id?: unknown; + file_error?: unknown; }; +const MPV_END_FILE_REASONS = ['eof', 'stop', 'quit', 'error', 'redirect'] as const; +export type MpvEndFileReason = PluginMpvEventMap['end-file']['reason']; + +/** mpv's `end-file` event: why the current file stopped, and mpv's error text when it failed. */ +export type MpvEndFileEvent = PluginMpvEventMap['end-file']; + +function parseEndFileEvent(msg: MpvMessage): MpvEndFileEvent { + const reason = MPV_END_FILE_REASONS.find((known) => known === msg.reason) ?? 'unknown'; + return { + reason, + playlistEntryId: typeof msg.playlist_entry_id === 'number' ? msg.playlist_entry_id : null, + error: typeof msg.file_error === 'string' && msg.file_error ? msg.file_error : null, + }; +} + export const MPV_REQUEST_ID_SUBTEXT = 101; export const MPV_REQUEST_ID_PATH = 102; export const MPV_REQUEST_ID_SECONDARY_SUBTEXT = 103; @@ -103,6 +122,8 @@ export interface MpvProtocolHandleMessageDeps { shouldQuitOnMpvShutdown: () => boolean; requestAppQuit: () => void; emitClientMessage?: (payload: { args: string[] }) => void; + emitEndFile?: (payload: MpvEndFileEvent) => void; + emitFileLoaded?: () => void; } type SubtitleTrackCandidate = { @@ -433,6 +454,10 @@ export async function dispatchMpvProtocolMessage( if (args.length > 0) { deps.emitClientMessage?.({ args }); } + } else if (msg.event === 'file-loaded') { + deps.emitFileLoaded?.(); + } else if (msg.event === 'end-file') { + deps.emitEndFile?.(parseEndFileEvent(msg)); } else if (msg.event === 'shutdown') { deps.restorePreviousSecondarySubVisibility(); if (deps.shouldQuitOnMpvShutdown()) { diff --git a/src/core/services/mpv.ts b/src/core/services/mpv.ts index c74d596d..6993dec9 100644 --- a/src/core/services/mpv.ts +++ b/src/core/services/mpv.ts @@ -1,3 +1,4 @@ +import type { PluginMpvEventMap } from '../../plugin-api'; import { EventEmitter } from 'events'; import { Config, MpvClient, MpvSubtitleRenderMetrics } from '../../types'; import { @@ -120,24 +121,8 @@ export interface MpvIpcClientProtocolDeps { export interface MpvIpcClientDeps extends MpvIpcClientProtocolDeps {} -export interface MpvIpcClientEventMap { - 'connection-change': { connected: boolean }; - 'subtitle-change': { text: string; isOverlayVisible: boolean }; - 'subtitle-timing': { text: string; start: number; end: number }; - 'time-pos-change': { time: number }; - 'duration-change': { duration: number }; - 'pause-change': { paused: boolean }; - 'fullscreen-change': { fullscreen: boolean }; - 'secondary-subtitle-change': { text: string }; - 'subtitle-track-change': { sid: number | null }; - 'secondary-subtitle-track-change': { sid: number | null }; - 'secondary-subtitle-delay-change': { delay: number }; - 'subtitle-track-list-change': { trackList: unknown[] | null }; - 'media-path-change': { path: string }; - 'media-title-change': { title: string | null }; +export interface MpvIpcClientEventMap extends PluginMpvEventMap { 'subtitle-metrics-change': { patch: Partial }; - 'secondary-subtitle-visibility': { visible: boolean }; - 'client-message': { args: string[] }; } type MpvIpcClientEventName = keyof MpvIpcClientEventMap; @@ -525,6 +510,10 @@ export class MpvIpcClient implements MpvClient { emitClientMessage: (payload) => { this.emit('client-message', payload); }, + emitEndFile: (payload) => { + this.emit('end-file', payload); + }, + emitFileLoaded: () => this.emit('file-loaded', undefined), }; } @@ -604,6 +593,14 @@ export class MpvIpcClient implements MpvClient { return response.data; } + async requestCommand(command: (string | number | boolean)[]): Promise { + const response = await this.request(command); + if (response.error && response.error !== 'success') { + throw new Error(`MPV command failed: ${response.error}`); + } + return response.data; + } + private failPendingRequests(): void { for (const [requestId, resolve] of this.pendingRequests.entries()) { resolve({ request_id: requestId, error: 'disconnected' }); diff --git a/src/core/services/plugins/exec.test.ts b/src/core/services/plugins/exec.test.ts new file mode 100644 index 00000000..aa0d381a --- /dev/null +++ b/src/core/services/plugins/exec.test.ts @@ -0,0 +1,137 @@ +import assert from 'node:assert/strict'; +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; +import { setTimeout as sleep } from 'node:timers/promises'; + +import { execPluginCommand, resolvePluginSpawnTarget } 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('force-kills a child that ignores SIGTERM after the grace period', async (t) => { + if (process.platform === 'win32') return t.skip('POSIX signals only'); + const pidFile = path.join(await mkdtemp(path.join(os.tmpdir(), 'plugin-exec-')), 'pid'); + const controller = new AbortController(); + const pending = execPluginCommand( + runtime, + [ + '-e', + `process.on("SIGTERM", () => {}); require("node:fs").writeFileSync(${JSON.stringify(pidFile)}, String(process.pid)); setInterval(() => {}, 1000);`, + ], + { signal: controller.signal }, + 100, + ); + + let pid = 0; + for (let i = 0; i < 100 && !pid; i += 1) { + pid = Number(await readFile(pidFile, 'utf8').catch(() => '0')); + if (!pid) await sleep(20); + } + assert.ok(pid > 0, 'child never reported its pid'); + try { + controller.abort(); + await assert.rejects(pending, { name: 'AbortError' }); + let alive = true; + for (let i = 0; i < 100 && alive; i += 1) { + await sleep(20); + alive = isRunning(pid); + } + assert.equal(alive, false, 'child survived SIGTERM'); + } finally { + if (isRunning(pid)) process.kill(pid, 'SIGKILL'); + await rm(path.dirname(pidFile), { recursive: true, force: true }); + } +}); + +function isRunning(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} + +test('rejects when the binary does not exist', async () => { + await assert.rejects( + execPluginCommand('subminer-definitely-missing-binary', []), + (error: NodeJS.ErrnoException) => error.code === 'ENOENT', + ); +}); + +test('Windows batch shims run through cmd.exe; other commands spawn directly', async () => { + const dir = await mkdtemp(path.join(os.tmpdir(), 'plugin-exec-shim-')); + try { + await writeFile(path.join(dir, 'codex.cmd'), ''); + await writeFile(path.join(dir, 'native.exe'), ''); + const env = { PATH: dir, PATHEXT: '.exe;.cmd', ComSpec: 'cmd.exe' }; + + const shim = resolvePluginSpawnTarget('codex', ['exec', '-'], env, 'win32'); + assert.equal(shim.viaShell, true); + assert.equal(shim.file, 'cmd.exe'); + assert.deepEqual(shim.args.slice(0, 4), ['/d', '/s', '/v:off', '/c']); + assert.ok(shim.args[4]!.includes(path.join(dir, 'codex.cmd'))); + + assert.deepEqual(resolvePluginSpawnTarget('native', ['-v'], env, 'win32'), { + file: path.join(dir, 'native.exe'), + args: ['-v'], + viaShell: false, + }); + assert.deepEqual(resolvePluginSpawnTarget('codex', ['-'], env, 'linux'), { + file: 'codex', + args: ['-'], + viaShell: false, + }); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); diff --git a/src/core/services/plugins/exec.ts b/src/core/services/plugins/exec.ts new file mode 100644 index 00000000..b2e8d88e --- /dev/null +++ b/src/core/services/plugins/exec.ts @@ -0,0 +1,134 @@ +import { type ChildProcess, spawn } from 'node:child_process'; + +import { buildSystemBinaryEnv } from '../../utils/system-binary-env'; +import { + buildWindowsShimInvocation, + isWindowsCommandShim, + resolveWindowsCommand, +} from '../../utils/windows-command-shim'; +import type { PluginExecOptions, PluginExecResult } from './types'; + +const MAX_CAPTURED_OUTPUT_CHARS = 4 * 1024 * 1024; +const DEFAULT_KILL_GRACE_MS = 3_000; + +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); +} + +interface SpawnTarget { + file: string; + args: string[]; + viaShell: boolean; +} + +/** + * Windows batch shims (npm installs `codex` as `codex.cmd`) only run through cmd.exe. Native + * binaries, and every command on Linux and macOS, are spawned directly. + */ +export function resolvePluginSpawnTarget( + command: string, + args: string[], + env: NodeJS.ProcessEnv, + platform: NodeJS.Platform = process.platform, +): SpawnTarget { + if (platform !== 'win32') return { file: command, args, viaShell: false }; + const resolved = resolveWindowsCommand(command, env); + if (!isWindowsCommandShim(resolved)) return { file: resolved, args, viaShell: false }; + return { ...buildWindowsShimInvocation(resolved, args, env), viaShell: true }; +} + +// Killing cmd.exe alone would leave the shim's own child running, so end the whole tree. +function killWindowsProcessTree(child: ChildProcess): void { + if (child.pid === undefined) return; + spawn('taskkill', ['/pid', String(child.pid), '/T', '/F'], { + stdio: 'ignore', + windowsHide: true, + }).on('error', () => child.kill()); +} + +/** + * Runs `command` (no shell, except cmd.exe for Windows batch shims) and collects its output. Resolves with the exit code on any exit, + * including non-zero; rejects when the binary cannot start, the timeout passes, or `signal` aborts. + * A timed-out or aborted child gets SIGTERM, then SIGKILL after `killGraceMs` (a shim's + * process tree is ended with taskkill instead). + */ +export function execPluginCommand( + command: string, + args: string[], + options: PluginExecOptions = {}, + killGraceMs = DEFAULT_KILL_GRACE_MS, +): Promise { + return new Promise((resolve, reject) => { + if (options.signal?.aborted) { + reject(new DOMException('Command aborted.', 'AbortError')); + return; + } + + const env = { ...buildSystemBinaryEnv(), ...options.env }; + const target = resolvePluginSpawnTarget(command, args, env); + const child = spawn(target.file, target.args, { + cwd: options.cwd, + env, + stdio: ['pipe', 'pipe', 'pipe'], + windowsHide: true, + windowsVerbatimArguments: target.viaShell, + }); + + 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(); + }; + + // Rejects right away, then escalates to SIGKILL if the child ignores SIGTERM. + const killWith = (error: Error): void => { + settle(() => reject(error)); + if (target.viaShell) { + killWindowsProcessTree(child); + return; + } + child.kill('SIGTERM'); + const forceKill = setTimeout(() => { + if (child.exitCode === null && child.signalCode === null) child.kill('SIGKILL'); + }, killGraceMs); + forceKill.unref(); + child.once('exit', () => clearTimeout(forceKill)); + }; + + 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..350cf7f7 --- /dev/null +++ b/src/core/services/plugins/manifest.ts @@ -0,0 +1,57 @@ +import path from 'node:path'; + +import { PLUGIN_API_VERSION, 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.' }; + } + const apiVersion = value.apiVersion === undefined ? 1 : value.apiVersion; + if (typeof apiVersion !== 'number' || !Number.isInteger(apiVersion) || apiVersion < 1) { + return { error: 'plugin.json "apiVersion" must be a positive integer.' }; + } + if (apiVersion !== PLUGIN_API_VERSION) { + return { + error: `Plugin requires API ${apiVersion}; this SubMiner supports API ${PLUGIN_API_VERSION}.`, + }; + } + return { + manifest: { + name: typeof value.name === 'string' && value.name.trim() ? value.name.trim() : fallbackName, + version: typeof value.version === 'string' ? value.version : null, + apiVersion, + main: value.main.trim(), + }, + }; +} + +/** Resolves a plugin file (entry, window HTML, preload), rejecting paths that leave the plugin directory. */ +export function resolvePluginFilePath(pluginDir: string, file: string): string | null { + const resolved = path.resolve(pluginDir, file); + const relative = path.relative(pluginDir, resolved); + if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) { + return null; + } + return resolved; +} diff --git a/src/core/services/plugins/media-state.ts b/src/core/services/plugins/media-state.ts new file mode 100644 index 00000000..f5dbd82b --- /dev/null +++ b/src/core/services/plugins/media-state.ts @@ -0,0 +1,46 @@ +import type { PluginMediaSnapshot } from './types'; + +/** Keeps plugin snapshots coherent without asynchronous property reads crossing file changes. */ +export function createPluginMediaState(onChange: (media: PluginMediaSnapshot | null) => void) { + let nextId = 0; + let current: PluginMediaSnapshot | null = null; + let waitingForLoad = false; + + function publish(media: PluginMediaSnapshot | null): void { + current = media && Object.freeze(media); + onChange(current); + } + + function start(path: string, title: string | null = null): void { + publish({ playbackId: ++nextId, path, title }); + } + + return { + current: () => current, + reset(path = '', title: string | null = null): void { + waitingForLoad = false; + if (path) start(path, title); + else if (current) publish(null); + }, + pathChanged(path: string): void { + if (!path) { + this.reset(); + } else if (path !== current?.path) { + waitingForLoad = true; + start(path); + } + }, + titleChanged(title: string | null): void { + if (current && title !== current.title) publish({ ...current, title }); + }, + fileLoaded(path: string, title: string | null): void { + if (!path) return; + if (waitingForLoad && current?.path === path) { + this.titleChanged(title); + } else { + start(path, title); + } + waitingForLoad = false; + }, + }; +} 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..3c2ac623 --- /dev/null +++ b/src/core/services/plugins/plugin-host.test.ts @@ -0,0 +1,791 @@ +import assert from 'node:assert/strict'; +import { EventEmitter } from 'node:events'; +import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; + +import type { BrowserWindow } from 'electron'; + +import type { SessionActionDispatchRequest } from '../../../types/runtime'; +import { createPluginHost, type PluginHostDeps } from './plugin-host'; +import type { + PluginConfigEntry, + PluginContext, + PluginLogger, + PluginMpvEventName, + PluginMpvEventMap, + PluginWindowRequest, +} 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); + } +} + +type FakeWindow = ReturnType; + +/** The slice of BrowserWindow the host touches: destroy, isDestroyed, and the `closed` event. */ +function createFakeWindow() { + const events = new EventEmitter(); + const fake = { + destroyed: false, + isDestroyed: () => fake.destroyed, + destroy: () => { + fake.destroyed = true; + events.emit('closed'); + }, + once: (event: string, listener: () => void) => events.once(event, listener), + asWindow: () => fake as unknown as BrowserWindow, + }; + return fake; +} + +function createHarness( + config: Record, + overrides: Partial> = {}, +) { + const root = mkdtempSync(path.join(os.tmpdir(), 'subminer-plugins-')); + const errors: string[] = []; + const notifications: string[] = []; + const sessionActions: SessionActionDispatchRequest[] = []; + const windowRequests: PluginWindowRequest[] = []; + const windows = new Map(); + const logger: PluginLogger = { + debug: () => {}, + info: () => {}, + warn: () => {}, + error: (message) => errors.push(message), + }; + const deps: PluginHostDeps<{ marker: string }> = { + pluginsDir: path.join(root, 'plugins'), + pluginDataDir: path.join(root, 'data'), + getPluginConfig: () => config, + createLogger: () => logger, + isMpvConnected: () => true, + sendMpvCommand: () => {}, + requestMpvProperty: async () => null, + requestMpvCommand: async () => null, + ensureMpvRunning: async () => true, + invokeAnkiConnect: async () => null, + dispatchSessionAction: async (request) => { + sessionActions.push(request); + }, + getCurrentSubtitle: () => null, + getSubtitleContext: () => ({ current: null, before: [], after: [] }), + notify: (message) => notifications.push(message), + openPanel: () => { + throw new Error('not used'); + }, + openWindow: (request) => { + windowRequests.push(request); + const existing = windows.get(request.key); + if (existing && !existing.destroyed) return { window: existing.asWindow(), created: false }; + const window = createFakeWindow(); + windows.set(request.key, window); + return { window: window.asWindow(), created: true }; + }, + unstable: { marker: 'internals' }, + ...overrides, + }; + return { + root: deps.pluginsDir, + dataRoot: deps.pluginDataDir, + errors, + notifications, + sessionActions, + windowRequests, + windows, + host: createPluginHost(deps), + cleanup: () => rmSync(root, { recursive: true, force: true }), + }; +} + +const enabled = (settings: Record = {}): PluginConfigEntry => ({ + enabled: true, + settings, +}); + +function deferred() { + let resolve = () => {}; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve: () => resolve() }; +} + +function createFakeMpvClient() { + const bus = new EventEmitter(); + return { + on: ( + event: E, + listener: (payload: PluginMpvEventMap[E]) => void, + ) => bus.on(event, listener), + off: ( + event: E, + listener: (payload: PluginMpvEventMap[E]) => void, + ) => bus.off(event, listener), + emit: (event: E, payload: PluginMpvEventMap[E]) => + 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(await h.host.runAction('greeter/say'), true); + assert.deepEqual(h.notifications, ['hi internals']); + assert.equal(await 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(await 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(await h.host.runAction('broken/half'), false); + assert.equal(await h.host.runAction('healthy/ok'), true); + assert.deepEqual(h.errors, ['Activation failed: boom']); + } finally { + h.cleanup(); + } +}); + +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('unload gives up on a deactivate that never settles and still disposes the plugin', async () => { + const h = createHarness({ stuck: enabled() }, { deactivateTimeoutMs: 20 }); + const client = createFakeMpvClient(); + try { + writePlugin(h.root, 'stuck', { + 'plugin.json': JSON.stringify({ main: 'main.cjs' }), + 'main.cjs': `exports.activate = (ctx) => { + ctx.mpv.on('pause-change', () => ctx.notify('still listening')); + return () => new Promise(() => {}); + };`, + }); + h.host.attachMpvClient(client); + await h.host.loadAll(); + + await h.host.unloadAll(); + client.emit('pause-change', { paused: true }); + + assert.deepEqual(h.notifications, []); + // Deactivate gets what is left of the shared unload budget, so the reported ms can be below 20. + assert.equal(h.errors.length, 1); + assert.match(h.errors[0]!, /^deactivate failed: timed out after \d+ ms$/); + } 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(); + } +}); + +test('unload skips modules whose manifest is still being read and prevents later loads', async () => { + const h = createHarness({ late: enabled() }); + const client = createFakeMpvClient(); + try { + writePlugin(h.root, 'late', { + 'plugin.json': JSON.stringify({ main: 'main.cjs' }), + 'main.cjs': `throw new Error('module must not execute after unload starts');`, + }); + h.host.attachMpvClient(client); + + // The plugin is still reading its manifest when unload starts. + const loading = h.host.loadAll(); + await h.host.unloadAll(); + await loading; + await h.host.loadAll(); + client.emit('pause-change', { paused: true }); + + assert.deepEqual(h.notifications, []); + assert.deepEqual(h.errors, []); + } finally { + h.cleanup(); + } +}); + +test('a plugin that finishes activating after unload gave up unloads itself', async () => { + const started = deferred(); + const resume = deferred(); + const cleaned = deferred(); + let deactivated = 0; + const h = createHarness( + { slow: enabled() }, + { + deactivateTimeoutMs: 20, + loadModule: () => ({ + activate: async (ctx) => { + started.resolve(); + await resume.promise; + ctx.mpv.on('pause-change', () => ctx.notify('still listening')); + return async () => { + deactivated++; + await Promise.resolve(); + cleaned.resolve(); + }; + }, + }), + }, + ); + const client = createFakeMpvClient(); + try { + writePlugin(h.root, 'slow', { + 'plugin.json': JSON.stringify({ main: 'main.cjs' }), + }); + h.host.attachMpvClient(client); + + const loading = h.host.loadAll(); + await started.promise; + await h.host.unloadAll(); + resume.resolve(); + await loading; + await cleaned.promise; + await h.host.unloadAll(); + client.emit('pause-change', { paused: true }); + + assert.equal(deactivated, 1); + assert.deepEqual(h.notifications, []); + assert.deepEqual(h.errors, []); + } finally { + h.cleanup(); + } +}); + +test('unload awaits asynchronous cleanup once when activation finishes during unload', async () => { + const started = deferred(); + const resume = deferred(); + const deactivating = deferred(); + const finish = deferred(); + let deactivated = 0; + const h = createHarness( + { slow: enabled() }, + { + loadModule: () => ({ + activate: async (ctx) => { + started.resolve(); + await resume.promise; + return async () => { + deactivated++; + deactivating.resolve(); + await finish.promise; + await ctx.anki.invoke('sync'); + ctx.notify('cleaned'); + }; + }, + }), + }, + ); + try { + writePlugin(h.root, 'slow', { 'plugin.json': '{"main":"main.cjs"}' }); + const loading = h.host.loadAll(); + await started.promise; + let unloaded = false; + const unloading = Promise.all([h.host.unloadAll(), h.host.unloadAll()]).then(() => { + unloaded = true; + }); + resume.resolve(); + await deactivating.promise; + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(unloaded, false); + assert.equal(deactivated, 1); + finish.resolve(); + await unloading; + await loading; + assert.deepEqual(h.notifications, ['cleaned']); + assert.deepEqual(h.errors, []); + } finally { + finish.resolve(); + resume.resolve(); + await h.host.unloadAll(); + h.cleanup(); + } +}); + +test('a timed-out plugin cannot call app APIs after disposal', async () => { + let context: PluginContext | undefined; + const calls: string[] = []; + const h = createHarness( + { stuck: enabled() }, + { + activateTimeoutMs: 10, + loadModule: () => ({ + activate: (ctx) => { + context = ctx; + return new Promise(() => {}); + }, + }), + sendMpvCommand: () => { + calls.push('command'); + }, + requestMpvCommand: async () => { + calls.push('request'); + }, + requestMpvProperty: async () => { + calls.push('property'); + }, + ensureMpvRunning: async () => { + calls.push('start'); + return true; + }, + invokeAnkiConnect: async () => { + calls.push('anki'); + }, + }, + ); + try { + writePlugin(h.root, 'stuck', { 'plugin.json': '{"main":"main.cjs"}' }); + await h.host.loadAll(); + assert.ok(context); + const ctx = context; + assert.equal(ctx.signal.aborted, true); + assert.throws(() => ctx.mpv.command(['stop']), /disposed/); + assert.throws(() => ctx.notify('late'), /disposed/); + await assert.rejects(ctx.mpv.request(['stop']), /disposed/); + await assert.rejects(ctx.mpv.getProperty('path'), /disposed/); + await assert.rejects(ctx.mpv.ensureRunning(), /disposed/); + await assert.rejects(ctx.anki.invoke('sync'), /disposed/); + await assert.rejects(ctx.actions.run('openJimaku'), /disposed/); + assert.deepEqual(calls, []); + assert.deepEqual(h.notifications, []); + assert.deepEqual(h.sessionActions, []); + } finally { + await h.host.unloadAll(); + h.cleanup(); + } +}); + +test('runAction waits for a load that is still in progress', async () => { + const h = createHarness({ slow: enabled() }); + try { + writePlugin(h.root, 'slow', { + 'plugin.json': JSON.stringify({ main: 'main.cjs' }), + 'main.cjs': `exports.activate = async (ctx) => { + await new Promise((resolve) => setTimeout(resolve, 20)); + ctx.actions.register('go', () => ctx.notify('went')); + };`, + }); + + void h.host.loadAll(); + + assert.equal(await h.host.runAction('slow/go'), true); + assert.deepEqual(h.notifications, ['went']); + } finally { + h.cleanup(); + } +}); + +test('a stuck activation cannot block another plugin and times out its own registrations', async () => { + const h = createHarness({ stuck: enabled(), healthy: enabled() }, { activateTimeoutMs: 30 }); + try { + writePlugin(h.root, 'stuck', { + 'plugin.json': JSON.stringify({ main: 'main.cjs' }), + 'main.cjs': `exports.activate = (ctx) => { + ctx.actions.register('go', () => ctx.notify('stuck ran')); + return new Promise(() => {}); + };`, + }); + writePlugin(h.root, 'healthy', { + 'plugin.json': JSON.stringify({ main: 'main.cjs' }), + 'main.cjs': `exports.activate = (ctx) => ctx.actions.register('go', () => ctx.notify('healthy ran'));`, + }); + const loading = h.host.loadAll(); + assert.equal(await h.host.runAction('healthy/go'), true); + assert.deepEqual(h.notifications, ['healthy ran']); + assert.deepEqual(h.errors, []); + await loading; + assert.equal(await h.host.runAction('stuck/go'), false); + assert.match(h.errors[0]!, /timed out/); + } finally { + await h.host.unloadAll(); + h.cleanup(); + } +}); + +test('lifetime aborts before deactivate and disposes early and late registrations once', async () => { + const events: string[] = []; + const h = createHarness( + { lifetime: enabled() }, + { + loadModule: () => ({ + activate: (ctx) => { + const dispose = ctx.onDispose(() => events.push('early')); + dispose(); + dispose(); + ctx.onDispose(() => events.push('cleanup')); + ctx.signal.addEventListener('abort', () => events.push('aborted')); + return () => { + events.push('deactivate aborted=' + ctx.signal.aborted); + ctx.onDispose(() => events.push('late cleanup')); + ctx.after(0, () => events.push('leaked timer')); + ctx.actions.register('late', () => events.push('leaked action')); + }; + }, + }), + }, + ); + try { + writePlugin(h.root, 'lifetime', { + 'plugin.json': JSON.stringify({ main: 'main.cjs' }), + }); + await h.host.loadAll(); + await h.host.unloadAll(); + await new Promise((resolve) => setTimeout(resolve, 10)); + assert.equal(await h.host.runAction('lifetime/late'), false); + assert.deepEqual(events, [ + 'early', + 'aborted', + 'deactivate aborted=true', + 'late cleanup', + 'cleanup', + ]); + } finally { + h.cleanup(); + } +}); + +test('media subscriptions replay the initial state and follow repeated loads and disconnects', async () => { + const h = createHarness({ media: enabled() }); + const client = createFakeMpvClient(); + try { + writePlugin(h.root, 'media', { + 'plugin.json': JSON.stringify({ main: 'main.cjs' }), + 'main.cjs': `exports.activate = (ctx) => { + ctx.media.on('change', (media) => ctx.notify(JSON.stringify(media))); + };`, + }); + h.host.attachMpvClient(client); + client.emit('media-path-change', { path: 'episode.mkv' }); + client.emit('media-title-change', { title: 'Episode 1' }); + await h.host.loadAll(); + const first = h.host.getMediaSnapshot(); + assert.ok(first); + assert.deepEqual(h.notifications, [JSON.stringify(first)]); + client.emit('file-loaded', undefined); + client.emit('file-loaded', undefined); + assert.notEqual(h.host.getMediaSnapshot()?.playbackId, first.playbackId); + assert.equal(first.title, 'Episode 1'); + client.emit('media-path-change', { path: 'next.mkv' }); + assert.equal(h.host.getMediaSnapshot()?.title, null); + client.emit('connection-change', { connected: false }); + assert.equal(h.host.getMediaSnapshot(), null); + await h.host.unloadAll(); + const count = h.notifications.length; + client.emit('media-path-change', { path: 'later.mkv' }); + assert.equal(h.notifications.length, count); + } finally { + await h.host.unloadAll(); + h.cleanup(); + } +}); + +test('incompatible API versions are rejected before the entry module executes', async () => { + const h = createHarness({ future: enabled() }); + try { + writePlugin(h.root, 'future', { + 'plugin.json': JSON.stringify({ apiVersion: 999, main: 'main.cjs' }), + 'main.cjs': `throw new Error('should never execute');`, + }); + await h.host.loadAll(); + assert.match(h.errors[0]!, /requires API 999/); + assert.equal(h.errors.length, 1); + } finally { + h.cleanup(); + } +}); + +test('a malformed manifest version cannot prevent compatible plugins from loading', async () => { + const h = createHarness({ bad: enabled(), good: enabled() }); + try { + writePlugin(h.root, 'bad', { + 'plugin.json': JSON.stringify({ main: 'main.cjs', apiVersion: { toString: null } }), + 'main.cjs': `throw new Error('should never execute');`, + }); + writePlugin(h.root, 'good', { + 'plugin.json': JSON.stringify({ main: 'main.cjs', apiVersion: 1 }), + 'main.cjs': `exports.activate = (ctx) => { ctx.actions.register('go', () => ctx.notify('good')); };`, + }); + await h.host.loadAll(); + assert.equal(await h.host.runAction('good/go'), true); + assert.deepEqual(h.notifications, ['good']); + assert.deepEqual(h.errors, ['plugin.json "apiVersion" must be a positive integer.']); + } finally { + await h.host.unloadAll(); + h.cleanup(); + } +}); + +test('episode-tags uses mining provenance for a kept note older than this playback', async () => { + const calls: unknown[] = []; + const h = createHarness( + { tags: enabled() }, + { + invokeAnkiConnect: async (action, params) => { + calls.push({ action, params }); + }, + }, + ); + try { + writePlugin(h.root, 'tags', { + 'plugin.json': JSON.stringify({ main: 'main.cjs', apiVersion: 1 }), + 'main.cjs': `module.exports = require(${JSON.stringify(path.resolve(__dirname, '../../../../assets/plugins/episode-tags/main.js'))});`, + }); + await h.host.loadAll(); + h.host.emitAnkiEvent('card-mined', { + noteIds: [7], + notes: [ + { + originalNoteId: 1234, + noteId: 7, + provenance: { + minedAt: 1234, + media: { playbackId: 1, path: 'Show - 02.mkv', title: null }, + subtitle: null, + }, + }, + ], + }); + await new Promise((resolve) => setImmediate(resolve)); + assert.deepEqual(calls, [ + { action: 'addTags', params: { notes: [7], tags: 'show::Show::E02' } }, + ]); + } finally { + await h.host.unloadAll(); + h.cleanup(); + } +}); + +test('each plugin gets its own data folder, created before activate', async () => { + const h = createHarness({ keeper: enabled() }); + try { + writePlugin(h.root, 'keeper', { + 'plugin.json': JSON.stringify({ main: 'main.cjs' }), + 'main.cjs': `const fs = require('node:fs'); + exports.activate = (ctx) => { + fs.writeFileSync(require('node:path').join(ctx.dataDir, 'state.json'), '{}'); + ctx.notify(ctx.dataDir); + };`, + }); + + await h.host.loadAll(); + + const expected = path.join(h.dataRoot, 'keeper'); + assert.deepEqual(h.notifications, [expected]); + assert.equal(existsSync(path.join(expected, 'state.json')), true); + assert.deepEqual(h.errors, []); + } finally { + h.cleanup(); + } +}); + +test('anki card-mined reaches listeners until unload, and invoke forwards to AnkiConnect', async () => { + const invoked: unknown[][] = []; + const h = createHarness( + { syncer: enabled() }, + { + invokeAnkiConnect: async (...args) => { + invoked.push(args); + return 'synced'; + }, + }, + ); + try { + writePlugin(h.root, 'syncer', { + 'plugin.json': JSON.stringify({ main: 'main.cjs' }), + 'main.cjs': `exports.activate = (ctx) => { + ctx.anki.on('card-mined', async ({ noteIds }) => { + ctx.notify(noteIds.join(',') + ' ' + (await ctx.anki.invoke('sync', undefined, { timeoutMs: 5 }))); + }); + };`, + }); + await h.host.loadAll(); + + h.host.emitAnkiEvent('card-mined', { noteIds: [1, 2], notes: [] }); + await new Promise((resolve) => setImmediate(resolve)); + assert.deepEqual(h.notifications, ['1,2 synced']); + assert.deepEqual(invoked, [['sync', {}, { timeoutMs: 5 }]]); + + await h.host.unloadAll(); + h.host.emitAnkiEvent('card-mined', { noteIds: [3], notes: [] }); + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(h.notifications.length, 1); + } finally { + h.cleanup(); + } +}); + +test('actions.run forwards built-in session actions', async () => { + const h = createHarness({ caller: enabled() }); + try { + writePlugin(h.root, 'caller', { + 'plugin.json': JSON.stringify({ main: 'main.cjs' }), + 'main.cjs': `exports.activate = async (ctx) => { + await ctx.actions.run('openJimaku'); + await ctx.actions.run('cycleRuntimeOption', { runtimeOptionId: 'x', direction: -1 }); + };`, + }); + + await h.host.loadAll(); + + assert.deepEqual(h.sessionActions, [ + { actionId: 'openJimaku' }, + { actionId: 'cycleRuntimeOption', payload: { runtimeOptionId: 'x', direction: -1 } }, + ]); + } finally { + h.cleanup(); + } +}); + +test('openWindow resolves plugin files, reuses open windows, and closes them on unload', async () => { + const h = createHarness({ viewer: enabled() }); + try { + writePlugin(h.root, 'viewer', { + 'plugin.json': JSON.stringify({ main: 'main.cjs' }), + 'main.cjs': `exports.activate = (ctx) => { + ctx.actions.register('open', () => ctx.ui.openWindow({ html: 'ui/index.html', preload: 'preload.js', width: 400 })); + ctx.actions.register('escape', () => ctx.ui.openWindow({ html: '../other/index.html' })); + };`, + }); + await h.host.loadAll(); + + await h.host.runAction('viewer/open'); + await h.host.runAction('viewer/open'); + const dir = path.join(h.root, 'viewer'); + assert.deepEqual(h.windowRequests[0], { + key: `viewer:${path.join(dir, 'ui', 'index.html')}`, + htmlPath: path.join(dir, 'ui', 'index.html'), + preloadPath: path.join(dir, 'preload.js'), + title: 'viewer', + width: 400, + height: 720, + }); + assert.equal(h.windows.size, 1); + + await h.host.runAction('viewer/escape'); + assert.equal(h.windowRequests.length, 2); + assert.match(h.errors[0] ?? '', /must stay inside/); + + const [window] = [...h.windows.values()]; + await h.host.unloadAll(); + assert.equal(window?.destroyed, true); + } finally { + h.cleanup(); + } +}); diff --git a/src/core/services/plugins/plugin-host.ts b/src/core/services/plugins/plugin-host.ts new file mode 100644 index 00000000..6bd831f3 --- /dev/null +++ b/src/core/services/plugins/plugin-host.ts @@ -0,0 +1,568 @@ +import { EventEmitter } from 'node:events'; +import { promises as fs } from 'node:fs'; +import path from 'node:path'; + +import type { BrowserWindow } from 'electron'; + +import type { SessionActionDispatchRequest } from '../../../types/runtime'; +import { execPluginCommand } from './exec'; +import { isValidPluginName, parsePluginManifest, resolvePluginFilePath } from './manifest'; +import { createPluginMediaState } from './media-state'; +import { + PLUGIN_API_VERSION, + PLUGIN_MPV_EVENT_NAMES, + type PluginAnkiEventMap, + type PluginAnkiEventName, + type PluginAnkiInvokeOptions, + type PluginConfigEntry, + type PluginContext, + type PluginLogger, + type PluginModule, + type PluginMpvEventName, + type PluginMpvEventMap, + type PluginNotifyVariant, + type PluginPanelContent, + type PluginPanelHandle, + type PluginSubtitleContext, + type PluginSubtitleLine, + type PluginWindowRequest, +} from './types'; + +const MIN_TIMER_INTERVAL_MS = 100; +const DEFAULT_WINDOW_WIDTH = 960; +const DEFAULT_WINDOW_HEIGHT = 720; +/** + * Quit waits for unloadAll, so a load or deactivate that never settles must not hold the app open. + * The forced-exit fallback in main adds its own budget on top of this. + */ +export const PLUGIN_DEACTIVATE_TIMEOUT_MS = 2_000; +export const PLUGIN_ACTIVATE_TIMEOUT_MS = 10_000; + +interface MpvClientEvents { + currentVideoPath?: string; + currentMediaTitle?: string | null; + on: ( + event: E, + listener: (payload: PluginMpvEventMap[E]) => void, + ) => void; + off: ( + event: E, + listener: (payload: PluginMpvEventMap[E]) => void, + ) => void; +} + +export interface PluginHostDeps { + pluginsDir: string; + /** Root of the per-plugin data folders; each plugin gets `/`. */ + pluginDataDir: string; + getPluginConfig: () => Record; + createLogger: (scope: string) => PluginLogger; + isMpvConnected: () => boolean; + sendMpvCommand: (command: (string | number)[]) => void; + requestMpvProperty: (name: string) => Promise; + requestMpvCommand: (command: (string | number | boolean)[]) => Promise; + ensureMpvRunning: () => Promise; + invokeAnkiConnect: ( + action: string, + params: Record, + options: PluginAnkiInvokeOptions, + ) => Promise; + dispatchSessionAction: (request: SessionActionDispatchRequest) => Promise; + getCurrentSubtitle: () => PluginSubtitleLine | null; + getSubtitleContext: (options: { before?: number; after?: number }) => PluginSubtitleContext; + notify: (message: string, variant: PluginNotifyVariant) => void; + openPanel: (content: PluginPanelContent) => PluginPanelHandle; + /** Focuses the window already open under `request.key`, or creates it (`created: true`). */ + openWindow: (request: PluginWindowRequest) => { window: BrowserWindow; created: boolean }; + unstable: TUnstable; + /** Test seam; defaults to Node's `require`, which also loads synchronous ES modules. */ + loadModule?: (entryPath: string) => PluginModule; + exec?: typeof execPluginCommand; + /** How long unload waits, in total, for loading and deactivate before disposing anyway. */ + deactivateTimeoutMs?: number; + /** Async activation is bounded; synchronous plugin code still shares the main thread. */ + activateTimeoutMs?: number; +} + +interface LoadedPlugin { + id: string; + disposers: Set<() => void>; + deactivate: (() => unknown) | null; + deactivation: Promise | null; + disposed: boolean; + /** Aborted on unload so in-flight `exec` children die with the plugin. */ + lifetime: AbortController; +} + +/** Resolves when `promise` settles, or rejects once `timeoutMs` passes first. */ +async function settleWithin(promise: Promise, timeoutMs: number): Promise { + let timer: ReturnType | undefined; + try { + return await Promise.race([ + promise, + new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error(`timed out after ${timeoutMs} ms`)), timeoutMs); + }), + ]); + } finally { + clearTimeout(timer); + } +} + +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 ankiEvents = new EventEmitter(); + ankiEvents.setMaxListeners(0); + const mediaEvents = new EventEmitter(); + mediaEvents.setMaxListeners(0); + const media = createPluginMediaState((snapshot) => mediaEvents.emit('change', snapshot)); + 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 detachClient: (() => void) | null = null; + let loadPromise: Promise | null = null; + const pluginLoads = new Map>(); + let unloading = false; + + 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, + dataDir: string, + entry: PluginConfigEntry, + log: PluginLogger, + ): PluginContext { + const assertActive = (): void => { + if (plugin.disposed) throw new Error(`Plugin "${plugin.id}" has been disposed.`); + }; + const track = (dispose: () => void): (() => void) => { + let disposed = false; + const disposer = (): void => { + if (disposed) return; + disposed = true; + plugin.disposers.delete(disposer); + dispose(); + }; + if (plugin.lifetime.signal.aborted) disposer(); + else plugin.disposers.add(disposer); + return disposer; + }; + + return { + apiVersion: PLUGIN_API_VERSION, + id: plugin.id, + signal: plugin.lifetime.signal, + onDispose: (dispose) => track(() => runGuarded(log, 'Cleanup', dispose)), + dir, + settings: Object.freeze({ ...entry.settings }), + dataDir, + 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) => { + assertActive(); + deps.sendMpvCommand(command); + }, + request: async (command) => { + assertActive(); + return deps.requestMpvCommand(command); + }, + getProperty: async (name) => { + assertActive(); + return deps.requestMpvProperty(name); + }, + ensureRunning: async () => { + assertActive(); + return deps.ensureMpvRunning(); + }, + }, + media: { + current: () => media.current(), + on: (event, listener, options) => { + if (event !== 'change') throw new Error(`Unknown media event "${event}".`); + const wrapped = (snapshot: ReturnType): void => + runGuarded(log, 'media change listener', () => listener(snapshot)); + mediaEvents.on('change', wrapped); + const dispose = track(() => mediaEvents.off('change', wrapped)); + if (!plugin.lifetime.signal.aborted && options?.emitCurrent !== false) { + wrapped(media.current()); + } + return dispose; + }, + }, + anki: { + on: (event, listener) => { + const wrapped = (payload: unknown): void => + runGuarded(log, `anki ${event} listener`, () => + listener(payload as Parameters[0]), + ); + ankiEvents.on(event, wrapped); + return track(() => ankiEvents.off(event, wrapped)); + }, + invoke: async (action, params = {}, options = {}) => { + assertActive(); + return deps.invokeAnkiConnect(action, params, options); + }, + }, + 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)); + }, + run: async (actionId, payload) => { + assertActive(); + return deps.dispatchSessionAction( + payload === undefined ? { actionId } : { actionId, payload }, + ); + }, + }, + notify: (message, options) => { + assertActive(); + deps.notify(message, options?.variant ?? 'info'); + }, + ui: { + panel: (content) => { + plugin.lifetime.signal.throwIfAborted(); + 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; + }, + openWindow: (options) => { + plugin.lifetime.signal.throwIfAborted(); + const resolveFile = (label: string, file: string): string => { + const resolved = resolvePluginFilePath(dir, file); + if (!resolved) throw new Error(`Window ${label} "${file}" must stay inside ${dir}.`); + return resolved; + }; + const htmlPath = resolveFile('html', options.html); + const { window, created } = deps.openWindow({ + key: `${plugin.id}:${htmlPath}`, + htmlPath, + preloadPath: + options.preload === undefined ? null : resolveFile('preload', options.preload), + title: options.title ?? plugin.id, + width: options.width ?? DEFAULT_WINDOW_WIDTH, + height: options.height ?? DEFAULT_WINDOW_HEIGHT, + }); + if (created) { + let closed = false; + const dispose = track(() => { + if (!closed && !window.isDestroyed()) window.destroy(); + }); + window.once('closed', () => { + closed = true; + dispose(); + }); + } + return window; + }, + }, + exec: (command, args, options = {}) => + exec(command, args, { + ...options, + signal: options.signal + ? AbortSignal.any([options.signal, plugin.lifetime.signal]) + : plugin.lifetime.signal, + }), + unstable: deps.unstable, + }; + } + + function disposePlugin(plugin: LoadedPlugin): void { + if (plugin.disposed) return; + plugin.disposed = true; + 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); + } + + /** Shares one bounded deactivate call between unload and an activation that finishes late. */ + function finishPlugin( + plugin: LoadedPlugin, + timeoutMs = deps.deactivateTimeoutMs ?? PLUGIN_DEACTIVATE_TIMEOUT_MS, + ): Promise { + if (plugin.deactivation) return plugin.deactivation; + if (!plugin.deactivate) { + disposePlugin(plugin); + return Promise.resolve(); + } + plugin.deactivation = (async () => { + try { + await settleWithin( + Promise.resolve().then(() => plugin.deactivate?.()), + timeoutMs, + ); + } catch (error) { + deps.createLogger(`plugin:${plugin.id}`).error(`deactivate failed: ${errorMessage(error)}`); + } finally { + disposePlugin(plugin); + } + })(); + return plugin.deactivation; + } + + 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 = resolvePluginFilePath(dir, parsed.manifest.main); + if (!entryPath) { + log.error(`Entry "${parsed.manifest.main}" must stay inside ${dir}.`); + return; + } + + const dataDir = path.join(deps.pluginDataDir, id); + try { + await fs.mkdir(dataDir, { recursive: true }); + } catch (error) { + log.error(`Cannot create data folder ${dataDir}: ${errorMessage(error)}`); + return; + } + + if (unloading) return; + const plugin: LoadedPlugin = { + id, + disposers: new Set(), + deactivate: null, + deactivation: null, + disposed: false, + 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 activation = Promise.resolve( + activate(createContext(plugin, dir, dataDir, entry, log)), + ).then((returned) => { + plugin.deactivate = + typeof returned === 'function' + ? (returned as () => unknown) + : pickExport(mod, 'deactivate'); + // Unload owns cleanup while it is waiting for activation. A timed-out plugin owns it later. + if (plugin.disposed) void finishPlugin(plugin); + }); + await settleWithin(activation, deps.activateTimeoutMs ?? PLUGIN_ACTIVATE_TIMEOUT_MS); + if (plugin.lifetime.signal.aborted) return; + 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 { + getMediaSnapshot: () => media.current(), + /** Forwards the client's events to plugin listeners, replacing any previously attached client. */ + attachMpvClient(client: MpvClientEvents): void { + detachClient?.(); + const disposers: Array<() => void> = []; + const listen = ( + event: E, + listener: (payload: PluginMpvEventMap[E]) => void, + ): void => { + client.on(event, listener); + disposers.push(() => client.off(event, listener)); + }; + listen('media-path-change', ({ path }) => media.pathChanged(path)); + listen('media-title-change', ({ title }) => media.titleChanged(title)); + listen('file-loaded', () => + media.fileLoaded( + client.currentVideoPath ?? media.current()?.path ?? '', + client.currentMediaTitle === undefined + ? (media.current()?.title ?? null) + : client.currentMediaTitle, + ), + ); + listen('end-file', () => media.reset()); + listen('connection-change', ({ connected }) => { + if (!connected) media.reset(); + }); + for (const event of PLUGIN_MPV_EVENT_NAMES) { + listen(event, (payload) => { + mpvEvents.emit(event, payload); + }); + } + detachClient = () => { + for (const dispose of disposers) dispose(); + }; + media.reset(client.currentVideoPath, client.currentMediaTitle); + }, + + /** Delivers an Anki event to every plugin listening for it. */ + emitAnkiEvent(event: E, payload: PluginAnkiEventMap[E]): void { + ankiEvents.emit(event, payload); + }, + + /** Loads every enabled plugin once; later calls return the first load. */ + loadAll(): Promise { + if (unloading) return Promise.resolve(); + loadPromise ??= Promise.all( + Object.entries(deps.getPluginConfig()) + .filter(([, entry]) => entry.enabled) + .map(([id, entry]) => { + const loading = loadPlugin(id, entry); + pluginLoads.set(id, loading); + return loading; + }), + ).then(() => undefined); + return loadPromise; + }, + + async unloadAll(): Promise { + unloading = true; + for (const plugin of plugins.values()) plugin.lifetime.abort(); + // One budget covers the load wait and every deactivate, so quit is bounded by it. + const timeoutMs = deps.deactivateTimeoutMs ?? PLUGIN_DEACTIVATE_TIMEOUT_MS; + const deadline = Date.now() + timeoutMs; + // Plugins still reading their files are not in `plugins` yet; let the load finish first. + if (loadPromise) { + try { + await settleWithin(loadPromise, timeoutMs); + } catch (error) { + deps + .createLogger('plugins') + .warn(`Unloading before load finished: ${errorMessage(error)}`); + } + } + loadPromise = null; + await Promise.all( + [...plugins.values()].map((plugin) => + finishPlugin(plugin, Math.max(0, deadline - Date.now())), + ), + ); + }, + + /** + * Runs a registered `/` action, waiting for an in-progress load first so a + * command sent at startup is not lost. Resolves false when no plugin registered the action. + */ + async runAction(actionId: string): Promise { + const separator = actionId.indexOf('/'); + if (separator < 1) return false; + const pluginId = actionId.slice(0, separator); + await pluginLoads.get(pluginId); + if (unloading) return false; + 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..a9530271 --- /dev/null +++ b/src/core/services/plugins/types.ts @@ -0,0 +1,55 @@ +import type { PluginMpvEventName } from '../../../plugin-api'; +export type * from '../../../plugin-api'; + +export const PLUGIN_API_VERSION = 1; + +/** `plugin.json` at the root of a plugin directory. */ +export interface PluginManifest { + /** Display name; defaults to the directory name. */ + name: string; + version: string | null; + /** Omitted by older plugins, which use API v1. */ + apiVersion: number; + /** Entry module, relative to the plugin directory. */ + main: string; +} + +/** One `plugins.` entry from config. */ +export interface PluginConfigEntry { + enabled: boolean; + settings: Record; +} + +const pluginMpvEvents = { + 'connection-change': true, + 'subtitle-change': true, + 'subtitle-timing': true, + 'time-pos-change': true, + 'duration-change': true, + 'pause-change': true, + 'fullscreen-change': true, + 'secondary-subtitle-change': true, + 'subtitle-track-change': true, + 'secondary-subtitle-track-change': true, + 'secondary-subtitle-delay-change': true, + 'subtitle-track-list-change': true, + 'media-path-change': true, + 'media-title-change': true, + 'secondary-subtitle-visibility': true, + 'client-message': true, + 'file-loaded': true, + 'end-file': true, +} satisfies Record; + +export const PLUGIN_MPV_EVENT_NAMES = Object.keys(pluginMpvEvents) as PluginMpvEventName[]; + +/** What the host needs to show a plugin window; paths are absolute and already validated. */ +export interface PluginWindowRequest { + /** Same key, same window: reopening focuses the existing one. */ + key: string; + htmlPath: string; + preloadPath: string | null; + title: string; + width: number; + height: number; +} diff --git a/src/core/services/session-actions.test.ts b/src/core/services/session-actions.test.ts index c1d0c75c..e9b8e9d1 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: async (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..44a10055 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) => Promise; showMpvOsd: (text: string) => void; } @@ -151,6 +153,13 @@ export async function dispatchSessionAction( } return; } + case 'runPluginAction': { + const pluginActionId = request.payload?.pluginActionId; + if (!pluginActionId || !(await 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/core/utils/windows-command-shim.test.ts b/src/core/utils/windows-command-shim.test.ts new file mode 100644 index 00000000..b193d429 --- /dev/null +++ b/src/core/utils/windows-command-shim.test.ts @@ -0,0 +1,63 @@ +import assert from 'node:assert/strict'; +import path from 'node:path'; +import test from 'node:test'; + +import { + buildWindowsShimInvocation, + isWindowsCommandShim, + resolveWindowsCommand, +} from './windows-command-shim'; + +test('resolves a bare name through PATH and PATHEXT in order', () => { + const npmDir = path.join('/fake', 'npm'); + const binDir = path.join('/fake', 'bin'); + const existing = new Set([path.join(npmDir, 'codex.CMD'), path.join(binDir, 'codex.EXE')]); + const env = { PATH: [npmDir, binDir].join(path.delimiter), PATHEXT: '.EXE;.CMD' }; + + assert.equal( + resolveWindowsCommand('codex', env, (candidate) => existing.has(candidate)), + path.join(npmDir, 'codex.CMD'), + ); + assert.equal( + resolveWindowsCommand('codex.exe', env, () => true), + 'codex.exe', + 'commands with an extension are left alone', + ); + assert.equal( + resolveWindowsCommand('missing', env, () => false), + 'missing', + ); +}); + +test('resolves an extensionless path in its own directory', () => { + const shim = path.join('/fake', 'npm', 'codex'); + assert.equal( + resolveWindowsCommand(shim, { PATHEXT: '.CMD' }, (candidate) => candidate === `${shim}.CMD`), + `${shim}.CMD`, + ); +}); + +test('recognizes batch shims only', () => { + assert.equal(isWindowsCommandShim('C:\\npm\\codex.cmd'), true); + assert.equal(isWindowsCommandShim('run.BAT'), true); + assert.equal(isWindowsCommandShim('codex.exe'), false); +}); + +test('quotes the shim call for cmd.exe', () => { + const invocation = buildWindowsShimInvocation( + 'C:\\npm\\codex.cmd', + ['-C', 'C:\\Temp dir\\a&b', '-c', 'effort="low"'], + { ComSpec: 'C:\\Windows\\system32\\cmd.exe' }, + ); + + assert.deepEqual(invocation, { + file: 'C:\\Windows\\system32\\cmd.exe', + args: [ + '/d', + '/s', + '/v:off', + '/c', + '"^"C:\\npm\\codex.cmd^" ^"-C^" ^"C:\\Temp^ dir\\a^&b^" ^"-c^" ^"effort=\\^"low\\^"^""', + ], + }); +}); diff --git a/src/core/utils/windows-command-shim.ts b/src/core/utils/windows-command-shim.ts new file mode 100644 index 00000000..95c00b90 --- /dev/null +++ b/src/core/utils/windows-command-shim.ts @@ -0,0 +1,83 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +/** True for batch shims (`.cmd`/`.bat`), which Windows only runs through cmd.exe. */ +export function isWindowsCommandShim(command: string): boolean { + return /\.(cmd|bat)$/i.test(command); +} + +/** + * Resolves an extensionless Windows command the way cmd.exe does: each `PATHEXT` extension in + * the command's own directory, or in each `PATH` entry for a bare name. Returns `command` + * unchanged when it already has an extension or nothing matches. + */ +export function resolveWindowsCommand( + command: string, + env: NodeJS.ProcessEnv, + existsSync: (candidate: string) => boolean = fs.existsSync, +): string { + if (path.extname(command)) return command; + const directory = path.dirname(command); + const directories = + directory === '.' ? (env.PATH ?? env.Path ?? '').split(path.delimiter).filter(Boolean) : ['']; + const extensions = (env.PATHEXT ?? '.COM;.EXE;.BAT;.CMD').split(';').filter(Boolean); + for (const entry of directories) { + for (const extension of extensions) { + const candidate = entry ? path.join(entry, command + extension) : command + extension; + if (existsSync(candidate)) return candidate; + } + } + return command; +} + +/*! + * Windows command escaping adapted from cross-spawn 7.0.6. + * + * The MIT License (MIT) + * + * Copyright (c) 2018 Made With MOXY Lda + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ +const WINDOWS_SHELL_META_CHARACTERS = /([()\][%!^"`<>&|;, *?])/g; + +// Quote for both cmd.exe and the Windows argv parser. The outer caret escapes +// are consumed by cmd, leaving the quoted argument unchanged for the command. +function escapeWindowsShellArgument(value: string): string { + const quotesEscaped = value + .replace(/(?=(\\+?)?)\1"/g, '$1$1\\"') + .replace(/(?=(\\+?)?)\1$/, '$1$1'); + return `"${quotesEscaped}"`.replace(WINDOWS_SHELL_META_CHARACTERS, '^$1'); +} + +/** + * Builds the cmd.exe invocation that runs a batch shim with `args`. Spawn the result with + * `windowsVerbatimArguments: true` so Node does not re-quote the prepared command line. + */ +export function buildWindowsShimInvocation( + command: string, + args: readonly string[], + env: NodeJS.ProcessEnv, +): { file: string; args: string[] } { + const commandLine = [command, ...args].map(escapeWindowsShellArgument).join(' '); + return { + file: env.ComSpec ?? env.COMSPEC ?? process.env.ComSpec ?? 'cmd.exe', + args: ['/d', '/s', '/v:off', '/c', `"${commandLine}"`], + }; +} diff --git a/src/main.ts b/src/main.ts index 93fb8ae3..286763b8 100644 --- a/src/main.ts +++ b/src/main.ts @@ -474,6 +474,17 @@ 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, + PLUGIN_DEACTIVATE_TIMEOUT_MS, +} 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 { createPluginWindowRuntime } from './main/runtime/plugin-window-runtime'; import { openCharacterDictionaryManagerModal as openCharacterDictionaryManagerModalRuntime } from './main/runtime/character-dictionary-open'; import { openControllerSelectModal as openControllerSelectModalRuntime } from './main/runtime/controller-select-open'; import { openControllerDebugModal as openControllerDebugModalRuntime } from './main/runtime/controller-debug-open'; @@ -1003,6 +1014,7 @@ function requestAppQuit(): void { logger.warn('Failed to stop stats server while quitting.', error); }); if (!forceQuitTimer) { + // Quit cleanup unloads plugins first, so allow their budget plus 2 s for the rest. forceQuitTimer = setTimeout(() => { logger.warn('App quit timed out; forcing process exit.'); void createForceQuitHandler({ @@ -1010,7 +1022,7 @@ function requestAppQuit(): void { logError: (error) => logger.error('Failed to finalize stats before forced exit.', error), exit: () => app.exit(0), })(); - }, 2000); + }, PLUGIN_DEACTIVATE_TIMEOUT_MS + 2000); } app.quit(); } @@ -2947,20 +2959,7 @@ const overlayVisibilityComposer = composeOverlayVisibilityRuntime({ }, }); -function createOverlayHostedModalOpenDeps(): { - ensureOverlayStartupPrereqs: () => void; - ensureOverlayWindowsReadyForVisibilityActions: () => void; - sendToActiveOverlayWindow: ( - channel: string, - payload?: unknown, - runtimeOptions?: { - restoreOnModalClose?: OverlayHostedModal; - preferModalWindow?: boolean; - }, - ) => boolean; - waitForModalOpen: (modal: OverlayHostedModal, timeoutMs: number) => Promise; - logWarn: (message: string) => void; -} { +function createOverlayHostedModalOpenDeps(): Parameters[0] { return { ensureOverlayStartupPrereqs: () => ensureOverlayStartupPrereqs(), ensureOverlayWindowsReadyForVisibilityActions: () => @@ -2972,6 +2971,108 @@ function createOverlayHostedModalOpenDeps(): { }; } +const pluginPanelRuntime = createPluginPanelRuntime({ + openModal: (getSnapshot, signal) => + openPluginPanelModal(createOverlayHostedModalOpenDeps(), getSnapshot, signal), + closeModal: () => overlayModalRuntime.handleOverlayModalClosed('plugin-panel'), + // 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: toSubtitleClock( + Number(appState.mpvClient?.currentTimePos), + appState.mpvClient?.currentSubDelay ?? 0, + ), + 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 pluginWindowRuntime = createPluginWindowRuntime({ + createBrowserWindow: (options) => new BrowserWindow(options), + promoteAboveOverlay: (window) => promoteSettingsWindowAboveOverlay(window), + logError: (message) => logger.error(message), +}); + +const pluginHost = createPluginHost({ + pluginsDir: path.join(CONFIG_DIR, 'plugins'), + pluginDataDir: path.join(USER_DATA_PATH, 'plugin-data'), + getPluginConfig: () => configService.getConfig().plugins, + createLogger, + isMpvConnected: () => appState.mpvClient?.connected === true, + 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); + }, + requestMpvCommand: async (command) => { + if (!appState.mpvClient) throw new Error('mpv is not connected.'); + return await appState.mpvClient.requestCommand(command); + }, + ensureMpvRunning: () => ensureMpvConnectedForPlayback(), + invokeAnkiConnect: (action, params, options) => { + const ankiConfig = configService.getConfig().ankiConnect; + const url = ( + appState.runtimeOptionsManager?.getEffectiveAnkiConnectConfig(ankiConfig) ?? ankiConfig + ).url; + if (!url) return Promise.reject(new Error('ankiConnect.url is not configured.')); + // No retries: a retried `sync` or `addNote` could run twice. + return new AnkiConnectClient(url).invoke(action, params, { + timeout: options.timeoutMs, + maxRetries: 0, + }); + }, + dispatchSessionAction: (request) => dispatchSessionAction(request), + 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), + openWindow: (request) => pluginWindowRuntime.open(request), + unstable: { appState, configService, overlayManager }, +}); + const mediaTimingFrameExtractor = createMediaTimingFrameExtractor(); const mediaTimingReviewRuntime = createMediaTimingReviewRuntime({ getMpvClient: () => appState.mpvClient, @@ -4201,6 +4302,7 @@ const { void appState.discordPresenceService?.stop(); appState.discordPresenceService = null; }, + unloadPlugins: () => pluginHost.unloadAll(), }, shouldRestoreWindowsOnActivateMainDeps: { isOverlayRuntimeInitialized: () => appState.overlayRuntimeInitialized, @@ -4468,6 +4570,9 @@ const { appReadyRuntimeRunner } = composeAppReadyRuntime({ createMpvClient: () => { appState.mpvClient = createMpvClientRuntimeService(); }, + startPlugins: () => { + void pluginHost.loadAll(); + }, getResolvedConfig: () => configService.getConfig(), getConfigWarnings: () => configService.getWarnings(), logConfigWarning: (warning) => appLogger.logConfigWarning(warning), @@ -5163,6 +5268,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; } @@ -5289,6 +5395,14 @@ function initializeOverlayRuntime(): void { overlayModalRuntime.primeModalWindow(); } appState.ankiIntegration?.setRecordCardsMinedCallback(recordTrackedCardsMined); + appState.ankiIntegration?.setMiningProvenanceProvider(() => ({ + minedAt: Date.now(), + media: pluginHost.getMediaSnapshot(), + subtitle: getPluginCurrentSubtitle(), + })); + appState.ankiIntegration?.setCardsReadyCallback((noteIds, notes) => + pluginHost.emitAnkiEvent('card-mined', { noteIds, notes }), + ); appState.ankiIntegration?.setKnownWordCacheUpdatedCallback(refreshCurrentSubtitleAnnotations); appState.ankiIntegration?.setSubtitleMiningContextConsumer(consumePendingSubtitleMiningContext); appState.ankiIntegration?.setMediaTimingReviewCallback(mediaTimingReviewRuntime.requestReview); @@ -5706,6 +5820,11 @@ async function dispatchSessionAction(request: SessionActionDispatchRequest): Pro }, playNextPlaylistItem: () => sendMpvCommandRuntime(appState.mpvClient, ['playlist-next', 'force']), + runPluginAction: async (pluginActionId) => { + const ran = await pluginHost.runAction(pluginActionId); + if (!ran) logger.warn(`Plugin action is not registered: ${pluginActionId}`); + return ran; + }, showMpvOsd: (text) => showConfiguredPlaybackFeedback(text), }); } @@ -5787,7 +5906,15 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({ return false; } }, - onOverlayModalClosed: (modal, senderWindow) => { + onOverlayModalClosed: (modal, senderWindow, instanceId) => { + // A late close for a replaced plugin panel must not hide the panel now showing. + if ( + modal === 'plugin-panel' && + instanceId !== undefined && + !pluginPanelRuntime.handleModalClosed(instanceId) + ) { + return; + } if (modal === 'media-timing-review') { void mediaTimingReviewRuntime.dispose(); } 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/main-wiring.test.ts b/src/main/main-wiring.test.ts index 18c7492f..231886ef 100644 --- a/src/main/main-wiring.test.ts +++ b/src/main/main-wiring.test.ts @@ -303,7 +303,7 @@ test('subtitle sidebar open state is restored for replacement visible overlay wi /onOverlayModalOpened:\s*\(modal,\s*senderWindow\)\s*=>\s*\{(?[\s\S]*?)\n \},/, )?.groups?.body; const closedBlock = source.match( - /onOverlayModalClosed:\s*\(modal,\s*senderWindow\)\s*=>\s*\{(?[\s\S]*?)\n \},/, + /onOverlayModalClosed:\s*\(modal,\s*senderWindow(?:,\s*instanceId)?\)\s*=>\s*\{(?[\s\S]*?)\n \},/, )?.groups?.body; const depsBlock = source.match(/getSubtitleSidebarOpen:\s*\(\)\s*=>\s*(?[^\n,]+)/)?.groups ?.body; diff --git a/src/main/overlay-runtime.test.ts b/src/main/overlay-runtime.test.ts index b204c6fa..00a21768 100644 --- a/src/main/overlay-runtime.test.ts +++ b/src/main/overlay-runtime.test.ts @@ -158,6 +158,32 @@ test('primeModalWindow leaves Linux modal creation lazy', () => { assert.equal(h.createCalls, 0); }); +test('a cancelled queued plugin panel cannot replace its successor when the window loads', () => { + const modal = new FakeOverlayWindow({ ...LOADING_BLANK_WINDOW, documentLoaded: false }); + const h = createHarness({ modal }); + const first = new AbortController(); + h.runtime.sendToActiveOverlayWindow( + 'plugin-panel:open', + { panelId: 1 }, + { + restoreOnModalClose: 'plugin-panel', + preferModalWindow: true, + signal: first.signal, + }, + ); + first.abort(); + h.runtime.sendToActiveOverlayWindow( + 'plugin-panel:open', + { panelId: 2 }, + { + restoreOnModalClose: 'plugin-panel', + preferModalWindow: true, + }, + ); + modal.finishLoad(); + assert.deepEqual(modal.sent, [['plugin-panel:open', { panelId: 2 }]]); +}); + test('sendToActiveOverlayWindow does not retain restore state when modal creation fails', () => { const h = createHarness({ createModal: () => null }); diff --git a/src/main/overlay-runtime.ts b/src/main/overlay-runtime.ts index fbe008f9..5e78bc01 100644 --- a/src/main/overlay-runtime.ts +++ b/src/main/overlay-runtime.ts @@ -50,6 +50,7 @@ export interface OverlayModalRuntime { runtimeOptions?: { restoreOnModalClose?: OverlayHostedModal; preferModalWindow?: boolean; + signal?: AbortSignal; }, ) => boolean; openRuntimeOptionsPalette: () => void; @@ -265,7 +266,9 @@ export function createOverlayModalRuntimeService( const sendOrQueueForWindow = ( window: BrowserWindow, sendNow: (window: BrowserWindow) => void, + signal?: AbortSignal, ): void => { + if (signal?.aborted) return; if (isWindowReadyForIpc(window)) { sendNow(window); return; @@ -273,7 +276,7 @@ export function createOverlayModalRuntimeService( let delivered = false; const deliver = (isReady: () => boolean): void => { - if (delivered || window.isDestroyed() || !isReady()) { + if (delivered || signal?.aborted || window.isDestroyed() || !isReady()) { return; } delivered = true; @@ -433,8 +436,10 @@ export function createOverlayModalRuntimeService( runtimeOptions?: { restoreOnModalClose?: OverlayHostedModal; preferModalWindow?: boolean; + signal?: AbortSignal; }, ): boolean => { + if (runtimeOptions?.signal?.aborted) return false; const restoreOnModalClose = runtimeOptions?.restoreOnModalClose; const preferModalWindow = runtimeOptions?.preferModalWindow === true; @@ -451,13 +456,17 @@ export function createOverlayModalRuntimeService( const mainWindow = getTargetOverlayWindow(); if (!preferModalWindow && mainWindow && !mainWindow.isDestroyed() && mainWindow.isVisible()) { restoreVisibleOverlayOnModalClose.add(restoreOnModalClose); - sendOrQueueForWindow(mainWindow, (window) => { - if (payload === undefined) { - window.webContents.send(channel); - } else { - window.webContents.send(channel, payload); - } - }); + sendOrQueueForWindow( + mainWindow, + (window) => { + if (payload === undefined) { + window.webContents.send(channel); + } else { + window.webContents.send(channel, payload); + } + }, + runtimeOptions?.signal, + ); return true; } @@ -479,16 +488,20 @@ export function createOverlayModalRuntimeService( showModalWindow(modalWindow); } - sendOrQueueForWindow(modalWindow, (window) => { - if (window.isVisible()) { - ensureModalWindowInteractive(window); - } - if (payload === undefined) { - window.webContents.send(channel); - } else { - window.webContents.send(channel, payload); - } - }); + sendOrQueueForWindow( + modalWindow, + (window) => { + if (window.isVisible()) { + ensureModalWindowInteractive(window); + } + if (payload === undefined) { + window.webContents.send(channel); + } else { + window.webContents.send(channel, payload); + } + }, + runtimeOptions?.signal, + ); return true; } @@ -500,7 +513,7 @@ export function createOverlayModalRuntimeService( showOverlayWindowForModal(target); } - sendOrQueueForWindow(target, sendNow); + sendOrQueueForWindow(target, sendNow, runtimeOptions?.signal); return true; }; diff --git a/src/main/runtime/app-lifecycle-actions.test.ts b/src/main/runtime/app-lifecycle-actions.test.ts index 325fd60e..815ab91f 100644 --- a/src/main/runtime/app-lifecycle-actions.test.ts +++ b/src/main/runtime/app-lifecycle-actions.test.ts @@ -55,6 +55,7 @@ function makeCleanupDeps(calls: string[], overrides: Partial = {}): cleanupRemoteMediaWindows: record('cleanupRemoteMediaWindows'), cleanupJellyfinSubtitleCache: record('cleanupJellyfinSubtitleCache'), stopDiscordPresenceService: record('stopDiscordPresenceService'), + unloadPlugins: record('unloadPlugins'), ...overrides, }; } @@ -99,6 +100,47 @@ test('on will quit cleanup handler flushes before teardown and awaits stats befo assert.ok(calls.indexOf('destroyImmersionTracker') < calls.indexOf('destroyAnkiIntegration')); }); +test('on will quit cleanup handler finishes plugin unload before any other teardown', async () => { + const calls: string[] = []; + let finishUnload!: () => void; + const cleanup = createOnWillQuitCleanupHandler( + makeCleanupDeps(calls, { + unloadPlugins: () => { + calls.push('unloadPlugins'); + return new Promise((resolve) => (finishUnload = resolve)); + }, + }), + ); + + let settled = false; + const pending = cleanup().then(() => { + settled = true; + }); + await new Promise((resolve) => setImmediate(resolve)); + assert.deepEqual(calls, ['unloadPlugins']); + assert.equal(settled, false); + + finishUnload(); + await pending; + assert.equal(settled, true); + assert.ok(calls.indexOf('destroyMpvSocket') > 0); +}); + +test('on will quit cleanup handler keeps tearing down when plugin unload fails', async () => { + const calls: string[] = []; + const cleanup = createOnWillQuitCleanupHandler( + makeCleanupDeps(calls, { + unloadPlugins: async () => { + throw new Error('unload failed'); + }, + }), + ); + + await assert.rejects(cleanup(), /unload failed/); + assert.ok(calls.includes('destroyMpvSocket')); + assert.ok(calls.includes('stopDiscordPresenceService')); +}); + test('on will quit cleanup handler cleans subtitle caches when stopping remote session fails', async () => { const calls: string[] = []; const cleanup = createOnWillQuitCleanupHandler( diff --git a/src/main/runtime/app-lifecycle-actions.ts b/src/main/runtime/app-lifecycle-actions.ts index e754b280..a0e96b37 100644 --- a/src/main/runtime/app-lifecycle-actions.ts +++ b/src/main/runtime/app-lifecycle-actions.ts @@ -59,8 +59,17 @@ export function createOnWillQuitCleanupHandler(deps: { cleanupRemoteMediaWindows: () => void; cleanupJellyfinSubtitleCache: () => void; stopDiscordPresenceService: () => void; + unloadPlugins: () => void | Promise; }) { return async (): Promise => { + const cleanupErrors: unknown[] = []; + // Plugins deactivate before any teardown so they can still reach mpv and the overlay; + // the plugin host bounds how long that takes. + try { + await deps.unloadPlugins(); + } catch (error) { + cleanupErrors.push(error); + } deps.destroyTray(); deps.stopConfigHotReload(); deps.restorePreviousSecondarySubVisibility(); @@ -68,7 +77,6 @@ export function createOnWillQuitCleanupHandler(deps: { deps.unregisterAllGlobalShortcuts(); deps.stopSubtitleWebsocket(); deps.stopTexthookerService(); - const cleanupErrors: unknown[] = []; const stopSyncAutoScheduler = Promise.resolve(deps.stopSyncAutoScheduler()).catch( (error: unknown) => { cleanupErrors.push(error); diff --git a/src/main/runtime/app-lifecycle-main-cleanup.test.ts b/src/main/runtime/app-lifecycle-main-cleanup.test.ts index 9571cad8..70f4ad42 100644 --- a/src/main/runtime/app-lifecycle-main-cleanup.test.ts +++ b/src/main/runtime/app-lifecycle-main-cleanup.test.ts @@ -52,6 +52,7 @@ function buildCleanupDeps(overrides: Partial = {}) { cleanupRemoteMediaWindows: noop, cleanupJellyfinSubtitleCache: noop, stopDiscordPresenceService: noop, + unloadPlugins: noop, ...overrides, }); } diff --git a/src/main/runtime/app-lifecycle-main-cleanup.ts b/src/main/runtime/app-lifecycle-main-cleanup.ts index f77aaf3a..3579821e 100644 --- a/src/main/runtime/app-lifecycle-main-cleanup.ts +++ b/src/main/runtime/app-lifecycle-main-cleanup.ts @@ -67,6 +67,7 @@ export function createOnWillQuitCleanupDeps(deps: { cleanupRemoteMediaWindows: () => void; cleanupJellyfinSubtitleCache: () => void; stopDiscordPresenceService: () => void; + unloadPlugins: () => void | Promise; }) { return { destroyTray: () => deps.destroyTray(), @@ -156,5 +157,6 @@ export function createOnWillQuitCleanupDeps(deps: { cleanupRemoteMediaWindows: () => deps.cleanupRemoteMediaWindows(), cleanupJellyfinSubtitleCache: () => deps.cleanupJellyfinSubtitleCache(), stopDiscordPresenceService: () => deps.stopDiscordPresenceService(), + unloadPlugins: () => deps.unloadPlugins(), }; } diff --git a/src/main/runtime/command-line-launcher-deps.ts b/src/main/runtime/command-line-launcher-deps.ts index 58f01693..0f88e5df 100644 --- a/src/main/runtime/command-line-launcher-deps.ts +++ b/src/main/runtime/command-line-launcher-deps.ts @@ -2,6 +2,11 @@ import { spawn } from 'node:child_process'; import fs from 'node:fs'; import path from 'node:path'; +import { + buildWindowsShimInvocation, + isWindowsCommandShim, +} from '../../core/utils/windows-command-shim'; + export interface RunCommandResult { exitCode: number | null; stdout: string; @@ -142,43 +147,7 @@ export function failureMessage(result: RunCommandResult, fallback: string): stri } function needsWindowsShell(command: string): boolean { - return process.platform === 'win32' && /\.(cmd|bat)$/i.test(command); -} - -/*! - * Windows command escaping adapted from cross-spawn 7.0.6. - * - * The MIT License (MIT) - * - * Copyright (c) 2018 Made With MOXY Lda - * - * Permission is hereby granted, free of charge, to any person obtaining a copy - * of this software and associated documentation files (the "Software"), to deal - * in the Software without restriction, including without limitation the rights - * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - * copies of the Software, and to permit persons to whom the Software is - * furnished to do so, subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in - * all copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN - * THE SOFTWARE. - */ -const WINDOWS_SHELL_META_CHARACTERS = /([()\][%!^"`<>&|;, *?])/g; - -// Quote for both cmd.exe and the Windows argv parser. The outer caret escapes -// are consumed by cmd, leaving the quoted argument unchanged for the command. -function escapeWindowsShellArgument(value: string): string { - const quotesEscaped = value - .replace(/(?=(\\+?)?)\1"/g, '$1$1\\"') - .replace(/(?=(\\+?)?)\1$/, '$1$1'); - return `"${quotesEscaped}"`.replace(WINDOWS_SHELL_META_CHARACTERS, '^$1'); + return process.platform === 'win32' && isWindowsCommandShim(command); } function createDefaultRunCommand(): RunCommand { @@ -189,12 +158,8 @@ function createDefaultRunCommand(): RunCommand { try { const env = options.env ?? process.env; if (useShell) { - const shellCommand = [ - escapeWindowsShellArgument(command), - ...args.map(escapeWindowsShellArgument), - ].join(' '); - const commandProcessor = env.ComSpec ?? env.COMSPEC ?? process.env.ComSpec ?? 'cmd.exe'; - child = spawn(commandProcessor, ['/d', '/s', '/v:off', '/c', `"${shellCommand}"`], { + const invocation = buildWindowsShimInvocation(command, args, env); + child = spawn(invocation.file, invocation.args, { env, windowsHide: false, windowsVerbatimArguments: true, diff --git a/src/main/runtime/managed-launcher.test.ts b/src/main/runtime/managed-launcher.test.ts index f11d9a67..c98f7737 100644 --- a/src/main/runtime/managed-launcher.test.ts +++ b/src/main/runtime/managed-launcher.test.ts @@ -279,114 +279,127 @@ test('Windows managed runtime path is absolute, versioned, and injectable', () = ); }); -test('Windows managed launcher forwards arguments without a system Bun', async (t) => { - if (process.platform !== 'win32') return; - const root = workspace(t); - const appDirectory = path.join(root, 'Installed App'); - const appPath = path.join(appDirectory, 'SubMiner.exe'); - const launcherDirectory = path.join(appDirectory, 'resources', 'launcher'); - const script = path.join(launcherDirectory, 'subminer.js'); - fs.mkdirSync(launcherDirectory, { recursive: true }); - fs.copyFileSync(process.execPath, appPath); - fs.writeFileSync(script, 'console.log(JSON.stringify(process.argv.slice(2)));'); - fs.writeFileSync(path.join(launcherDirectory, 'version'), '1.0.0'); - const options = { - platform: process.platform, - env: { ...process.env, PATH: '', LOCALAPPDATA: root }, - bundledBunPath: process.execPath, - launcherResourcePath: script, - appExePath: appPath, - appVersion: '1.0.0', - localAppData: root, - getUserPath: () => '', - setUserPath: () => {}, - broadcastEnvironmentChange: () => {}, - }; - const snapshot = await installLauncher(options); - assert.equal(snapshot.status, 'ready', snapshot.message ?? 'install failed'); - const { getRunCommand } = await import('./command-line-launcher-deps'); - const args = ['spaces here', 'a&b', 'p%TEMP%q', 'bang!z', 'say "hi"', '日本語']; - const result = await getRunCommand({})(snapshot.installPath!, args, { env: options.env }); - assert.equal(result.exitCode, 0, result.stderr); - assert.deepEqual(JSON.parse(result.stdout), args); -}); +// These copy and spawn the real bun.exe, which can outlast the default 5s on CI runners. +const WINDOWS_BUN_TEST_TIMEOUT_MS = 30_000; -test('Windows stages a new runtime version while the prior Bun executable is running', async (t) => { - if (process.platform !== 'win32') return; - const root = workspace(t); - const bundledDirectory = path.join(root, 'packaged', 'bun'); - const bundledBunPath = path.join(bundledDirectory, 'bun.exe'); - const launcherResourcePath = path.join(root, 'packaged', 'launcher', 'subminer'); - fs.mkdirSync(path.join(bundledDirectory, 'licenses'), { recursive: true }); - fs.mkdirSync(path.dirname(launcherResourcePath), { recursive: true }); - fs.copyFileSync(process.execPath, bundledBunPath); - fs.writeFileSync(path.join(bundledDirectory, 'licenses', 'Bun-LICENSE.md'), 'license'); - fs.writeFileSync(launcherResourcePath, 'console.log("launcher");'); +test( + 'Windows managed launcher forwards arguments without a system Bun', + { timeout: WINDOWS_BUN_TEST_TIMEOUT_MS }, + async (t) => { + if (process.platform !== 'win32') return; + const root = workspace(t); + const appDirectory = path.join(root, 'Installed App'); + const appPath = path.join(appDirectory, 'SubMiner.exe'); + const launcherDirectory = path.join(appDirectory, 'resources', 'launcher'); + const script = path.join(launcherDirectory, 'subminer.js'); + fs.mkdirSync(launcherDirectory, { recursive: true }); + fs.copyFileSync(process.execPath, appPath); + fs.writeFileSync(script, 'console.log(JSON.stringify(process.argv.slice(2)));'); + fs.writeFileSync(path.join(launcherDirectory, 'version'), '1.0.0'); + const options = { + platform: process.platform, + env: { ...process.env, PATH: '', LOCALAPPDATA: root }, + bundledBunPath: process.execPath, + launcherResourcePath: script, + appExePath: appPath, + appVersion: '1.0.0', + localAppData: root, + getUserPath: () => '', + setUserPath: () => {}, + broadcastEnvironmentChange: () => {}, + }; + const snapshot = await installLauncher(options); + assert.equal(snapshot.status, 'ready', snapshot.message ?? 'install failed'); + const { getRunCommand } = await import('./command-line-launcher-deps'); + const args = ['spaces here', 'a&b', 'p%TEMP%q', 'bang!z', 'say "hi"', '日本語']; + const result = await getRunCommand({})(snapshot.installPath!, args, { env: options.env }); + assert.equal(result.exitCode, 0, result.stderr); + assert.deepEqual(JSON.parse(result.stdout), args); + }, +); - const first = stageManagedLauncher({ - platform: 'win32', - localAppData: root, - appVersion: '1.0.0', - bundledBunPath, - launcherResourcePath, - }); - const running = spawn(first.bunPath, ['-e', 'setInterval(() => {}, 1000)']); - await new Promise((resolve, reject) => { - running.once('spawn', resolve); - running.once('error', reject); - }); +test( + 'Windows stages a new runtime version while the prior Bun executable is running', + { timeout: WINDOWS_BUN_TEST_TIMEOUT_MS }, + async (t) => { + if (process.platform !== 'win32') return; + const root = workspace(t); + const bundledDirectory = path.join(root, 'packaged', 'bun'); + const bundledBunPath = path.join(bundledDirectory, 'bun.exe'); + const launcherResourcePath = path.join(root, 'packaged', 'launcher', 'subminer'); + fs.mkdirSync(path.join(bundledDirectory, 'licenses'), { recursive: true }); + fs.mkdirSync(path.dirname(launcherResourcePath), { recursive: true }); + fs.copyFileSync(process.execPath, bundledBunPath); + fs.writeFileSync(path.join(bundledDirectory, 'licenses', 'Bun-LICENSE.md'), 'license'); + fs.writeFileSync(launcherResourcePath, 'console.log("launcher");'); - const expectedSecond = windowsManagedRuntimePaths({ - platform: 'win32', - localAppData: root, - appVersion: '2.0.0', - }); - try { - const second = stageManagedLauncher({ + const first = stageManagedLauncher({ platform: 'win32', localAppData: root, - appVersion: '2.0.0', + appVersion: '1.0.0', bundledBunPath, launcherResourcePath, }); - assert.notEqual(second.bunPath, first.bunPath); - assert.ok(fs.existsSync(second.bunPath)); - cleanupOldWindowsManagedRuntimes({ + const running = spawn(first.bunPath, ['-e', 'setInterval(() => {}, 1000)']); + await new Promise((resolve, reject) => { + running.once('spawn', resolve); + running.once('error', reject); + }); + + const expectedSecond = windowsManagedRuntimePaths({ platform: 'win32', localAppData: root, appVersion: '2.0.0', }); - assert.ok(fs.existsSync(first.bunPath)); - assert.ok(fs.existsSync(path.join(path.dirname(first.bunPath), 'licenses', 'Bun-LICENSE.md'))); - assert.equal( - fs.readFileSync( - path.join(path.dirname(second.bunPath), 'licenses', 'Bun-LICENSE.md'), - 'utf8', - ), - 'license', - ); - } finally { - if (running.exitCode === null) { - const exited = new Promise((resolve) => running.once('exit', () => resolve())); - running.kill(); - await exited; + try { + const second = stageManagedLauncher({ + platform: 'win32', + localAppData: root, + appVersion: '2.0.0', + bundledBunPath, + launcherResourcePath, + }); + assert.notEqual(second.bunPath, first.bunPath); + assert.ok(fs.existsSync(second.bunPath)); + cleanupOldWindowsManagedRuntimes({ + platform: 'win32', + localAppData: root, + appVersion: '2.0.0', + }); + assert.ok(fs.existsSync(first.bunPath)); + assert.ok( + fs.existsSync(path.join(path.dirname(first.bunPath), 'licenses', 'Bun-LICENSE.md')), + ); + assert.equal( + fs.readFileSync( + path.join(path.dirname(second.bunPath), 'licenses', 'Bun-LICENSE.md'), + 'utf8', + ), + 'license', + ); + } finally { + if (running.exitCode === null) { + const exited = new Promise((resolve) => running.once('exit', () => resolve())); + running.kill(); + await exited; + } } - } - // Windows can keep bun.exe locked briefly after the exit event; cleanup - // skips locked runtimes, so retry the way the next launch would. - const firstDirectory = path.dirname(first.bunPath); - for (let attempt = 0; attempt < 30; attempt += 1) { - cleanupOldWindowsManagedRuntimes({ - platform: 'win32', - localAppData: root, - appVersion: '2.0.0', - }); - if (!fs.existsSync(firstDirectory)) break; - await new Promise((resolve) => setTimeout(resolve, 100)); - } - assert.equal(fs.existsSync(firstDirectory), false); - assert.ok(fs.existsSync(expectedSecond.bunPath)); -}); + // Windows can keep bun.exe locked briefly after the exit event; cleanup + // skips locked runtimes, so retry the way the next launch would. + const firstDirectory = path.dirname(first.bunPath); + for (let attempt = 0; attempt < 30; attempt += 1) { + cleanupOldWindowsManagedRuntimes({ + platform: 'win32', + localAppData: root, + appVersion: '2.0.0', + }); + if (!fs.existsSync(firstDirectory)) break; + await new Promise((resolve) => setTimeout(resolve, 100)); + } + assert.equal(fs.existsSync(firstDirectory), false); + assert.ok(fs.existsSync(expectedSecond.bunPath)); + }, +); test('Windows cleanup removes an obsolete runtime with no Bun executable', (t) => { if (process.platform !== 'win32') return; diff --git a/src/main/runtime/overlay-hosted-modal-open.ts b/src/main/runtime/overlay-hosted-modal-open.ts index f19b30f4..a1308812 100644 --- a/src/main/runtime/overlay-hosted-modal-open.ts +++ b/src/main/runtime/overlay-hosted-modal-open.ts @@ -1,23 +1,18 @@ import type { OverlayHostedModal } from '../../shared/ipc/contracts'; +import type { OverlayModalRuntime } from '../overlay-runtime'; export function openOverlayHostedModal( deps: { ensureOverlayStartupPrereqs: () => void; ensureOverlayWindowsReadyForVisibilityActions: () => void; - sendToActiveOverlayWindow: ( - channel: string, - payload?: unknown, - runtimeOptions?: { - restoreOnModalClose?: OverlayHostedModal; - preferModalWindow?: boolean; - }, - ) => boolean; + sendToActiveOverlayWindow: OverlayModalRuntime['sendToActiveOverlayWindow']; }, input: { channel: string; modal: OverlayHostedModal; payload?: unknown; preferModalWindow?: boolean; + signal?: AbortSignal; }, ): boolean { deps.ensureOverlayStartupPrereqs(); @@ -25,6 +20,7 @@ export function openOverlayHostedModal( return deps.sendToActiveOverlayWindow(input.channel, input.payload, { restoreOnModalClose: input.modal, preferModalWindow: input.preferModalWindow, + ...(input.signal ? { signal: input.signal } : {}), }); } diff --git a/src/main/runtime/overlay-runtime-main-actions.ts b/src/main/runtime/overlay-runtime-main-actions.ts index f1b01528..4f69b972 100644 --- a/src/main/runtime/overlay-runtime-main-actions.ts +++ b/src/main/runtime/overlay-runtime-main-actions.ts @@ -1,5 +1,5 @@ import type { RuntimeOptionState } from '../../types'; -import type { OverlayHostedModal } from '../../shared/ipc/contracts'; +import type { OverlayModalRuntime } from '../overlay-runtime'; type RuntimeOptionsManagerLike = { listOptions: () => RuntimeOptionState[]; @@ -47,16 +47,12 @@ export function createBroadcastRuntimeOptionsChangedHandler(deps: { } export function createSendToActiveOverlayWindowHandler(deps: { - sendToActiveOverlayWindowRuntime: ( - channel: string, - payload?: unknown, - runtimeOptions?: { restoreOnModalClose?: OverlayHostedModal }, - ) => boolean; + sendToActiveOverlayWindowRuntime: OverlayModalRuntime['sendToActiveOverlayWindow']; }) { return ( channel: string, payload?: unknown, - runtimeOptions?: { restoreOnModalClose?: OverlayHostedModal }, + runtimeOptions?: Parameters[2], ): boolean => deps.sendToActiveOverlayWindowRuntime(channel, payload, runtimeOptions); } 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..8b97429b --- /dev/null +++ b/src/main/runtime/plugin-panel-runtime.test.ts @@ -0,0 +1,146 @@ +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, openPluginPanelModal } from './plugin-panel-runtime'; + +function createHarness(openResult: Promise) { + const sent: Array<{ channel: string; payload: unknown }> = []; + const opened: PluginPanelSnapshot[] = []; + let openFailures = 0; + let modalCloses = 0; + const runtime = createPluginPanelRuntime({ + openModal: async (getSnapshot) => { + opened.push(getSnapshot()); + return await openResult; + }, + broadcast: (channel, payload) => sent.push({ channel, payload }), + closeModal: () => { + modalCloses += 1; + }, + onOpenFailed: () => { + openFailures += 1; + }, + }); + return { runtime, sent, opened, failures: () => openFailures, closes: () => modalCloses }; +} + +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; + + assert.equal(h.runtime.handleModalClosed(1), true); + panel.update({ markdown: 'too late' }); + + assert.equal(panel.signal.aborted, true); + assert.equal(panel.closed, true); + assert.equal(h.sent.length, sentBefore); +}); + +test('a late close acknowledgement for a replaced panel leaves the new panel open', () => { + const h = createHarness(Promise.resolve(true)); + const first = h.runtime.open({ title: 'One', markdown: '' }); + first.close(); + const second = h.runtime.open({ title: 'Two', markdown: '' }); + + assert.equal(h.runtime.handleModalClosed(1), false, 'skip teardown of the newer panel'); + assert.equal(second.signal.aborted, false); +}); + +test('a close notice after the plugin closed its panel still tears the modal down', () => { + const h = createHarness(Promise.resolve(true)); + h.runtime.open({ title: 'One', markdown: '' }).close(); + + assert.equal(h.runtime.handleModalClosed(1), true); +}); + +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.equal(h.closes(), 1); + 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); + assert.equal(h.closes(), 1); +}); + +for (const cancel of ['close', 'replace'] as const) { + test(`${cancel} cancels a pending plugin panel open before it can retry`, async () => { + const sent: unknown[] = []; + const acknowledgements: Array<(opened: boolean) => void> = []; + let modalCloses = 0; + const runtime = createPluginPanelRuntime({ + openModal: (getSnapshot, signal) => + openPluginPanelModal( + { + ensureOverlayStartupPrereqs: () => {}, + ensureOverlayWindowsReadyForVisibilityActions: () => {}, + sendToActiveOverlayWindow: (_channel, payload) => { + sent.push(payload); + return true; + }, + waitForModalOpen: () => new Promise((resolve) => acknowledgements.push(resolve)), + logWarn: () => {}, + }, + getSnapshot, + signal, + ), + closeModal: () => { + modalCloses += 1; + }, + broadcast: () => {}, + onOpenFailed: () => assert.fail('cancelled opens must not report failures'), + }); + const first = runtime.open({ title: 'first', markdown: '' }); + if (cancel === 'close') first.close(); + else runtime.open({ title: 'second', markdown: '' }); + acknowledgements[0]!(false); + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(first.closed, true); + assert.deepEqual(sent, [ + { panelId: 1, title: 'first', markdown: '' }, + ...(cancel === 'replace' ? [{ panelId: 2, title: 'second', markdown: '' }] : []), + ]); + assert.equal(modalCloses, cancel === 'close' ? 1 : 0); + acknowledgements[1]?.(true); + }); +} diff --git a/src/main/runtime/plugin-panel-runtime.ts b/src/main/runtime/plugin-panel-runtime.ts new file mode 100644 index 00000000..f4693b7d --- /dev/null +++ b/src/main/runtime/plugin-panel-runtime.ts @@ -0,0 +1,123 @@ +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, + signal: AbortSignal, +): Promise { + return await retryOverlayModalOpen(deps, { + modal: PLUGIN_PANEL_MODAL, + signal, + 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, + signal, + }), + }); +} + +/** + * 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, signal: AbortSignal) => Promise; + closeModal: () => void; + /** 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.closeModal(); + deps.onOpenFailed(); + }; + deps + .openModal(() => snapshot(state), state.controller.signal) + .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); + deps.closeModal(); + }, + }; + }, + + /** + * The overlay closed `panelId`. Returns false for a late notice about a panel that a newer one + * already replaced, so the caller skips the modal teardown that would hide the newer panel. + */ + handleModalClosed(panelId: number): boolean { + if (!active) return true; + if (active.panelId !== panelId) return false; + finish(active); + return true; + }, + }; +} diff --git a/src/main/runtime/plugin-window-runtime.test.ts b/src/main/runtime/plugin-window-runtime.test.ts new file mode 100644 index 00000000..4b61fb88 --- /dev/null +++ b/src/main/runtime/plugin-window-runtime.test.ts @@ -0,0 +1,91 @@ +import assert from 'node:assert/strict'; +import { EventEmitter } from 'node:events'; +import test from 'node:test'; + +import type { BrowserWindowConstructorOptions } from 'electron'; + +import { createPluginWindowRuntime } from './plugin-window-runtime'; + +function createFakeWindow(loadResult: Promise) { + const events = new EventEmitter(); + const calls: string[] = []; + const window = { + calls, + destroyed: false, + isDestroyed: () => window.destroyed, + show: () => calls.push('show'), + focus: () => calls.push('focus'), + loadFile: (filePath: string) => { + calls.push(`load:${filePath}`); + return loadResult; + }, + destroy: () => { + window.destroyed = true; + events.emit('closed'); + }, + once: (event: 'closed', listener: () => void) => events.once(event, listener), + }; + return window; +} + +const request = { + key: 'viewer:/plugins/viewer/index.html', + htmlPath: '/plugins/viewer/index.html', + preloadPath: '/plugins/viewer/preload.js', + title: 'Viewer', + width: 400, + height: 300, +}; + +test('reopening a key focuses the open window and a closed window is recreated', () => { + const created: Array> = []; + const options: BrowserWindowConstructorOptions[] = []; + const promoted: unknown[] = []; + const runtime = createPluginWindowRuntime({ + createBrowserWindow: (windowOptions) => { + options.push(windowOptions); + const window = createFakeWindow(Promise.resolve()); + created.push(window); + return window; + }, + promoteAboveOverlay: (window) => promoted.push(window), + logError: () => {}, + }); + + const first = runtime.open(request); + const again = runtime.open(request); + assert.equal(first.created, true); + assert.equal(again.created, false); + assert.equal(again.window, first.window); + assert.deepEqual(first.window.calls, [ + 'load:/plugins/viewer/index.html', + 'show', + 'focus', + 'show', + 'focus', + ]); + assert.equal(promoted.length, 2); + assert.equal(options[0]?.webPreferences?.preload, '/plugins/viewer/preload.js'); + assert.equal(options[0]?.webPreferences?.contextIsolation, true); + + first.window.destroy(); + const reopened = runtime.open(request); + assert.equal(reopened.created, true); + assert.equal(created.length, 2); +}); + +test('a window whose page fails to load is destroyed and logged', async () => { + const errors: string[] = []; + const window = createFakeWindow(Promise.reject(new Error('ENOENT'))); + const runtime = createPluginWindowRuntime({ + createBrowserWindow: () => window, + promoteAboveOverlay: () => {}, + logError: (message) => errors.push(message), + }); + + runtime.open(request); + await new Promise((resolve) => setImmediate(resolve)); + + assert.equal(window.destroyed, true); + assert.deepEqual(errors, ['Failed to load plugin window /plugins/viewer/index.html: ENOENT']); +}); diff --git a/src/main/runtime/plugin-window-runtime.ts b/src/main/runtime/plugin-window-runtime.ts new file mode 100644 index 00000000..cb0ef71c --- /dev/null +++ b/src/main/runtime/plugin-window-runtime.ts @@ -0,0 +1,64 @@ +import type { BrowserWindowConstructorOptions } from 'electron'; + +import type { PluginWindowRequest } from '../../core/services/plugins/types'; + +interface PluginWindowLike { + isDestroyed(): boolean; + show(): void; + focus(): void; + loadFile(filePath: string): Promise; + destroy(): void; + once(event: 'closed', listener: () => void): unknown; +} + +/** + * Creates plugin windows and keeps one per request key, so a plugin that reopens the same page + * focuses the existing window. Windows are raised above the overlay like the settings windows. + */ +export function createPluginWindowRuntime(deps: { + createBrowserWindow: (options: BrowserWindowConstructorOptions) => TWindow; + promoteAboveOverlay: (window: TWindow) => void; + logError: (message: string) => void; +}) { + const windows = new Map(); + + const showAndFocus = (window: TWindow): void => { + window.show(); + window.focus(); + deps.promoteAboveOverlay(window); + }; + + return { + open(request: PluginWindowRequest): { window: TWindow; created: boolean } { + const existing = windows.get(request.key); + if (existing && !existing.isDestroyed()) { + showAndFocus(existing); + return { window: existing, created: false }; + } + + const window = deps.createBrowserWindow({ + width: request.width, + height: request.height, + title: request.title, + show: false, + autoHideMenuBar: true, + webPreferences: { + nodeIntegration: false, + contextIsolation: true, + ...(request.preloadPath ? { preload: request.preloadPath } : {}), + }, + }); + windows.set(request.key, window); + window.once('closed', () => { + if (windows.get(request.key) === window) windows.delete(request.key); + }); + window.loadFile(request.htmlPath).catch((error: unknown) => { + const message = error instanceof Error ? error.message : String(error); + deps.logError(`Failed to load plugin window ${request.htmlPath}: ${message}`); + if (!window.isDestroyed()) window.destroy(); + }); + showAndFocus(window); + return { window, created: true }; + }, + }; +} diff --git a/src/plugin-api.test.ts b/src/plugin-api.test.ts new file mode 100644 index 00000000..854d7eb3 --- /dev/null +++ b/src/plugin-api.test.ts @@ -0,0 +1,62 @@ +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { copyFileSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; + +test('plugin declarations work without SubMiner sources', { timeout: 30_000 }, () => { + const root = mkdtempSync(path.join(os.tmpdir(), 'subminer-plugin-types-')); + try { + copyFileSync(path.join(__dirname, 'plugin-api.ts'), path.join(root, 'subminer.d.ts')); + symlinkSync( + path.resolve(__dirname, '../node_modules'), + path.join(root, 'node_modules'), + 'junction', + ); + writeFileSync( + path.join(root, 'tsconfig.json'), + JSON.stringify({ + compilerOptions: { + strict: true, + noEmit: true, + module: 'Node16', + target: 'ES2022', + types: ['node'], + }, + files: ['plugin.ts', 'subminer.d.ts'], + }), + ); + writeFileSync( + path.join(root, 'plugin.ts'), + ` + import type { PluginContext, PluginMediaSnapshot } from './subminer'; + export function activate(ctx: PluginContext) { + const version: 1 = ctx.apiVersion; + const initial: PluginMediaSnapshot | null = ctx.media.current(); + ctx.onDispose(ctx.media.on('change', media => ctx.log.info(String(media?.playbackId)))); + ctx.anki.on('card-mined', ({ notes }) => { + for (const { originalNoteId, noteId, provenance } of notes) { + ctx.log.info(String({ originalNoteId, noteId, source: provenance?.media?.path })); + } + }); + ctx.actions.register('pause', async () => { + await ctx.mpv.request(['set_property', 'pause', true]); + await ctx.actions.run('openJimaku'); + }); + ctx.log.info(String({ version, initial, aborted: ctx.signal.aborted })); + } + `, + ); + const output = execFileSync( + process.execPath, + [require.resolve('typescript/bin/tsc'), '-p', root], + { + encoding: 'utf8', + }, + ); + assert.equal(output, ''); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/src/plugin-api.ts b/src/plugin-api.ts new file mode 100644 index 00000000..a183101c --- /dev/null +++ b/src/plugin-api.ts @@ -0,0 +1,262 @@ +/** Public plugin API v1. This file can also be copied into a plugin as subminer.d.ts. */ +import type { BrowserWindow } from 'electron'; + +export type SessionActionId = + | 'toggleStatsOverlay' + | 'markWatched' + | 'toggleVisibleOverlay' + | 'copySubtitle' + | 'copySubtitleMultiple' + | 'updateLastCardFromClipboard' + | 'triggerFieldGrouping' + | 'triggerSubsync' + | 'mineSentence' + | 'mineSentenceMultiple' + | 'toggleSecondarySub' + | 'toggleSubtitleSidebar' + | 'toggleNotificationHistory' + | 'appendClipboardVideoToQueue' + | 'markAudioCard' + | 'openRuntimeOptions' + | 'openSessionHelp' + | 'openCharacterDictionaryManager' + | 'openControllerSelect' + | 'openControllerDebug' + | 'openJimaku' + | 'openTsukihime' + | 'openSubtitleSelection' + | 'openSubtitleGeneration' + | 'openYoutubePicker' + | 'openPlaylistBrowser' + | 'replayCurrentSubtitle' + | 'playNextSubtitle' + | 'cycleRuntimeOption' + | 'runPluginAction'; + +export interface SessionActionPayload { + count?: number; + runtimeOptionId?: string; + direction?: 1 | -1; + /** `/` for runPluginAction. */ + pluginActionId?: string; +} + +export interface PluginMpvEventMap { + 'connection-change': { connected: boolean }; + 'subtitle-change': { text: string; isOverlayVisible: boolean }; + 'subtitle-timing': { text: string; start: number; end: number }; + 'time-pos-change': { time: number }; + 'duration-change': { duration: number }; + 'pause-change': { paused: boolean }; + 'fullscreen-change': { fullscreen: boolean }; + 'secondary-subtitle-change': { text: string }; + 'subtitle-track-change': { sid: number | null }; + 'secondary-subtitle-track-change': { sid: number | null }; + 'secondary-subtitle-delay-change': { delay: number }; + 'subtitle-track-list-change': { trackList: unknown[] | null }; + 'media-path-change': { path: string }; + 'media-title-change': { title: string | null }; + 'secondary-subtitle-visibility': { visible: boolean }; + 'client-message': { args: string[] }; + 'file-loaded': undefined; + 'end-file': { + reason: 'eof' | 'stop' | 'quit' | 'error' | 'redirect' | 'unknown'; + playlistEntryId: number | null; + error: string | null; + }; +} + +export type PluginMpvEventName = keyof PluginMpvEventMap; + +/** A cached playback snapshot. A new load gets a new id, including reopening the same path. */ +export interface PluginMediaSnapshot { + readonly playbackId: number; + readonly path: string; + readonly title: string | null; +} + +/** Captured when mining starts. Null provenance means the source could not be established. */ +export interface PluginMiningProvenance { + readonly minedAt: number; + readonly media: PluginMediaSnapshot | null; + readonly subtitle: PluginSubtitleLine | null; +} + +export interface PluginMinedNote { + readonly originalNoteId: number; + /** The note that kept the content after any field grouping. */ + readonly noteId: number; + readonly provenance: PluginMiningProvenance | null; +} + +/** Anki events plugins may observe. */ +export interface PluginAnkiEventMap { + /** + * SubMiner finished writing a mined card, or polling saw it while auto-update is off. + * Field grouping may have merged the note into another one by the time this fires. + */ + 'card-mined': { noteIds: number[]; notes: PluginMinedNote[] }; +} +export type PluginAnkiEventName = keyof PluginAnkiEventMap; + +export interface PluginAnkiInvokeOptions { + /** Request timeout; AnkiConnect's default is 10 seconds. */ + timeoutMs?: number; +} + +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 PluginWindowOptions { + /** HTML file to load, relative to the plugin directory. */ + html: string; + /** Preload script, relative to the plugin directory. Runs with context isolation. */ + preload?: string; + /** Window title; Hyprland matches it to float the window above the overlay. */ + title?: string; + width?: number; + height?: number; +} + +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 apiVersion: 1; + readonly id: string; + /** Aborts as unloading starts, before deactivate runs. */ + readonly signal: AbortSignal; + /** Registers cleanup; runs immediately if unloading has started. The disposer runs it once. */ + onDispose: (dispose: () => void) => Disposer; + /** Absolute path of the plugin directory. */ + readonly dir: string; + /** `plugins..settings` from config. */ + readonly settings: Readonly>; + /** + * `/plugin-data//` for caches and state. Kept apart from `dir`, so replacing the + * plugin folder keeps its data. Created before `activate`. + */ + readonly dataDir: string; + readonly log: PluginLogger; + /** Repeating timer; cleared automatically when the plugin unloads. */ + every: (intervalMs: number, callback: () => unknown) => Disposer; + /** 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; + /** Resolves the mpv result; rejects on command error, disconnect, or a 4-second timeout. */ + request: (command: (string | number | boolean)[]) => Promise; + getProperty: (name: string) => Promise; + /** Connects to mpv, launching an idle mpv if none is running. Resolves false when that fails. */ + ensureRunning: () => Promise; + }; + readonly media: { + current: () => PluginMediaSnapshot | null; + /** Immediately delivers the current snapshot unless emitCurrent is false. */ + on: ( + event: 'change', + listener: (media: PluginMediaSnapshot | null) => unknown, + options?: { emitCurrent?: boolean }, + ) => Disposer; + }; + readonly anki: { + on: ( + event: E, + listener: (payload: PluginAnkiEventMap[E]) => unknown, + ) => Disposer; + /** Sends one AnkiConnect action to the configured `ankiConnect.url`. Rejects on an Anki error. */ + invoke: ( + action: string, + params?: Record, + options?: PluginAnkiInvokeOptions, + ) => 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; + /** Runs a built-in session action, the same ones keybindings use (e.g. `openJimaku`). */ + run: (actionId: SessionActionId, payload?: SessionActionPayload) => Promise; + }; + notify: (message: string, options?: { variant?: PluginNotifyVariant }) => void; + readonly ui: { + /** Opens the shared plugin panel, replacing any panel that is already open. */ + panel: (content: Partial & { markdown: string }) => PluginPanelHandle; + /** + * Opens a window for this plugin, or focuses it if the same `html` is already open. + * The window is destroyed when the plugin unloads. + */ + openWindow: (options: PluginWindowOptions) => BrowserWindow; + }; + /** Runs a binary without a shell, with the AppImage library path removed from its environment. */ + exec: (command: string, args: string[], options?: PluginExecOptions) => Promise; + /** 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/preload.ts b/src/preload.ts index 45a19000..38473fe5 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, @@ -506,8 +532,8 @@ const electronAPI: ElectronAPI = { mediaId, direction, ), - notifyOverlayModalClosed: (modal) => { - ipcRenderer.send(IPC_CHANNELS.command.overlayModalClosed, modal); + notifyOverlayModalClosed: (modal, instanceId) => { + ipcRenderer.send(IPC_CHANNELS.command.overlayModalClosed, modal, instanceId); }, notifyOverlayModalOpened: (modal) => { ipcRenderer.send(IPC_CHANNELS.command.overlayModalOpened, modal); 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 @@ +