mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-10-26 23:35:17 -07:00
feat(plugins): add plugin windows, CLI actions, and data dirs
- Add `--plugin-action <id>/<name>` CLI flag, dispatched via runPluginAction - Add ctx.ui.openWindow for plugin-owned BrowserWindows with sandboxed preload - Add ctx.dataDir (`plugin-data/<id>/`), ctx.actions.run, ctx.mpv.ensureRunning - Emit mpv end-file events to plugins with reason, entry id, and error - Run Windows .cmd/.bat shims through cmd.exe in exec, killing the tree on abort - Update plugin docs, usage, architecture notes, and e2e coverage
This commit is contained in:
28 files changed
+843
-77
No files matched your search
@@ -1,5 +1,5 @@
|
|||||||
type: added
|
type: added
|
||||||
area: plugins
|
area: plugins
|
||||||
|
|
||||||
- Added JavaScript plugins loaded at startup from `<config dir>/plugins/<id>/` and enabled under `plugins` in the config. Plugins can listen to mpv events, run timers, register actions bound with `__plugin-action:<id>/<action>` keybindings, run programs, and show markdown results in an overlay panel.
|
- Added JavaScript plugins loaded at startup from `<config dir>/plugins/<id>/` and enabled under `plugins` in the config. Plugins can listen to mpv events, run timers, register actions bound with `__plugin-action:<id>/<action>` keybindings or run with `--plugin-action`, run built-in actions, start mpv, run programs, keep state in a per-plugin data folder, and show results in an overlay panel or their own window.
|
||||||
- Added an example `ai-explain` plugin that sends the current line and its neighbors to `codex exec` and shows a translation or grammar explanation.
|
- Added an example `ai-explain` plugin that sends the current line and its neighbors to `codex exec` and shows a translation or grammar explanation.
|
||||||
+11
-3
@@ -32,6 +32,8 @@ These are SubMiner app plugins. The Lua script that runs inside mpv is the separ
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
To run an action from a script or a window-manager shortcut, use `subminer app --plugin-action ai-explain/explain`. It goes to the running SubMiner, or starts one if none is running.
|
||||||
|
|
||||||
4. Restart SubMiner. Plugin changes apply on the next start; keybinding changes apply immediately.
|
4. Restart SubMiner. Plugin changes apply on the next start; keybinding changes apply immediately.
|
||||||
|
|
||||||
Load errors and plugin log output go to the app log with a `plugin:<id>` scope.
|
Load errors and plugin log output go to the app log with a `plugin:<id>` scope.
|
||||||
@@ -85,24 +87,30 @@ An error thrown by a plugin is logged and does not crash SubMiner. A plugin that
|
|||||||
### `ctx` reference
|
### `ctx` reference
|
||||||
|
|
||||||
| Member | What it does |
|
| Member | What it does |
|
||||||
| -------------------------------------- | ------------------------------------------------------------------------------------------------------- |
|
| -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `id`, `dir`, `settings` | Plugin id, plugin folder path, and `plugins.<id>.settings` |
|
| `id`, `dir`, `settings` | Plugin id, plugin folder path, and `plugins.<id>.settings` |
|
||||||
|
| `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 |
|
| `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 |
|
| `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.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.command(args)` | Send an mpv command, e.g. `['set', 'pause', 'yes']` |
|
||||||
| `mpv.getProperty(name)` | Read an mpv property (Promise) |
|
| `mpv.getProperty(name)` | Read an mpv property (Promise) |
|
||||||
| `mpv.connected` | Whether mpv is connected |
|
| `mpv.connected` | Whether mpv is connected |
|
||||||
|
| `mpv.ensureRunning()` | Connect to mpv, starting an idle mpv if none is running. Resolves `false` if that fails |
|
||||||
| `subtitles.current()` | The displayed line as `{ text, startTime, endTime }`, or `null` |
|
| `subtitles.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 |
|
| `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.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` |
|
| `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.panel({ title, markdown })` | Open the plugin panel. Returns `{ update(content), close(), closed, signal }` |
|
||||||
| `exec(command, args, options)` | Run a program without a shell. Resolves `{ exitCode, stdout, stderr }` |
|
| `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 |
|
| `unstable` | SubMiner internals. These change between releases without notice |
|
||||||
|
|
||||||
A panel's `signal` aborts when the user closes it or another panel replaces it. Pass it to `exec` to stop work nobody will see. Only one panel is open at a time. Panel markdown supports headings, lists, quotes, code, bold, and italics; HTML is shown as plain text.
|
A panel's `signal` aborts when the user closes it or another panel replaces it. Pass it to `exec` to stop work nobody will see. Only one panel is open at a time. Panel markdown supports headings, lists, quotes, code, bold, and italics; HTML is shown as plain text.
|
||||||
|
|
||||||
`exec` options are `stdin`, `cwd`, `env`, `timeoutMs`, and `signal`. It resolves for any exit code and rejects when the program can't start, times out, or is aborted.
|
`exec` options are `stdin`, `cwd`, `env`, `timeoutMs`, and `signal`. It resolves for any exit code and rejects when the program can't start, times out, or is aborted.
|
||||||
|
|
||||||
mpv events: `connection-change`, `subtitle-change`, `subtitle-timing`, `time-pos-change`, `duration-change`, `pause-change`, `fullscreen-change`, `media-path-change`, `media-title-change`, `subtitle-track-change`, `subtitle-track-list-change`, `secondary-subtitle-change`, `secondary-subtitle-track-change`, `secondary-subtitle-delay-change`, `secondary-subtitle-visibility`, and `client-message`.
|
`ui.openWindow` takes `html` and an optional `preload`, both relative to the plugin folder, plus `title`, `width`, and `height`. Opening the same `html` again focuses the open window. The page runs with context isolation and no Node access. The preload is sandboxed too: it can `require('electron')` but nothing else, so bundle anything more into it. Expose what the page needs from the preload with `contextBridge`, and handle its messages with `ipcMain` from `require('electron')` in the plugin. Prefix channel names with the plugin id. Plugin windows close when SubMiner quits.
|
||||||
|
|
||||||
|
mpv events: `connection-change`, `subtitle-change`, `subtitle-timing`, `time-pos-change`, `duration-change`, `pause-change`, `fullscreen-change`, `media-path-change`, `media-title-change`, `subtitle-track-change`, `subtitle-track-list-change`, `secondary-subtitle-change`, `secondary-subtitle-track-change`, `secondary-subtitle-delay-change`, `secondary-subtitle-visibility`, `client-message`, and `end-file`. `end-file` gives `{ reason, playlistEntryId, error }`, where `reason` is `eof`, `stop`, `quit`, `error`, `redirect`, or `unknown`.
|
||||||
@@ -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 generate-subs video.mkv # make Japanese subtitles from the audio
|
||||||
subminer logs -e # export a log ZIP for bug reports
|
subminer logs -e # export a log ZIP for bug reports
|
||||||
subminer app --setup # reopen first-run setup
|
subminer app --setup # reopen first-run setup
|
||||||
|
subminer app --plugin-action <id>/<name> # run a plugin action
|
||||||
subminer -u # update SubMiner
|
subminer -u # update SubMiner
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ Update checks and startup launcher migration share a serialized update-state sto
|
|||||||
- `subtitle-generation-reference.ts` ranks mpv's loaded text subtitle tracks, excludes signs/songs and forced references, and extracts timing hints with FFmpeg. The overlay and launcher snapshot references only for matching media, including active subtitle delays. Hints guide long-passage cuts with or without VAD; they never limit audio coverage or replace Whisper timestamps.
|
- `subtitle-generation-reference.ts` ranks mpv's loaded text subtitle tracks, excludes signs/songs and forced references, and extracts timing hints with FFmpeg. The overlay and launcher snapshot references only for matching media, including active subtitle delays. Hints guide long-passage cuts with or without VAD; they never limit audio coverage or replace Whisper timestamps.
|
||||||
- `src/main/runtime/subtitle-selection.ts` reads and validates mpv subtitle tracks and applies primary/secondary selections. Its opt-in session shortcut opens the shared overlay modal window, with renderer focus and subtitle suppression handled by the modal registry.
|
- `src/main/runtime/subtitle-selection.ts` reads and validates mpv subtitle tracks and applies primary/secondary selections. Its opt-in session shortcut opens the shared overlay modal window, with renderer focus and subtitle suppression handled by the modal registry.
|
||||||
- `src/shared/session-key-sequences.ts` rejects sequence prefixes reserved by single-key actions. The session-binding compiler reserves configured and built-in overlay keys; `src/main/runtime/session-bindings-runtime.ts` adds active mpv bindings and publishes the effective list to both the plugin artifact and the renderer through `session-bindings:changed`. mpv no-op `ignore` bindings do not reserve prefixes.
|
- `src/shared/session-key-sequences.ts` rejects sequence prefixes reserved by single-key actions. The session-binding compiler reserves configured and built-in overlay keys; `src/main/runtime/session-bindings-runtime.ts` adds active mpv bindings and publishes the effective list to both the plugin artifact and the renderer through `session-bindings:changed`. mpv no-op `ignore` bindings do not reserve prefixes.
|
||||||
- `src/core/services/plugins/` owns user plugins: discovery from `<config dir>/plugins/<id>/plugin.json` for ids enabled in `plugins`, the per-plugin `ctx`, guarded callbacks, and `exec` (no shell, AppImage `LD_LIBRARY_PATH` dropped). `main.ts` supplies the host's deps, attaches each new mpv client so plugin listeners survive client replacement, starts plugins from the app-ready path after logging is configured, and unloads them on `before-quit`. Plugins run in the main process with no sandbox. Plugin actions are the `runPluginAction` session action (keybinding command `__plugin-action:<id>/<name>`). `src/main/runtime/plugin-panel-runtime.ts` owns the single `plugin-panel` modal: a new panel aborts the previous handle's signal, updates broadcast to both overlay windows keyed by `panelId`, and the latest content is re-sent once the renderer acknowledges the open.
|
- `src/core/services/plugins/` owns user plugins: discovery from `<config dir>/plugins/<id>/plugin.json` for ids enabled in `plugins`, the per-plugin `ctx`, guarded callbacks, and `exec` (no shell, AppImage `LD_LIBRARY_PATH` dropped). `main.ts` supplies the host's deps, attaches each new mpv client so plugin listeners survive client replacement, starts plugins from the app-ready path after logging is configured, and unloads them on `before-quit`. Plugins run in the main process with no sandbox. Plugin actions are the `runPluginAction` session action (keybinding command `__plugin-action:<id>/<name>`, 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`. `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/renderer/` owns overlay rendering and input behavior.
|
||||||
- `src/config/` owns config definitions, defaults, loading, and resolution.
|
- `src/config/` owns config definitions, defaults, loading, and resolution.
|
||||||
- `src/types/` owns shared cross-runtime contracts via domain entrypoints; `src/types.ts` stays a compatibility barrel.
|
- `src/types/` owns shared cross-runtime contracts via domain entrypoints; `src/types.ts` stays a compatibility barrel.
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ Read when: you need to find the owner module for a behavior or test surface
|
|||||||
- AniList tracking + character dictionary: `src/core/services/anilist/`, `src/main/runtime/composers/anilist-*`, `src/main/character-dictionary-runtime.ts`, `src/main/character-dictionary-runtime/`
|
- AniList tracking + character dictionary: `src/core/services/anilist/`, `src/main/runtime/composers/anilist-*`, `src/main/character-dictionary-runtime.ts`, `src/main/character-dictionary-runtime/`
|
||||||
- TMDB live-action metadata: `src/core/services/tmdb/` (client + exact-title resolver), `src/core/services/immersion-tracker/live-action-link.ts` (links an entry to a TMDB title and merges other holders of the same title). The AniList cover-art fetcher calls the resolver as its fallback; `imm_anime.media_kind` marks the result and keeps the entry out of AniList season repair.
|
- TMDB live-action metadata: `src/core/services/tmdb/` (client + exact-title resolver), `src/core/services/immersion-tracker/live-action-link.ts` (links an entry to a TMDB title and merges other holders of the same title). The AniList cover-art fetcher calls the resolver as its fallback; `imm_anime.media_kind` marks the result and keeps the entry out of AniList season repair.
|
||||||
- Jellyfin integration: `src/core/services/jellyfin*.ts`, `src/main/runtime/composers/jellyfin-*`
|
- Jellyfin integration: `src/core/services/jellyfin*.ts`, `src/main/runtime/composers/jellyfin-*`
|
||||||
- User plugins: `src/core/services/plugins/` (host, `ctx`, `exec`), `src/main/runtime/plugin-panel-runtime.ts` (panel modal), `src/renderer/modals/plugin-panel*.ts`; example in `assets/plugins/`
|
- User plugins: `src/core/services/plugins/` (host, `ctx`, `exec`), `src/main/runtime/plugin-panel-runtime.ts` (panel modal), `src/main/runtime/plugin-window-runtime.ts` (plugin windows), `src/renderer/modals/plugin-panel*.ts`; example in `assets/plugins/`
|
||||||
- Window trackers: `src/window-trackers/`
|
- Window trackers: `src/window-trackers/`
|
||||||
- Stats HTTP app: `src/core/services/stats-server.ts`, with route groups and shared route support
|
- Stats HTTP app: `src/core/services/stats-server.ts`, with route groups and shared route support
|
||||||
in `src/core/services/stats-server/`
|
in `src/core/services/stats-server/`
|
||||||
|
|||||||
@@ -14,7 +14,14 @@ const TEST_TIMEOUT_MS = 120_000;
|
|||||||
// Opens a panel describing the subtitle context and a child process result, and
|
// 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.
|
// logs when the panel's signal aborts so the test can see the close reach main.
|
||||||
const PROBE_PLUGIN = `
|
const PROBE_PLUGIN = `
|
||||||
|
const { ipcMain } = require('electron');
|
||||||
|
|
||||||
exports.activate = (ctx) => {
|
exports.activate = (ctx) => {
|
||||||
|
ipcMain.handle('e2e-probe:info', () => ({ dataDir: ctx.dataDir, greeting: ctx.settings.greeting }));
|
||||||
|
ctx.actions.register('window', () => {
|
||||||
|
ctx.ui.openWindow({ html: 'ui/index.html', preload: 'preload.js', title: 'E2E Probe Window' });
|
||||||
|
});
|
||||||
|
ctx.mpv.on('end-file', ({ reason }) => ctx.log.info('probe end-file ' + reason));
|
||||||
ctx.actions.register('show', () => {
|
ctx.actions.register('show', () => {
|
||||||
const { before, current, after } = ctx.subtitles.context({ before: 1, after: 1 });
|
const { before, current, after } = ctx.subtitles.context({ before: 1, after: 1 });
|
||||||
const panel = ctx.ui.panel({ title: 'Probe', markdown: '_working_' });
|
const panel = ctx.ui.panel({ title: 'Probe', markdown: '_working_' });
|
||||||
@@ -38,9 +45,29 @@ exports.activate = (ctx) => {
|
|||||||
)
|
)
|
||||||
.catch((error) => panel.update({ markdown: 'exec failed: ' + error.message }));
|
.catch((error) => panel.update({ markdown: 'exec failed: ' + error.message }));
|
||||||
});
|
});
|
||||||
|
return () => ipcMain.removeHandler('e2e-probe:info');
|
||||||
};
|
};
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
const PROBE_PRELOAD = `
|
||||||
|
const { contextBridge, ipcRenderer } = require('electron');
|
||||||
|
contextBridge.exposeInMainWorld('probe', { info: () => ipcRenderer.invoke('e2e-probe:info') });
|
||||||
|
`;
|
||||||
|
|
||||||
|
const PROBE_WINDOW_HTML = `<!doctype html>
|
||||||
|
<html>
|
||||||
|
<head><title>E2E Probe Window</title></head>
|
||||||
|
<body>
|
||||||
|
<pre id="out">loading</pre>
|
||||||
|
<script>
|
||||||
|
window.probe.info().then((info) => {
|
||||||
|
document.getElementById('out').textContent = JSON.stringify(info);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`;
|
||||||
|
|
||||||
type Started = { session: E2eSession; overlay: CdpPage };
|
type Started = { session: E2eSession; overlay: CdpPage };
|
||||||
let started: Promise<Started> | undefined;
|
let started: Promise<Started> | undefined;
|
||||||
|
|
||||||
@@ -51,6 +78,8 @@ function useSession(): Promise<Started> {
|
|||||||
files: {
|
files: {
|
||||||
'plugins/e2e-probe/plugin.json': JSON.stringify({ name: 'E2E Probe', main: 'main.cjs' }),
|
'plugins/e2e-probe/plugin.json': JSON.stringify({ name: 'E2E Probe', main: 'main.cjs' }),
|
||||||
'plugins/e2e-probe/main.cjs': PROBE_PLUGIN,
|
'plugins/e2e-probe/main.cjs': PROBE_PLUGIN,
|
||||||
|
'plugins/e2e-probe/preload.js': PROBE_PRELOAD,
|
||||||
|
'plugins/e2e-probe/ui/index.html': PROBE_WINDOW_HTML,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
@@ -148,3 +177,41 @@ test(
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
test(
|
||||||
|
'a CLI plugin action opens a plugin window whose preload talks to the plugin',
|
||||||
|
{ timeout: TEST_TIMEOUT_MS },
|
||||||
|
async () => {
|
||||||
|
const { session } = await useSession();
|
||||||
|
|
||||||
|
await session.runAppCommand('--plugin-action', 'e2e-probe/window');
|
||||||
|
|
||||||
|
const page = await session.openPage('plugins/e2e-probe/ui/index.html');
|
||||||
|
const info = await page.waitFor<{ dataDir: string; greeting: string } | false>(
|
||||||
|
`(() => {
|
||||||
|
const text = document.getElementById('out')?.textContent;
|
||||||
|
return text && text !== 'loading' ? JSON.parse(text) : false;
|
||||||
|
})()`,
|
||||||
|
{ description: 'plugin window to read plugin info over IPC' },
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(info.greeting, 'hello');
|
||||||
|
assert.equal(path.basename(info.dataDir), 'e2e-probe');
|
||||||
|
assert.equal(path.basename(path.dirname(info.dataDir)), 'plugin-data');
|
||||||
|
assert.equal(fs.statSync(info.dataDir).isDirectory(), true);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test('plugins receive mpv end-file events', { timeout: TEST_TIMEOUT_MS }, async () => {
|
||||||
|
const { session } = await useSession();
|
||||||
|
const mediaPath = await session.mpv.command('get_property', 'path');
|
||||||
|
|
||||||
|
// Replacing the playing file ends it with reason "stop".
|
||||||
|
await session.mpv.command('loadfile', String(mediaPath), 'replace');
|
||||||
|
|
||||||
|
await waitUntil(
|
||||||
|
() =>
|
||||||
|
fs.readFileSync(path.join(session.root, 'app.log'), 'utf8').includes('probe end-file stop'),
|
||||||
|
{ description: 'the plugin to log the end-file reason' },
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -340,6 +340,14 @@ test('hasExplicitCommand and shouldStartApp preserve command intent', () => {
|
|||||||
assert.equal(shouldStartApp(sessionAction), true);
|
assert.equal(shouldStartApp(sessionAction), true);
|
||||||
assert.equal(commandNeedsOverlayRuntime(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']);
|
const toggleStatsOverlayRuntime = parseArgs(['--toggle-stats-overlay']);
|
||||||
assert.equal(commandNeedsOverlayRuntime(toggleStatsOverlayRuntime), true);
|
assert.equal(commandNeedsOverlayRuntime(toggleStatsOverlayRuntime), true);
|
||||||
|
|
||||||
|
|||||||
@@ -48,6 +48,8 @@ export interface CliArgs {
|
|||||||
cycleRuntimeOptionId?: string;
|
cycleRuntimeOptionId?: string;
|
||||||
cycleRuntimeOptionDirection?: 1 | -1;
|
cycleRuntimeOptionDirection?: 1 | -1;
|
||||||
sessionAction?: SessionActionDispatchRequest;
|
sessionAction?: SessionActionDispatchRequest;
|
||||||
|
/** `<plugin id>/<action name>` to run, like a `__plugin-action:` keybinding. */
|
||||||
|
pluginAction?: string;
|
||||||
copySubtitleCount?: number;
|
copySubtitleCount?: number;
|
||||||
mineSentenceCount?: number;
|
mineSentenceCount?: number;
|
||||||
anilistStatus: boolean;
|
anilistStatus: boolean;
|
||||||
@@ -338,6 +340,12 @@ export function parseArgs(argv: string[]): CliArgs {
|
|||||||
} else if (arg === '--session-action') {
|
} else if (arg === '--session-action') {
|
||||||
const parsed = parseSessionAction(readValue(argv[i + 1]));
|
const parsed = parseSessionAction(readValue(argv[i + 1]));
|
||||||
if (parsed) args.sessionAction = parsed;
|
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=')) {
|
} else if (arg.startsWith('--copy-subtitle-count=')) {
|
||||||
const value = Number(arg.split('=', 2)[1]);
|
const value = Number(arg.split('=', 2)[1]);
|
||||||
if (Number.isInteger(value) && value > 0) args.copySubtitleCount = value;
|
if (Number.isInteger(value) && value > 0) args.copySubtitleCount = value;
|
||||||
@@ -601,6 +609,7 @@ export function hasExplicitCommand(args: CliArgs): boolean {
|
|||||||
args.playbackFeedback !== undefined ||
|
args.playbackFeedback !== undefined ||
|
||||||
args.cycleRuntimeOptionId !== undefined ||
|
args.cycleRuntimeOptionId !== undefined ||
|
||||||
args.sessionAction !== undefined ||
|
args.sessionAction !== undefined ||
|
||||||
|
args.pluginAction !== undefined ||
|
||||||
args.copySubtitleCount !== undefined ||
|
args.copySubtitleCount !== undefined ||
|
||||||
args.mineSentenceCount !== undefined ||
|
args.mineSentenceCount !== undefined ||
|
||||||
args.anilistStatus ||
|
args.anilistStatus ||
|
||||||
@@ -682,6 +691,7 @@ export function isStandaloneTexthookerCommand(args: CliArgs): boolean {
|
|||||||
args.playbackFeedback === undefined &&
|
args.playbackFeedback === undefined &&
|
||||||
args.cycleRuntimeOptionId === undefined &&
|
args.cycleRuntimeOptionId === undefined &&
|
||||||
args.sessionAction === undefined &&
|
args.sessionAction === undefined &&
|
||||||
|
args.pluginAction === undefined &&
|
||||||
args.copySubtitleCount === undefined &&
|
args.copySubtitleCount === undefined &&
|
||||||
args.mineSentenceCount === undefined &&
|
args.mineSentenceCount === undefined &&
|
||||||
!args.anilistStatus &&
|
!args.anilistStatus &&
|
||||||
@@ -752,6 +762,7 @@ export function shouldStartApp(args: CliArgs): boolean {
|
|||||||
args.playbackFeedback !== undefined ||
|
args.playbackFeedback !== undefined ||
|
||||||
args.cycleRuntimeOptionId !== undefined ||
|
args.cycleRuntimeOptionId !== undefined ||
|
||||||
args.sessionAction !== undefined ||
|
args.sessionAction !== undefined ||
|
||||||
|
args.pluginAction !== undefined ||
|
||||||
args.copySubtitleCount !== undefined ||
|
args.copySubtitleCount !== undefined ||
|
||||||
args.mineSentenceCount !== undefined ||
|
args.mineSentenceCount !== undefined ||
|
||||||
args.dictionary ||
|
args.dictionary ||
|
||||||
@@ -819,6 +830,7 @@ export function shouldRunDictionarySettingsOnlyStartup(args: CliArgs): boolean {
|
|||||||
args.playbackFeedback === undefined &&
|
args.playbackFeedback === undefined &&
|
||||||
args.cycleRuntimeOptionId === undefined &&
|
args.cycleRuntimeOptionId === undefined &&
|
||||||
args.sessionAction === undefined &&
|
args.sessionAction === undefined &&
|
||||||
|
args.pluginAction === undefined &&
|
||||||
args.copySubtitleCount === undefined &&
|
args.copySubtitleCount === undefined &&
|
||||||
args.mineSentenceCount === undefined &&
|
args.mineSentenceCount === undefined &&
|
||||||
!args.anilistStatus &&
|
!args.anilistStatus &&
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ ${B}Mining${R}
|
|||||||
--open-session-help Open session help modal
|
--open-session-help Open session help modal
|
||||||
--open-controller-select Open controller select modal
|
--open-controller-select Open controller select modal
|
||||||
--open-controller-debug Open controller debug modal
|
--open-controller-debug Open controller debug modal
|
||||||
|
--plugin-action ${D}<id>/<name>${R} Run a plugin action
|
||||||
|
|
||||||
${B}AniList${R}
|
${B}AniList${R}
|
||||||
--anilist-setup Open AniList authentication flow
|
--anilist-setup Open AniList authentication flow
|
||||||
|
|||||||
@@ -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 () => {
|
test('handleCliCommand dispatches mark-watched session action', async () => {
|
||||||
let request: unknown = null;
|
let request: unknown = null;
|
||||||
const { deps } = createDeps({
|
const { deps } = createDeps({
|
||||||
|
|||||||
@@ -422,6 +422,12 @@ export function handleCliCommand(
|
|||||||
`sessionAction:${args.sessionAction.actionId}`,
|
`sessionAction:${args.sessionAction.actionId}`,
|
||||||
'Session action failed',
|
'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) {
|
} else if (args.toggle || args.toggleVisibleOverlay) {
|
||||||
deps.toggleVisibleOverlay();
|
deps.toggleVisibleOverlay();
|
||||||
} else if (args.togglePrimarySubtitleBar) {
|
} else if (args.togglePrimarySubtitleBar) {
|
||||||
|
|||||||
@@ -445,3 +445,19 @@ test('dispatchMpvProtocolMessage emits client-message string args', async () =>
|
|||||||
|
|
||||||
assert.deepEqual(received, [{ args: ['subminer-skip-intro', 'extra'] }]);
|
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 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
@@ -9,8 +9,30 @@ export type MpvMessage = {
|
|||||||
args?: unknown;
|
args?: unknown;
|
||||||
request_id?: number;
|
request_id?: number;
|
||||||
error?: string;
|
error?: string;
|
||||||
|
reason?: unknown;
|
||||||
|
playlist_entry_id?: unknown;
|
||||||
|
file_error?: unknown;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const MPV_END_FILE_REASONS = ['eof', 'stop', 'quit', 'error', 'redirect'] as const;
|
||||||
|
export type MpvEndFileReason = (typeof MPV_END_FILE_REASONS)[number] | 'unknown';
|
||||||
|
|
||||||
|
/** mpv's `end-file` event: why the current file stopped, and mpv's error text when it failed. */
|
||||||
|
export interface MpvEndFileEvent {
|
||||||
|
reason: MpvEndFileReason;
|
||||||
|
playlistEntryId: number | null;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseEndFileEvent(msg: MpvMessage): MpvEndFileEvent {
|
||||||
|
const reason = MPV_END_FILE_REASONS.find((known) => known === msg.reason) ?? 'unknown';
|
||||||
|
return {
|
||||||
|
reason,
|
||||||
|
playlistEntryId: typeof msg.playlist_entry_id === 'number' ? msg.playlist_entry_id : null,
|
||||||
|
error: typeof msg.file_error === 'string' && msg.file_error ? msg.file_error : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export const MPV_REQUEST_ID_SUBTEXT = 101;
|
export const MPV_REQUEST_ID_SUBTEXT = 101;
|
||||||
export const MPV_REQUEST_ID_PATH = 102;
|
export const MPV_REQUEST_ID_PATH = 102;
|
||||||
export const MPV_REQUEST_ID_SECONDARY_SUBTEXT = 103;
|
export const MPV_REQUEST_ID_SECONDARY_SUBTEXT = 103;
|
||||||
@@ -100,6 +122,7 @@ export interface MpvProtocolHandleMessageDeps {
|
|||||||
shouldQuitOnMpvShutdown: () => boolean;
|
shouldQuitOnMpvShutdown: () => boolean;
|
||||||
requestAppQuit: () => void;
|
requestAppQuit: () => void;
|
||||||
emitClientMessage?: (payload: { args: string[] }) => void;
|
emitClientMessage?: (payload: { args: string[] }) => void;
|
||||||
|
emitEndFile?: (payload: MpvEndFileEvent) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
type SubtitleTrackCandidate = {
|
type SubtitleTrackCandidate = {
|
||||||
@@ -419,6 +442,8 @@ export async function dispatchMpvProtocolMessage(
|
|||||||
if (args.length > 0) {
|
if (args.length > 0) {
|
||||||
deps.emitClientMessage?.({ args });
|
deps.emitClientMessage?.({ args });
|
||||||
}
|
}
|
||||||
|
} else if (msg.event === 'end-file') {
|
||||||
|
deps.emitEndFile?.(parseEndFileEvent(msg));
|
||||||
} else if (msg.event === 'shutdown') {
|
} else if (msg.event === 'shutdown') {
|
||||||
deps.restorePreviousSecondarySubVisibility();
|
deps.restorePreviousSecondarySubVisibility();
|
||||||
if (deps.shouldQuitOnMpvShutdown()) {
|
if (deps.shouldQuitOnMpvShutdown()) {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
dispatchMpvProtocolMessage,
|
dispatchMpvProtocolMessage,
|
||||||
MPV_REQUEST_ID_TRACK_LIST_AUDIO,
|
MPV_REQUEST_ID_TRACK_LIST_AUDIO,
|
||||||
MPV_REQUEST_ID_TRACK_LIST_SECONDARY,
|
MPV_REQUEST_ID_TRACK_LIST_SECONDARY,
|
||||||
|
type MpvEndFileEvent,
|
||||||
MpvMessage,
|
MpvMessage,
|
||||||
MpvProtocolHandleMessageDeps,
|
MpvProtocolHandleMessageDeps,
|
||||||
splitMpvMessagesFromBuffer,
|
splitMpvMessagesFromBuffer,
|
||||||
@@ -138,6 +139,7 @@ export interface MpvIpcClientEventMap {
|
|||||||
'subtitle-metrics-change': { patch: Partial<MpvSubtitleRenderMetrics> };
|
'subtitle-metrics-change': { patch: Partial<MpvSubtitleRenderMetrics> };
|
||||||
'secondary-subtitle-visibility': { visible: boolean };
|
'secondary-subtitle-visibility': { visible: boolean };
|
||||||
'client-message': { args: string[] };
|
'client-message': { args: string[] };
|
||||||
|
'end-file': MpvEndFileEvent;
|
||||||
}
|
}
|
||||||
|
|
||||||
type MpvIpcClientEventName = keyof MpvIpcClientEventMap;
|
type MpvIpcClientEventName = keyof MpvIpcClientEventMap;
|
||||||
@@ -519,6 +521,9 @@ export class MpvIpcClient implements MpvClient {
|
|||||||
emitClientMessage: (payload) => {
|
emitClientMessage: (payload) => {
|
||||||
this.emit('client-message', payload);
|
this.emit('client-message', payload);
|
||||||
},
|
},
|
||||||
|
emitEndFile: (payload) => {
|
||||||
|
this.emit('end-file', payload);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { mkdtemp, readFile, rm } from 'node:fs/promises';
|
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
||||||
import os from 'node:os';
|
import os from 'node:os';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import { setTimeout as sleep } from 'node:timers/promises';
|
import { setTimeout as sleep } from 'node:timers/promises';
|
||||||
|
|
||||||
import { execPluginCommand } from './exec';
|
import { execPluginCommand, resolvePluginSpawnTarget } from './exec';
|
||||||
|
|
||||||
// The test runtime (bun or node) doubles as a portable child process.
|
// The test runtime (bun or node) doubles as a portable child process.
|
||||||
const runtime = process.execPath;
|
const runtime = process.execPath;
|
||||||
@@ -107,3 +107,31 @@ test('rejects when the binary does not exist', async () => {
|
|||||||
(error: NodeJS.ErrnoException) => error.code === 'ENOENT',
|
(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 });
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -1,6 +1,11 @@
|
|||||||
import { spawn } from 'node:child_process';
|
import { type ChildProcess, spawn } from 'node:child_process';
|
||||||
|
|
||||||
import { buildSystemBinaryEnv } from '../../utils/system-binary-env';
|
import { buildSystemBinaryEnv } from '../../utils/system-binary-env';
|
||||||
|
import {
|
||||||
|
buildWindowsShimInvocation,
|
||||||
|
isWindowsCommandShim,
|
||||||
|
resolveWindowsCommand,
|
||||||
|
} from '../../utils/windows-command-shim';
|
||||||
import type { PluginExecOptions, PluginExecResult } from './types';
|
import type { PluginExecOptions, PluginExecResult } from './types';
|
||||||
|
|
||||||
const MAX_CAPTURED_OUTPUT_CHARS = 4 * 1024 * 1024;
|
const MAX_CAPTURED_OUTPUT_CHARS = 4 * 1024 * 1024;
|
||||||
@@ -11,10 +16,42 @@ function appendCapped(current: string, chunk: string): string {
|
|||||||
return (current + chunk).slice(0, MAX_CAPTURED_OUTPUT_CHARS);
|
return (current + chunk).slice(0, MAX_CAPTURED_OUTPUT_CHARS);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface SpawnTarget {
|
||||||
|
file: string;
|
||||||
|
args: string[];
|
||||||
|
viaShell: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Runs `command` without a shell and collects its output. Resolves with the exit code on any exit,
|
* Windows batch shims (npm installs `codex` as `codex.cmd`) only run through cmd.exe. Native
|
||||||
|
* binaries, and every command on Linux and macOS, are spawned directly.
|
||||||
|
*/
|
||||||
|
export function resolvePluginSpawnTarget(
|
||||||
|
command: string,
|
||||||
|
args: string[],
|
||||||
|
env: NodeJS.ProcessEnv,
|
||||||
|
platform: NodeJS.Platform = process.platform,
|
||||||
|
): SpawnTarget {
|
||||||
|
if (platform !== 'win32') return { file: command, args, viaShell: false };
|
||||||
|
const resolved = resolveWindowsCommand(command, env);
|
||||||
|
if (!isWindowsCommandShim(resolved)) return { file: resolved, args, viaShell: false };
|
||||||
|
return { ...buildWindowsShimInvocation(resolved, args, env), viaShell: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Killing cmd.exe alone would leave the shim's own child running, so end the whole tree.
|
||||||
|
function killWindowsProcessTree(child: ChildProcess): void {
|
||||||
|
if (child.pid === undefined) return;
|
||||||
|
spawn('taskkill', ['/pid', String(child.pid), '/T', '/F'], {
|
||||||
|
stdio: 'ignore',
|
||||||
|
windowsHide: true,
|
||||||
|
}).on('error', () => child.kill());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs `command` (no shell, except cmd.exe for Windows batch shims) and collects its output. Resolves with the exit code on any exit,
|
||||||
* including non-zero; rejects when the binary cannot start, the timeout passes, or `signal` aborts.
|
* including non-zero; rejects when the binary cannot start, the timeout passes, or `signal` aborts.
|
||||||
* A timed-out or aborted child gets SIGTERM, then SIGKILL after `killGraceMs`.
|
* A timed-out or aborted child gets SIGTERM, then SIGKILL after `killGraceMs` (a shim's
|
||||||
|
* process tree is ended with taskkill instead).
|
||||||
*/
|
*/
|
||||||
export function execPluginCommand(
|
export function execPluginCommand(
|
||||||
command: string,
|
command: string,
|
||||||
@@ -28,11 +65,14 @@ export function execPluginCommand(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const child = spawn(command, args, {
|
const env = { ...buildSystemBinaryEnv(), ...options.env };
|
||||||
|
const target = resolvePluginSpawnTarget(command, args, env);
|
||||||
|
const child = spawn(target.file, target.args, {
|
||||||
cwd: options.cwd,
|
cwd: options.cwd,
|
||||||
env: { ...buildSystemBinaryEnv(), ...options.env },
|
env,
|
||||||
stdio: ['pipe', 'pipe', 'pipe'],
|
stdio: ['pipe', 'pipe', 'pipe'],
|
||||||
windowsHide: true,
|
windowsHide: true,
|
||||||
|
windowsVerbatimArguments: target.viaShell,
|
||||||
});
|
});
|
||||||
|
|
||||||
let stdout = '';
|
let stdout = '';
|
||||||
@@ -51,6 +91,10 @@ export function execPluginCommand(
|
|||||||
// Rejects right away, then escalates to SIGKILL if the child ignores SIGTERM.
|
// Rejects right away, then escalates to SIGKILL if the child ignores SIGTERM.
|
||||||
const killWith = (error: Error): void => {
|
const killWith = (error: Error): void => {
|
||||||
settle(() => reject(error));
|
settle(() => reject(error));
|
||||||
|
if (target.viaShell) {
|
||||||
|
killWindowsProcessTree(child);
|
||||||
|
return;
|
||||||
|
}
|
||||||
child.kill('SIGTERM');
|
child.kill('SIGTERM');
|
||||||
const forceKill = setTimeout(() => {
|
const forceKill = setTimeout(() => {
|
||||||
if (child.exitCode === null && child.signalCode === null) child.kill('SIGKILL');
|
if (child.exitCode === null && child.signalCode === null) child.kill('SIGKILL');
|
||||||
|
|||||||
@@ -36,9 +36,9 @@ export function parsePluginManifest(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Resolves the entry file, rejecting paths that leave the plugin directory. */
|
/** Resolves a plugin file (entry, window HTML, preload), rejecting paths that leave the plugin directory. */
|
||||||
export function resolvePluginEntryPath(pluginDir: string, main: string): string | null {
|
export function resolvePluginFilePath(pluginDir: string, file: string): string | null {
|
||||||
const resolved = path.resolve(pluginDir, main);
|
const resolved = path.resolve(pluginDir, file);
|
||||||
const relative = path.relative(pluginDir, resolved);
|
const relative = path.relative(pluginDir, resolved);
|
||||||
if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) {
|
if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) {
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -1,12 +1,20 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { EventEmitter } from 'node:events';
|
import { EventEmitter } from 'node:events';
|
||||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||||||
import os from 'node:os';
|
import os from 'node:os';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
|
|
||||||
|
import type { BrowserWindow } from 'electron';
|
||||||
|
|
||||||
|
import type { SessionActionDispatchRequest } from '../../../types/runtime';
|
||||||
import { createPluginHost, type PluginHostDeps } from './plugin-host';
|
import { createPluginHost, type PluginHostDeps } from './plugin-host';
|
||||||
import type { PluginConfigEntry, PluginLogger, PluginMpvEventName } from './types';
|
import type {
|
||||||
|
PluginConfigEntry,
|
||||||
|
PluginLogger,
|
||||||
|
PluginMpvEventName,
|
||||||
|
PluginWindowRequest,
|
||||||
|
} from './types';
|
||||||
|
|
||||||
function writePlugin(root: string, id: string, files: Record<string, string>): void {
|
function writePlugin(root: string, id: string, files: Record<string, string>): void {
|
||||||
const dir = path.join(root, id);
|
const dir = path.join(root, id);
|
||||||
@@ -16,10 +24,31 @@ function writePlugin(root: string, id: string, files: Record<string, string>): v
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type FakeWindow = ReturnType<typeof createFakeWindow>;
|
||||||
|
|
||||||
|
/** 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<string, PluginConfigEntry>) {
|
function createHarness(config: Record<string, PluginConfigEntry>) {
|
||||||
const root = mkdtempSync(path.join(os.tmpdir(), 'subminer-plugins-'));
|
const root = mkdtempSync(path.join(os.tmpdir(), 'subminer-plugins-'));
|
||||||
const errors: string[] = [];
|
const errors: string[] = [];
|
||||||
const notifications: string[] = [];
|
const notifications: string[] = [];
|
||||||
|
const sessionActions: SessionActionDispatchRequest[] = [];
|
||||||
|
const windowRequests: PluginWindowRequest[] = [];
|
||||||
|
const windows = new Map<string, FakeWindow>();
|
||||||
const logger: PluginLogger = {
|
const logger: PluginLogger = {
|
||||||
debug: () => {},
|
debug: () => {},
|
||||||
info: () => {},
|
info: () => {},
|
||||||
@@ -27,24 +56,41 @@ function createHarness(config: Record<string, PluginConfigEntry>) {
|
|||||||
error: (message) => errors.push(message),
|
error: (message) => errors.push(message),
|
||||||
};
|
};
|
||||||
const deps: PluginHostDeps<{ marker: string }> = {
|
const deps: PluginHostDeps<{ marker: string }> = {
|
||||||
pluginsDir: root,
|
pluginsDir: path.join(root, 'plugins'),
|
||||||
|
pluginDataDir: path.join(root, 'data'),
|
||||||
getPluginConfig: () => config,
|
getPluginConfig: () => config,
|
||||||
createLogger: () => logger,
|
createLogger: () => logger,
|
||||||
isMpvConnected: () => true,
|
isMpvConnected: () => true,
|
||||||
sendMpvCommand: () => {},
|
sendMpvCommand: () => {},
|
||||||
requestMpvProperty: async () => null,
|
requestMpvProperty: async () => null,
|
||||||
|
ensureMpvRunning: async () => true,
|
||||||
|
dispatchSessionAction: async (request) => {
|
||||||
|
sessionActions.push(request);
|
||||||
|
},
|
||||||
getCurrentSubtitle: () => null,
|
getCurrentSubtitle: () => null,
|
||||||
getSubtitleContext: () => ({ current: null, before: [], after: [] }),
|
getSubtitleContext: () => ({ current: null, before: [], after: [] }),
|
||||||
notify: (message) => notifications.push(message),
|
notify: (message) => notifications.push(message),
|
||||||
openPanel: () => {
|
openPanel: () => {
|
||||||
throw new Error('not used');
|
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' },
|
unstable: { marker: 'internals' },
|
||||||
};
|
};
|
||||||
return {
|
return {
|
||||||
root,
|
root: deps.pluginsDir,
|
||||||
|
dataRoot: deps.pluginDataDir,
|
||||||
errors,
|
errors,
|
||||||
notifications,
|
notifications,
|
||||||
|
sessionActions,
|
||||||
|
windowRequests,
|
||||||
|
windows,
|
||||||
host: createPluginHost(deps),
|
host: createPluginHost(deps),
|
||||||
cleanup: () => rmSync(root, { recursive: true, force: true }),
|
cleanup: () => rmSync(root, { recursive: true, force: true }),
|
||||||
};
|
};
|
||||||
@@ -85,9 +131,9 @@ test('loads enabled plugins and runs their registered actions', async () => {
|
|||||||
|
|
||||||
await h.host.loadAll();
|
await h.host.loadAll();
|
||||||
|
|
||||||
assert.equal(h.host.runAction('greeter/say'), true);
|
assert.equal(await h.host.runAction('greeter/say'), true);
|
||||||
assert.deepEqual(h.notifications, ['hi internals']);
|
assert.deepEqual(h.notifications, ['hi internals']);
|
||||||
assert.equal(h.host.runAction('greeter/missing'), false);
|
assert.equal(await h.host.runAction('greeter/missing'), false);
|
||||||
assert.deepEqual(h.errors, []);
|
assert.deepEqual(h.errors, []);
|
||||||
} finally {
|
} finally {
|
||||||
h.cleanup();
|
h.cleanup();
|
||||||
@@ -104,7 +150,7 @@ test('loads ES module plugins', async () => {
|
|||||||
|
|
||||||
await h.host.loadAll();
|
await h.host.loadAll();
|
||||||
|
|
||||||
assert.equal(h.host.runAction('esm/go'), true);
|
assert.equal(await h.host.runAction('esm/go'), true);
|
||||||
assert.deepEqual(h.notifications, ['esm']);
|
assert.deepEqual(h.notifications, ['esm']);
|
||||||
} finally {
|
} finally {
|
||||||
h.cleanup();
|
h.cleanup();
|
||||||
@@ -128,8 +174,8 @@ test('a failed activation rolls back its registrations without affecting other p
|
|||||||
|
|
||||||
await h.host.loadAll();
|
await h.host.loadAll();
|
||||||
|
|
||||||
assert.equal(h.host.runAction('broken/half'), false);
|
assert.equal(await h.host.runAction('broken/half'), false);
|
||||||
assert.equal(h.host.runAction('healthy/ok'), true);
|
assert.equal(await h.host.runAction('healthy/ok'), true);
|
||||||
assert.deepEqual(h.errors, ['Activation failed: boom']);
|
assert.deepEqual(h.errors, ['Activation failed: boom']);
|
||||||
} finally {
|
} finally {
|
||||||
h.cleanup();
|
h.cleanup();
|
||||||
@@ -204,3 +250,105 @@ test('events follow a replacement mpv client', async () => {
|
|||||||
h.cleanup();
|
h.cleanup();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('runAction waits for a load that is still in progress', async () => {
|
||||||
|
const h = createHarness({ slow: enabled() });
|
||||||
|
try {
|
||||||
|
writePlugin(h.root, 'slow', {
|
||||||
|
'plugin.json': JSON.stringify({ main: 'main.cjs' }),
|
||||||
|
'main.cjs': `exports.activate = async (ctx) => {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||||
|
ctx.actions.register('go', () => ctx.notify('went'));
|
||||||
|
};`,
|
||||||
|
});
|
||||||
|
|
||||||
|
void h.host.loadAll();
|
||||||
|
|
||||||
|
assert.equal(await h.host.runAction('slow/go'), true);
|
||||||
|
assert.deepEqual(h.notifications, ['went']);
|
||||||
|
} finally {
|
||||||
|
h.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('each plugin gets its own data folder, created before activate', async () => {
|
||||||
|
const h = createHarness({ keeper: enabled() });
|
||||||
|
try {
|
||||||
|
writePlugin(h.root, 'keeper', {
|
||||||
|
'plugin.json': JSON.stringify({ main: 'main.cjs' }),
|
||||||
|
'main.cjs': `const fs = require('node:fs');
|
||||||
|
exports.activate = (ctx) => {
|
||||||
|
fs.writeFileSync(require('node:path').join(ctx.dataDir, 'state.json'), '{}');
|
||||||
|
ctx.notify(ctx.dataDir);
|
||||||
|
};`,
|
||||||
|
});
|
||||||
|
|
||||||
|
await h.host.loadAll();
|
||||||
|
|
||||||
|
const expected = path.join(h.dataRoot, 'keeper');
|
||||||
|
assert.deepEqual(h.notifications, [expected]);
|
||||||
|
assert.equal(existsSync(path.join(expected, 'state.json')), true);
|
||||||
|
assert.deepEqual(h.errors, []);
|
||||||
|
} finally {
|
||||||
|
h.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('actions.run forwards built-in session actions', async () => {
|
||||||
|
const h = createHarness({ caller: enabled() });
|
||||||
|
try {
|
||||||
|
writePlugin(h.root, 'caller', {
|
||||||
|
'plugin.json': JSON.stringify({ main: 'main.cjs' }),
|
||||||
|
'main.cjs': `exports.activate = async (ctx) => {
|
||||||
|
await ctx.actions.run('openJimaku');
|
||||||
|
await ctx.actions.run('cycleRuntimeOption', { runtimeOptionId: 'x', direction: -1 });
|
||||||
|
};`,
|
||||||
|
});
|
||||||
|
|
||||||
|
await h.host.loadAll();
|
||||||
|
|
||||||
|
assert.deepEqual(h.sessionActions, [
|
||||||
|
{ actionId: 'openJimaku' },
|
||||||
|
{ actionId: 'cycleRuntimeOption', payload: { runtimeOptionId: 'x', direction: -1 } },
|
||||||
|
]);
|
||||||
|
} finally {
|
||||||
|
h.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('openWindow resolves plugin files, reuses open windows, and closes them on unload', async () => {
|
||||||
|
const h = createHarness({ viewer: enabled() });
|
||||||
|
try {
|
||||||
|
writePlugin(h.root, 'viewer', {
|
||||||
|
'plugin.json': JSON.stringify({ main: 'main.cjs' }),
|
||||||
|
'main.cjs': `exports.activate = (ctx) => {
|
||||||
|
ctx.actions.register('open', () => ctx.ui.openWindow({ html: 'ui/index.html', preload: 'preload.js', width: 400 }));
|
||||||
|
ctx.actions.register('escape', () => ctx.ui.openWindow({ html: '../other/index.html' }));
|
||||||
|
};`,
|
||||||
|
});
|
||||||
|
await h.host.loadAll();
|
||||||
|
|
||||||
|
await h.host.runAction('viewer/open');
|
||||||
|
await h.host.runAction('viewer/open');
|
||||||
|
const dir = path.join(h.root, 'viewer');
|
||||||
|
assert.deepEqual(h.windowRequests[0], {
|
||||||
|
key: `viewer:${path.join(dir, 'ui', 'index.html')}`,
|
||||||
|
htmlPath: path.join(dir, 'ui', 'index.html'),
|
||||||
|
preloadPath: path.join(dir, 'preload.js'),
|
||||||
|
title: 'viewer',
|
||||||
|
width: 400,
|
||||||
|
height: 720,
|
||||||
|
});
|
||||||
|
assert.equal(h.windows.size, 1);
|
||||||
|
|
||||||
|
await h.host.runAction('viewer/escape');
|
||||||
|
assert.equal(h.windowRequests.length, 2);
|
||||||
|
assert.match(h.errors[0] ?? '', /must stay inside/);
|
||||||
|
|
||||||
|
const [window] = [...h.windows.values()];
|
||||||
|
await h.host.unloadAll();
|
||||||
|
assert.equal(window?.destroyed, true);
|
||||||
|
} finally {
|
||||||
|
h.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -2,8 +2,11 @@ import { EventEmitter } from 'node:events';
|
|||||||
import { promises as fs } from 'node:fs';
|
import { promises as fs } from 'node:fs';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
|
|
||||||
|
import type { BrowserWindow } from 'electron';
|
||||||
|
|
||||||
|
import type { SessionActionDispatchRequest } from '../../../types/runtime';
|
||||||
import { execPluginCommand } from './exec';
|
import { execPluginCommand } from './exec';
|
||||||
import { isValidPluginName, parsePluginManifest, resolvePluginEntryPath } from './manifest';
|
import { isValidPluginName, parsePluginManifest, resolvePluginFilePath } from './manifest';
|
||||||
import {
|
import {
|
||||||
PLUGIN_MPV_EVENT_NAMES,
|
PLUGIN_MPV_EVENT_NAMES,
|
||||||
type PluginConfigEntry,
|
type PluginConfigEntry,
|
||||||
@@ -16,9 +19,12 @@ import {
|
|||||||
type PluginPanelHandle,
|
type PluginPanelHandle,
|
||||||
type PluginSubtitleContext,
|
type PluginSubtitleContext,
|
||||||
type PluginSubtitleLine,
|
type PluginSubtitleLine,
|
||||||
|
type PluginWindowRequest,
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|
||||||
const MIN_TIMER_INTERVAL_MS = 100;
|
const MIN_TIMER_INTERVAL_MS = 100;
|
||||||
|
const DEFAULT_WINDOW_WIDTH = 960;
|
||||||
|
const DEFAULT_WINDOW_HEIGHT = 720;
|
||||||
|
|
||||||
interface MpvClientEvents {
|
interface MpvClientEvents {
|
||||||
on: (event: PluginMpvEventName, listener: (payload: unknown) => void) => void;
|
on: (event: PluginMpvEventName, listener: (payload: unknown) => void) => void;
|
||||||
@@ -27,15 +33,21 @@ interface MpvClientEvents {
|
|||||||
|
|
||||||
export interface PluginHostDeps<TUnstable> {
|
export interface PluginHostDeps<TUnstable> {
|
||||||
pluginsDir: string;
|
pluginsDir: string;
|
||||||
|
/** Root of the per-plugin data folders; each plugin gets `<pluginDataDir>/<id>`. */
|
||||||
|
pluginDataDir: string;
|
||||||
getPluginConfig: () => Record<string, PluginConfigEntry>;
|
getPluginConfig: () => Record<string, PluginConfigEntry>;
|
||||||
createLogger: (scope: string) => PluginLogger;
|
createLogger: (scope: string) => PluginLogger;
|
||||||
isMpvConnected: () => boolean;
|
isMpvConnected: () => boolean;
|
||||||
sendMpvCommand: (command: (string | number)[]) => void;
|
sendMpvCommand: (command: (string | number)[]) => void;
|
||||||
requestMpvProperty: (name: string) => Promise<unknown>;
|
requestMpvProperty: (name: string) => Promise<unknown>;
|
||||||
|
ensureMpvRunning: () => Promise<boolean>;
|
||||||
|
dispatchSessionAction: (request: SessionActionDispatchRequest) => Promise<void>;
|
||||||
getCurrentSubtitle: () => PluginSubtitleLine | null;
|
getCurrentSubtitle: () => PluginSubtitleLine | null;
|
||||||
getSubtitleContext: (options: { before?: number; after?: number }) => PluginSubtitleContext;
|
getSubtitleContext: (options: { before?: number; after?: number }) => PluginSubtitleContext;
|
||||||
notify: (message: string, variant: PluginNotifyVariant) => void;
|
notify: (message: string, variant: PluginNotifyVariant) => void;
|
||||||
openPanel: (content: PluginPanelContent) => PluginPanelHandle;
|
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;
|
unstable: TUnstable;
|
||||||
/** Test seam; defaults to Node's `require`, which also loads synchronous ES modules. */
|
/** Test seam; defaults to Node's `require`, which also loads synchronous ES modules. */
|
||||||
loadModule?: (entryPath: string) => PluginModule;
|
loadModule?: (entryPath: string) => PluginModule;
|
||||||
@@ -106,6 +118,7 @@ export function createPluginHost<TUnstable>(deps: PluginHostDeps<TUnstable>) {
|
|||||||
function createContext(
|
function createContext(
|
||||||
plugin: LoadedPlugin,
|
plugin: LoadedPlugin,
|
||||||
dir: string,
|
dir: string,
|
||||||
|
dataDir: string,
|
||||||
entry: PluginConfigEntry,
|
entry: PluginConfigEntry,
|
||||||
log: PluginLogger,
|
log: PluginLogger,
|
||||||
): PluginContext<TUnstable> {
|
): PluginContext<TUnstable> {
|
||||||
@@ -125,6 +138,7 @@ export function createPluginHost<TUnstable>(deps: PluginHostDeps<TUnstable>) {
|
|||||||
id: plugin.id,
|
id: plugin.id,
|
||||||
dir,
|
dir,
|
||||||
settings: Object.freeze({ ...entry.settings }),
|
settings: Object.freeze({ ...entry.settings }),
|
||||||
|
dataDir,
|
||||||
log,
|
log,
|
||||||
every: (intervalMs, callback) => {
|
every: (intervalMs, callback) => {
|
||||||
const handle = setInterval(
|
const handle = setInterval(
|
||||||
@@ -158,6 +172,7 @@ export function createPluginHost<TUnstable>(deps: PluginHostDeps<TUnstable>) {
|
|||||||
},
|
},
|
||||||
command: (command) => deps.sendMpvCommand(command),
|
command: (command) => deps.sendMpvCommand(command),
|
||||||
getProperty: (name) => deps.requestMpvProperty(name),
|
getProperty: (name) => deps.requestMpvProperty(name),
|
||||||
|
ensureRunning: () => deps.ensureMpvRunning(),
|
||||||
},
|
},
|
||||||
subtitles: {
|
subtitles: {
|
||||||
current: () => deps.getCurrentSubtitle(),
|
current: () => deps.getCurrentSubtitle(),
|
||||||
@@ -175,6 +190,8 @@ export function createPluginHost<TUnstable>(deps: PluginHostDeps<TUnstable>) {
|
|||||||
actions.set(actionId, { pluginId: plugin.id, handler });
|
actions.set(actionId, { pluginId: plugin.id, handler });
|
||||||
return track(() => actions.delete(actionId));
|
return track(() => actions.delete(actionId));
|
||||||
},
|
},
|
||||||
|
run: (actionId, payload) =>
|
||||||
|
deps.dispatchSessionAction(payload === undefined ? { actionId } : { actionId, payload }),
|
||||||
},
|
},
|
||||||
notify: (message, options) => deps.notify(message, options?.variant ?? 'info'),
|
notify: (message, options) => deps.notify(message, options?.variant ?? 'info'),
|
||||||
ui: {
|
ui: {
|
||||||
@@ -187,6 +204,34 @@ export function createPluginHost<TUnstable>(deps: PluginHostDeps<TUnstable>) {
|
|||||||
handle.signal.addEventListener('abort', dispose, { once: true });
|
handle.signal.addEventListener('abort', dispose, { once: true });
|
||||||
return handle;
|
return handle;
|
||||||
},
|
},
|
||||||
|
openWindow: (options) => {
|
||||||
|
const resolveFile = (label: string, file: string): string => {
|
||||||
|
const resolved = resolvePluginFilePath(dir, file);
|
||||||
|
if (!resolved) throw new Error(`Window ${label} "${file}" must stay inside ${dir}.`);
|
||||||
|
return resolved;
|
||||||
|
};
|
||||||
|
const htmlPath = resolveFile('html', options.html);
|
||||||
|
const { window, created } = deps.openWindow({
|
||||||
|
key: `${plugin.id}:${htmlPath}`,
|
||||||
|
htmlPath,
|
||||||
|
preloadPath:
|
||||||
|
options.preload === undefined ? null : resolveFile('preload', options.preload),
|
||||||
|
title: options.title ?? plugin.id,
|
||||||
|
width: options.width ?? DEFAULT_WINDOW_WIDTH,
|
||||||
|
height: options.height ?? DEFAULT_WINDOW_HEIGHT,
|
||||||
|
});
|
||||||
|
if (created) {
|
||||||
|
let closed = false;
|
||||||
|
const dispose = track(() => {
|
||||||
|
if (!closed && !window.isDestroyed()) window.destroy();
|
||||||
|
});
|
||||||
|
window.once('closed', () => {
|
||||||
|
closed = true;
|
||||||
|
dispose();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return window;
|
||||||
|
},
|
||||||
},
|
},
|
||||||
exec: (command, args, options = {}) =>
|
exec: (command, args, options = {}) =>
|
||||||
exec(command, args, {
|
exec(command, args, {
|
||||||
@@ -231,12 +276,20 @@ export function createPluginHost<TUnstable>(deps: PluginHostDeps<TUnstable>) {
|
|||||||
log.error(parsed.error);
|
log.error(parsed.error);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const entryPath = resolvePluginEntryPath(dir, parsed.manifest.main);
|
const entryPath = resolvePluginFilePath(dir, parsed.manifest.main);
|
||||||
if (!entryPath) {
|
if (!entryPath) {
|
||||||
log.error(`Entry "${parsed.manifest.main}" must stay inside ${dir}.`);
|
log.error(`Entry "${parsed.manifest.main}" must stay inside ${dir}.`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const dataDir = path.join(deps.pluginDataDir, id);
|
||||||
|
try {
|
||||||
|
await fs.mkdir(dataDir, { recursive: true });
|
||||||
|
} catch (error) {
|
||||||
|
log.error(`Cannot create data folder ${dataDir}: ${errorMessage(error)}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const plugin: LoadedPlugin = {
|
const plugin: LoadedPlugin = {
|
||||||
id,
|
id,
|
||||||
disposers: new Set(),
|
disposers: new Set(),
|
||||||
@@ -250,7 +303,9 @@ export function createPluginHost<TUnstable>(deps: PluginHostDeps<TUnstable>) {
|
|||||||
if (!activate) {
|
if (!activate) {
|
||||||
throw new Error(`${parsed.manifest.main} does not export an activate(ctx) function.`);
|
throw new Error(`${parsed.manifest.main} does not export an activate(ctx) function.`);
|
||||||
}
|
}
|
||||||
const returned = await activate(createContext(plugin, dir, entry, log) as PluginContext);
|
const returned = await activate(
|
||||||
|
createContext(plugin, dir, dataDir, entry, log) as PluginContext,
|
||||||
|
);
|
||||||
plugin.deactivate =
|
plugin.deactivate =
|
||||||
typeof returned === 'function'
|
typeof returned === 'function'
|
||||||
? (returned as () => unknown)
|
? (returned as () => unknown)
|
||||||
@@ -308,8 +363,12 @@ export function createPluginHost<TUnstable>(deps: PluginHostDeps<TUnstable>) {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
||||||
/** Runs a registered `<plugin id>/<name>` action. Returns false when none is registered. */
|
/**
|
||||||
runAction(actionId: string): boolean {
|
* Runs a registered `<plugin id>/<name>` 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<boolean> {
|
||||||
|
await loadPromise;
|
||||||
const action = actions.get(actionId);
|
const action = actions.get(actionId);
|
||||||
if (!action) return false;
|
if (!action) return false;
|
||||||
runGuarded(
|
runGuarded(
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
import type { BrowserWindow } from 'electron';
|
||||||
|
|
||||||
|
import type { SessionActionId, SessionActionPayload } from '../../../types/session-bindings';
|
||||||
import type { MpvIpcClientEventMap } from '../mpv';
|
import type { MpvIpcClientEventMap } from '../mpv';
|
||||||
|
|
||||||
/** `plugin.json` at the root of a plugin directory. */
|
/** `plugin.json` at the root of a plugin directory. */
|
||||||
@@ -36,6 +39,7 @@ export const PLUGIN_MPV_EVENT_NAMES = [
|
|||||||
'media-title-change',
|
'media-title-change',
|
||||||
'secondary-subtitle-visibility',
|
'secondary-subtitle-visibility',
|
||||||
'client-message',
|
'client-message',
|
||||||
|
'end-file',
|
||||||
] as const satisfies readonly PluginMpvEventName[];
|
] as const satisfies readonly PluginMpvEventName[];
|
||||||
|
|
||||||
export interface PluginSubtitleLine {
|
export interface PluginSubtitleLine {
|
||||||
@@ -66,6 +70,28 @@ export interface PluginPanelHandle {
|
|||||||
close: () => 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What the host needs to show a plugin window; paths are absolute and already validated. */
|
||||||
|
export interface PluginWindowRequest {
|
||||||
|
/** Same key, same window: reopening focuses the existing one. */
|
||||||
|
key: string;
|
||||||
|
htmlPath: string;
|
||||||
|
preloadPath: string | null;
|
||||||
|
title: string;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface PluginExecOptions {
|
export interface PluginExecOptions {
|
||||||
/** Written to the child's stdin, which is then closed. */
|
/** Written to the child's stdin, which is then closed. */
|
||||||
stdin?: string;
|
stdin?: string;
|
||||||
@@ -103,6 +129,11 @@ export interface PluginContext<TUnstable = unknown> {
|
|||||||
readonly dir: string;
|
readonly dir: string;
|
||||||
/** `plugins.<id>.settings` from config. */
|
/** `plugins.<id>.settings` from config. */
|
||||||
readonly settings: Readonly<Record<string, unknown>>;
|
readonly settings: Readonly<Record<string, unknown>>;
|
||||||
|
/**
|
||||||
|
* `<config dir>/plugin-data/<id>/` 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;
|
readonly log: PluginLogger;
|
||||||
/** Repeating timer; cleared automatically when the plugin unloads. */
|
/** Repeating timer; cleared automatically when the plugin unloads. */
|
||||||
every: (intervalMs: number, callback: () => unknown) => Disposer;
|
every: (intervalMs: number, callback: () => unknown) => Disposer;
|
||||||
@@ -116,6 +147,8 @@ export interface PluginContext<TUnstable = unknown> {
|
|||||||
) => Disposer;
|
) => Disposer;
|
||||||
command: (command: (string | number)[]) => void;
|
command: (command: (string | number)[]) => void;
|
||||||
getProperty: (name: string) => Promise<unknown>;
|
getProperty: (name: string) => Promise<unknown>;
|
||||||
|
/** Connects to mpv, launching an idle mpv if none is running. Resolves false when that fails. */
|
||||||
|
ensureRunning: () => Promise<boolean>;
|
||||||
};
|
};
|
||||||
readonly subtitles: {
|
readonly subtitles: {
|
||||||
current: () => PluginSubtitleLine | null;
|
current: () => PluginSubtitleLine | null;
|
||||||
@@ -124,11 +157,18 @@ export interface PluginContext<TUnstable = unknown> {
|
|||||||
readonly actions: {
|
readonly actions: {
|
||||||
/** Bind with `["__plugin-action:<plugin id>/<name>"]` in `keybindings`. */
|
/** Bind with `["__plugin-action:<plugin id>/<name>"]` in `keybindings`. */
|
||||||
register: (name: string, handler: () => unknown) => Disposer;
|
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<void>;
|
||||||
};
|
};
|
||||||
notify: (message: string, options?: { variant?: PluginNotifyVariant }) => void;
|
notify: (message: string, options?: { variant?: PluginNotifyVariant }) => void;
|
||||||
readonly ui: {
|
readonly ui: {
|
||||||
/** Opens the shared plugin panel, replacing any panel that is already open. */
|
/** Opens the shared plugin panel, replacing any panel that is already open. */
|
||||||
panel: (content: Partial<PluginPanelContent> & { markdown: string }) => PluginPanelHandle;
|
panel: (content: Partial<PluginPanelContent> & { 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. */
|
/** Runs a binary without a shell, with the AppImage library path removed from its environment. */
|
||||||
exec: (command: string, args: string[], options?: PluginExecOptions) => Promise<PluginExecResult>;
|
exec: (command: string, args: string[], options?: PluginExecOptions) => Promise<PluginExecResult>;
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ function createDeps(overrides: Partial<SessionActionExecutorDeps> = {}) {
|
|||||||
playNextSubtitle: () => calls.push('play-next'),
|
playNextSubtitle: () => calls.push('play-next'),
|
||||||
cycleRuntimeOption: () => ({ ok: true }),
|
cycleRuntimeOption: () => ({ ok: true }),
|
||||||
playNextPlaylistItem: () => calls.push('playlist-next'),
|
playNextPlaylistItem: () => calls.push('playlist-next'),
|
||||||
runPluginAction: (pluginActionId) => {
|
runPluginAction: async (pluginActionId) => {
|
||||||
calls.push(`plugin:${pluginActionId}`);
|
calls.push(`plugin:${pluginActionId}`);
|
||||||
return pluginActionId === 'ai-explain/explain';
|
return pluginActionId === 'ai-explain/explain';
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ export interface SessionActionExecutorDeps {
|
|||||||
cycleRuntimeOption: (id: RuntimeOptionId, direction: 1 | -1) => RuntimeOptionApplyResult;
|
cycleRuntimeOption: (id: RuntimeOptionId, direction: 1 | -1) => RuntimeOptionApplyResult;
|
||||||
playNextPlaylistItem: () => void;
|
playNextPlaylistItem: () => void;
|
||||||
/** Returns false when no loaded plugin registered the action. */
|
/** Returns false when no loaded plugin registered the action. */
|
||||||
runPluginAction: (pluginActionId: string) => boolean;
|
runPluginAction: (pluginActionId: string) => Promise<boolean>;
|
||||||
showMpvOsd: (text: string) => void;
|
showMpvOsd: (text: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -155,7 +155,7 @@ export async function dispatchSessionAction(
|
|||||||
}
|
}
|
||||||
case 'runPluginAction': {
|
case 'runPluginAction': {
|
||||||
const pluginActionId = request.payload?.pluginActionId;
|
const pluginActionId = request.payload?.pluginActionId;
|
||||||
if (!pluginActionId || !deps.runPluginAction(pluginActionId)) {
|
if (!pluginActionId || !(await deps.runPluginAction(pluginActionId))) {
|
||||||
deps.showMpvOsd(`Plugin action not available: ${pluginActionId ?? 'unknown'}`);
|
deps.showMpvOsd(`Plugin action not available: ${pluginActionId ?? 'unknown'}`);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -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\\^"^""',
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 <hello@moxy.studio>
|
||||||
|
*
|
||||||
|
* 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}"`],
|
||||||
|
};
|
||||||
|
}
|
||||||
+16
-1
@@ -519,6 +519,7 @@ import {
|
|||||||
createPluginPanelRuntime,
|
createPluginPanelRuntime,
|
||||||
openPluginPanelModal,
|
openPluginPanelModal,
|
||||||
} from './main/runtime/plugin-panel-runtime';
|
} 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 { openCharacterDictionaryManagerModal as openCharacterDictionaryManagerModalRuntime } from './main/runtime/character-dictionary-open';
|
||||||
import { openControllerSelectModal as openControllerSelectModalRuntime } from './main/runtime/controller-select-open';
|
import { openControllerSelectModal as openControllerSelectModalRuntime } from './main/runtime/controller-select-open';
|
||||||
import { openControllerDebugModal as openControllerDebugModalRuntime } from './main/runtime/controller-debug-open';
|
import { openControllerDebugModal as openControllerDebugModalRuntime } from './main/runtime/controller-debug-open';
|
||||||
@@ -3128,8 +3129,15 @@ function getPluginCurrentSubtitle(): PluginSubtitleLine | null {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const pluginWindowRuntime = createPluginWindowRuntime({
|
||||||
|
createBrowserWindow: (options) => new BrowserWindow(options),
|
||||||
|
promoteAboveOverlay: (window) => promoteSettingsWindowAboveOverlay(window),
|
||||||
|
logError: (message) => logger.error(message),
|
||||||
|
});
|
||||||
|
|
||||||
const pluginHost = createPluginHost({
|
const pluginHost = createPluginHost({
|
||||||
pluginsDir: path.join(CONFIG_DIR, 'plugins'),
|
pluginsDir: path.join(CONFIG_DIR, 'plugins'),
|
||||||
|
pluginDataDir: path.join(USER_DATA_PATH, 'plugin-data'),
|
||||||
getPluginConfig: () => configService.getConfig().plugins,
|
getPluginConfig: () => configService.getConfig().plugins,
|
||||||
createLogger,
|
createLogger,
|
||||||
isMpvConnected: () => appState.mpvClient?.connected === true,
|
isMpvConnected: () => appState.mpvClient?.connected === true,
|
||||||
@@ -3138,6 +3146,8 @@ const pluginHost = createPluginHost({
|
|||||||
if (!appState.mpvClient) throw new Error('mpv is not connected.');
|
if (!appState.mpvClient) throw new Error('mpv is not connected.');
|
||||||
return await appState.mpvClient.requestProperty(name);
|
return await appState.mpvClient.requestProperty(name);
|
||||||
},
|
},
|
||||||
|
ensureMpvRunning: () => ensureMpvConnectedForPlayback(),
|
||||||
|
dispatchSessionAction: (request) => dispatchSessionAction(request),
|
||||||
getCurrentSubtitle: () => getPluginCurrentSubtitle(),
|
getCurrentSubtitle: () => getPluginCurrentSubtitle(),
|
||||||
getSubtitleContext: (options) =>
|
getSubtitleContext: (options) =>
|
||||||
buildPluginSubtitleContext({
|
buildPluginSubtitleContext({
|
||||||
@@ -3153,6 +3163,7 @@ const pluginHost = createPluginHost({
|
|||||||
notify: (message, variant) =>
|
notify: (message, variant) =>
|
||||||
overlayNotificationsRuntime.showConfiguredStatusNotification(message, { variant }),
|
overlayNotificationsRuntime.showConfiguredStatusNotification(message, { variant }),
|
||||||
openPanel: (content) => pluginPanelRuntime.open(content),
|
openPanel: (content) => pluginPanelRuntime.open(content),
|
||||||
|
openWindow: (request) => pluginWindowRuntime.open(request),
|
||||||
unstable: { appState, configService, overlayManager },
|
unstable: { appState, configService, overlayManager },
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -5942,7 +5953,11 @@ async function dispatchSessionAction(request: SessionActionDispatchRequest): Pro
|
|||||||
},
|
},
|
||||||
playNextPlaylistItem: () =>
|
playNextPlaylistItem: () =>
|
||||||
sendMpvCommandRuntime(appState.mpvClient, ['playlist-next', 'force']),
|
sendMpvCommandRuntime(appState.mpvClient, ['playlist-next', 'force']),
|
||||||
runPluginAction: (pluginActionId) => pluginHost.runAction(pluginActionId),
|
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),
|
showMpvOsd: (text) => showConfiguredPlaybackFeedback(text),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,11 @@ import { spawn } from 'node:child_process';
|
|||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
|
|
||||||
|
import {
|
||||||
|
buildWindowsShimInvocation,
|
||||||
|
isWindowsCommandShim,
|
||||||
|
} from '../../core/utils/windows-command-shim';
|
||||||
|
|
||||||
export interface RunCommandResult {
|
export interface RunCommandResult {
|
||||||
exitCode: number | null;
|
exitCode: number | null;
|
||||||
stdout: string;
|
stdout: string;
|
||||||
@@ -142,43 +147,7 @@ export function failureMessage(result: RunCommandResult, fallback: string): stri
|
|||||||
}
|
}
|
||||||
|
|
||||||
function needsWindowsShell(command: string): boolean {
|
function needsWindowsShell(command: string): boolean {
|
||||||
return process.platform === 'win32' && /\.(cmd|bat)$/i.test(command);
|
return process.platform === 'win32' && isWindowsCommandShim(command);
|
||||||
}
|
|
||||||
|
|
||||||
/*!
|
|
||||||
* Windows command escaping adapted from cross-spawn 7.0.6.
|
|
||||||
*
|
|
||||||
* The MIT License (MIT)
|
|
||||||
*
|
|
||||||
* Copyright (c) 2018 Made With MOXY Lda <hello@moxy.studio>
|
|
||||||
*
|
|
||||||
* 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');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function createDefaultRunCommand(): RunCommand {
|
function createDefaultRunCommand(): RunCommand {
|
||||||
@@ -189,12 +158,8 @@ function createDefaultRunCommand(): RunCommand {
|
|||||||
try {
|
try {
|
||||||
const env = options.env ?? process.env;
|
const env = options.env ?? process.env;
|
||||||
if (useShell) {
|
if (useShell) {
|
||||||
const shellCommand = [
|
const invocation = buildWindowsShimInvocation(command, args, env);
|
||||||
escapeWindowsShellArgument(command),
|
child = spawn(invocation.file, invocation.args, {
|
||||||
...args.map(escapeWindowsShellArgument),
|
|
||||||
].join(' ');
|
|
||||||
const commandProcessor = env.ComSpec ?? env.COMSPEC ?? process.env.ComSpec ?? 'cmd.exe';
|
|
||||||
child = spawn(commandProcessor, ['/d', '/s', '/v:off', '/c', `"${shellCommand}"`], {
|
|
||||||
env,
|
env,
|
||||||
windowsHide: false,
|
windowsHide: false,
|
||||||
windowsVerbatimArguments: true,
|
windowsVerbatimArguments: true,
|
||||||
|
|||||||
@@ -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<void>) {
|
||||||
|
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<ReturnType<typeof createFakeWindow>> = [];
|
||||||
|
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']);
|
||||||
|
});
|
||||||
@@ -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<void>;
|
||||||
|
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<TWindow extends PluginWindowLike>(deps: {
|
||||||
|
createBrowserWindow: (options: BrowserWindowConstructorOptions) => TWindow;
|
||||||
|
promoteAboveOverlay: (window: TWindow) => void;
|
||||||
|
logError: (message: string) => void;
|
||||||
|
}) {
|
||||||
|
const windows = new Map<string, TWindow>();
|
||||||
|
|
||||||
|
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 };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in new issue
Block a user