add app control server for launcher-to-app attachment

- Launcher detects a running app via control socket and attaches without spawning a new process
- Own-lifecycle app launches now pass --background --managed-playback; borrowed apps skip --background
- Separate plain subtitle websocket (tokens: []) from annotation websocket
- Default pauseVideoOnHover to true; update docs and config.example.jsonc
- Setup: remove plugin readiness card, add Open SubMiner Settings button
This commit is contained in:
2026-05-21 01:32:58 -07:00
parent 47f92129af
commit 355d7d95b2
58 changed files with 1618 additions and 205 deletions
+22 -22
View File
@@ -4,7 +4,7 @@
# SubMiner # SubMiner
Integrates Yomitan with mpv - look up words, mine to Anki, and track your immersion without leaving the player. Integrates Yomitan and mpv - on-screen lookups, mine to Anki, and track immersion without leaving the player
[Installation](#quick-start) · [Requirements](#requirements) · [Usage](https://docs.subminer.moe/usage) · [Documentation](https://docs.subminer.moe) [Installation](#quick-start) · [Requirements](#requirements) · [Usage](https://docs.subminer.moe/usage) · [Documentation](https://docs.subminer.moe)
@@ -23,7 +23,7 @@ Integrates Yomitan with mpv - look up words, mine to Anki, and track your immers
### Dictionary Lookups ### Dictionary Lookups
Yomitan runs inside the overlay. Trigger a lookup on any word for full dictionary popups — definitions, pitch accent, frequency data without ever leaving mpv. Hover over any word and trigger a lookup to get the full Yomitan popup - definitions, pitch accent, and frequency data - without ever leaving mpv.
<div align="center"> <div align="center">
<img src="docs-site/public/screenshots/yomitan-lookup.png" width="800" alt="Yomitan dictionary popup over annotated subtitles in mpv"> <img src="docs-site/public/screenshots/yomitan-lookup.png" width="800" alt="Yomitan dictionary popup over annotated subtitles in mpv">
@@ -43,7 +43,7 @@ Create an Anki card with the sentence, audio clip, screenshot, and machine trans
### Reading Annotations ### Reading Annotations
Real-time subtitle annotations with frequency highlighting, JLPT tags, N+1 targeting, and a character name dictionary. Known words fade back; new words stand out. Grammar-only tokens render as plain text so you focus on what matters. Real-time subtitle annotations with frequency highlighting, JLPT tags, N+1 targeting, and a character name dictionary. Grammar-only tokens and particles render as plain text so you focus on what matters.
<div align="center"> <div align="center">
<img src="docs-site/public/screenshots/annotations.png" width="800" alt="Annotated subtitles with frequency coloring, JLPT underlines, and N+1 targets"> <img src="docs-site/public/screenshots/annotations.png" width="800" alt="Annotated subtitles with frequency coloring, JLPT underlines, and N+1 targets">
@@ -53,7 +53,7 @@ Real-time subtitle annotations with frequency highlighting, JLPT tags, N+1 targe
### Immersion Dashboard ### Immersion Dashboard
Local stats dashboard watch time, anime library, vocabulary growth, mining throughput, session history, and trends. All stored locally, no third-party tracking. Local stats dashboard tracking watch time, vocabulary growth, mining throughput, session history, and trends. All stored locally, no third-party tracking.
<div align="center"> <div align="center">
<img src="docs-site/public/screenshots/stats-overview.png" width="800" alt="Stats dashboard showing watch time, cards mined, streaks, and tracking data"> <img src="docs-site/public/screenshots/stats-overview.png" width="800" alt="Stats dashboard showing watch time, cards mined, streaks, and tracking data">
@@ -96,7 +96,7 @@ Browse sibling episode files and the active mpv queue in one overlay modal. Open
</tr> </tr>
<tr> <tr>
<td><b>WebSocket</b></td> <td><b>WebSocket</b></td>
<td>Annotated subtitle feed for external clients (texthooker pages, custom tools)</td> <td>Plain subtitle feed plus a dedicated annotated feed for texthooker pages and custom tools</td>
</tr> </tr>
</table> </table>
@@ -110,16 +110,17 @@ Browse sibling episode files and the active mpv queue in one overlay modal. Open
## Requirements ## Requirements
Only **mpv** is required. Everything else is optional but enhances the experience. Only **mpv** and Anki+AnkiConnect is required. Everything else is optional but enhances the experience
| Dependency | Status | What it does | | Dependency | Status | What it does |
| -------------------- | ----------- | ------------------------------------------------- | | -------------------- | ----------- | ---------------------------------------- |
| mpv | Required | The video player SubMiner overlays on | | mpv | Required | The video player SubMiner overlays on |
| ffmpeg | Recommended | Audio clips & screenshots for Anki cards | | Anki + AnkiConnect | Required | Card creation from the Yomitan popup |
| MeCab + mecab-ipadic | Recommended | More precise N+1, JLPT, and frequency annotations | | ffmpeg | Recommended | Audio clips & screenshots for Anki cards |
| yt-dlp | Optional | YouTube playback | | MeCab + mecab-ipadic | Recommended | More precise annotations and filtering |
| fzf / rofi | Optional | Video picker in the launcher | | yt-dlp | Optional | YouTube playback |
| alass / ffsubsync | Optional | Subtitle sync | | fzf / rofi | Optional | Video picker in the launcher |
| alass / ffsubsync | Optional | Subtitle sync |
<details> <details>
<summary><b>Platform-specific install commands</b></summary> <summary><b>Platform-specific install commands</b></summary>
@@ -196,25 +197,24 @@ See the [build-from-source guide](https://docs.subminer.moe/installation#from-so
Run SubMiner and the first-run setup wizard will guide you through importing Yomitan dictionaries and optionally installing the `subminer` command-line launcher. Run SubMiner and the first-run setup wizard will guide you through importing Yomitan dictionaries and optionally installing the `subminer` command-line launcher.
```bash ```bash
# Linux (AUR) # Linux
subminer app --setup subminer app --setup
# macOS — open SubMiner.app, or: # macOS — open SubMiner.app, or:
subminer app --setup subminer app --setup
``` ```
On **Windows**, just run `SubMiner.exe` setup opens automatically on first launch. On **Windows**, just run `SubMiner.exe` and the setup will open automatically on first launch.
### 3. Play ### 3. Mine
```bash ```bash
subminer video.mkv # play video with overlay subminer video.mkv # launch mpv with SubMiner
subminer stats # open immersion dashboard subminer /path/to/dir # pick a file with fzf
subminer settings # open settings window subminer -R /path/to/dir # pick a file with rofi (Linux only)
subminer --settings # open settings window via flag
``` ```
On **Windows**, use the **SubMiner mpv** shortcut created during setup — double-click it or drag a video file onto it. On **Windows**, use the **SubMiner mpv** shortcut created during setup. Double-click it or drag a video file onto it.
## Documentation ## Documentation
+1 -1
View File
@@ -1,4 +1,4 @@
type: fixed type: fixed
area: launcher area: launcher
- Reused an already-running background SubMiner app for launcher-opened videos, preserving warmups and keeping the tray app alive after playback closes. - Reused an already-running background SubMiner app for launcher-opened videos, closed launcher-owned tray apps after playback ends, and reapplied preferred subtitles for warm launches.
+4
View File
@@ -0,0 +1,4 @@
type: changed
area: setup
- Setup: Removed the bundled mpv runtime plugin readiness card; legacy mpv plugin removal still appears when needed.
+4
View File
@@ -0,0 +1,4 @@
type: fixed
area: websocket
- WebSocket: Kept the regular subtitle websocket plain-text only; annotation spans and token metadata now stay on the annotation websocket.
@@ -0,0 +1,4 @@
type: added
area: setup
- Setup: Added an Open SubMiner Settings button to first-run setup and moved Finish setup to the right-side action slot.
+1 -1
View File
@@ -438,7 +438,7 @@
"autoOpen": false, // Automatically open the subtitle sidebar once during overlay startup. Values: true | false "autoOpen": false, // Automatically open the subtitle sidebar once during overlay startup. Values: true | false
"layout": "overlay", // Render the subtitle sidebar as a floating overlay or reserve space inside mpv. Values: overlay | embedded "layout": "overlay", // Render the subtitle sidebar as a floating overlay or reserve space inside mpv. Values: overlay | embedded
"toggleKey": "Backslash", // KeyboardEvent.code used to toggle the subtitle sidebar open and closed. "toggleKey": "Backslash", // KeyboardEvent.code used to toggle the subtitle sidebar open and closed.
"pauseVideoOnHover": false, // Pause mpv while hovering the subtitle sidebar, then resume on leave. Values: true | false "pauseVideoOnHover": true, // Pause mpv while hovering the subtitle sidebar, then resume on leave. Values: true | false
"autoScroll": true, // Auto-scroll the active subtitle cue into view while playback advances. Values: true | false "autoScroll": true, // Auto-scroll the active subtitle cue into view while playback advances. Values: true | false
"css": { "css": {
"font-family": "Hiragino Sans, M PLUS 1, Source Han Sans JP, Noto Sans CJK JP", // Font family setting. "font-family": "Hiragino Sans, M PLUS 1, Source Han Sans JP, Noto Sans CJK JP", // Font family setting.
+3 -3
View File
@@ -275,7 +275,7 @@ Defaults warm local tokenizer/dictionary work (`true` for `mecab`, `yomitanExten
### WebSocket Server ### WebSocket Server
The overlay includes a built-in WebSocket server that broadcasts subtitle text to connected clients (such as texthooker-ui) for external processing. The overlay includes a built-in WebSocket server that broadcasts plain subtitle text to connected clients for external processing.
For endpoint details, payload examples, and client patterns, see [WebSocket / Texthooker API & Integration](/websocket-texthooker-api). For endpoint details, payload examples, and client patterns, see [WebSocket / Texthooker API & Integration](/websocket-texthooker-api).
@@ -443,7 +443,7 @@ Configure the parsed-subtitle sidebar modal.
"autoOpen": false, "autoOpen": false,
"layout": "overlay", "layout": "overlay",
"toggleKey": "Backslash", "toggleKey": "Backslash",
"pauseVideoOnHover": false, "pauseVideoOnHover": true,
"autoScroll": true, "autoScroll": true,
"fontFamily": "\"M PLUS 1\", \"Noto Sans CJK JP\", sans-serif", "fontFamily": "\"M PLUS 1\", \"Noto Sans CJK JP\", sans-serif",
"fontSize": 16 "fontSize": 16
@@ -457,7 +457,7 @@ Configure the parsed-subtitle sidebar modal.
| `autoOpen` | boolean | Open sidebar automatically on overlay startup (`false` by default) | | `autoOpen` | boolean | Open sidebar automatically on overlay startup (`false` by default) |
| `layout` | string | `"overlay"` floats over mpv; `"embedded"` reserves right-side player space to mimic browser-like layout | | `layout` | string | `"overlay"` floats over mpv; `"embedded"` reserves right-side player space to mimic browser-like layout |
| `toggleKey` | string | `KeyboardEvent.code` used to open/close the sidebar (default: `"Backslash"`) | | `toggleKey` | string | `KeyboardEvent.code` used to open/close the sidebar (default: `"Backslash"`) |
| `pauseVideoOnHover` | boolean | Pause playback while hovering the sidebar cue list | | `pauseVideoOnHover` | boolean | Pause playback while hovering the sidebar cue list (`true` by default) |
| `autoScroll` | boolean | Keep the active cue in view while playback advances | | `autoScroll` | boolean | Keep the active cue in view while playback advances |
| `maxWidth` | number | Maximum sidebar width in CSS pixels (default: `420`) | | `maxWidth` | number | Maximum sidebar width in CSS pixels (default: `420`) |
| `opacity` | number | Sidebar opacity between `0` and `1` (default: `0.95`) | | `opacity` | number | Sidebar opacity between `0` and `1` (default: `0.95`) |
+1 -1
View File
@@ -438,7 +438,7 @@
"autoOpen": false, // Automatically open the subtitle sidebar once during overlay startup. Values: true | false "autoOpen": false, // Automatically open the subtitle sidebar once during overlay startup. Values: true | false
"layout": "overlay", // Render the subtitle sidebar as a floating overlay or reserve space inside mpv. Values: overlay | embedded "layout": "overlay", // Render the subtitle sidebar as a floating overlay or reserve space inside mpv. Values: overlay | embedded
"toggleKey": "Backslash", // KeyboardEvent.code used to toggle the subtitle sidebar open and closed. "toggleKey": "Backslash", // KeyboardEvent.code used to toggle the subtitle sidebar open and closed.
"pauseVideoOnHover": false, // Pause mpv while hovering the subtitle sidebar, then resume on leave. Values: true | false "pauseVideoOnHover": true, // Pause mpv while hovering the subtitle sidebar, then resume on leave. Values: true | false
"autoScroll": true, // Auto-scroll the active subtitle cue into view while playback advances. Values: true | false "autoScroll": true, // Auto-scroll the active subtitle cue into view while playback advances. Values: true | false
"css": { "css": {
"font-family": "Hiragino Sans, M PLUS 1, Source Han Sans JP, Noto Sans CJK JP", // Font family setting. "font-family": "Hiragino Sans, M PLUS 1, Source Han Sans JP, Noto Sans CJK JP", // Font family setting.
+2 -2
View File
@@ -33,7 +33,7 @@ Enable and configure the sidebar under `subtitleSidebar` in your config file:
"autoOpen": false, "autoOpen": false,
"layout": "overlay", "layout": "overlay",
"toggleKey": "Backslash", "toggleKey": "Backslash",
"pauseVideoOnHover": false, "pauseVideoOnHover": true,
"autoScroll": true, "autoScroll": true,
"fontFamily": "\"M PLUS 1\", \"Noto Sans CJK JP\", sans-serif", "fontFamily": "\"M PLUS 1\", \"Noto Sans CJK JP\", sans-serif",
"fontSize": 16 "fontSize": 16
@@ -47,7 +47,7 @@ Enable and configure the sidebar under `subtitleSidebar` in your config file:
| `autoOpen` | boolean | `false` | Open the sidebar automatically on overlay startup | | `autoOpen` | boolean | `false` | Open the sidebar automatically on overlay startup |
| `layout` | string | `"overlay"` | `"overlay"` floats over mpv; `"embedded"` reserves right-side player space | | `layout` | string | `"overlay"` | `"overlay"` floats over mpv; `"embedded"` reserves right-side player space |
| `toggleKey` | string | `"Backslash"` | `KeyboardEvent.code` for the toggle shortcut | | `toggleKey` | string | `"Backslash"` | `KeyboardEvent.code` for the toggle shortcut |
| `pauseVideoOnHover` | boolean | `false` | Pause playback while hovering the cue list | | `pauseVideoOnHover` | boolean | `true` | Pause playback while hovering the cue list |
| `autoScroll` | boolean | `true` | Keep the active cue in view during playback | | `autoScroll` | boolean | `true` | Keep the active cue in view during playback |
| `maxWidth` | number | `420` | Maximum sidebar width in CSS pixels | | `maxWidth` | number | `420` | Maximum sidebar width in CSS pixels |
| `opacity` | number | `0.95` | Sidebar opacity between `0` and `1` | | `opacity` | number | `0.95` | Sidebar opacity between `0` and `1` |
+32 -21
View File
@@ -52,7 +52,7 @@ If you use the [mpv plugin](/mpv-plugin), it can also start a texthooker-only he
### 1. Subtitle WebSocket ### 1. Subtitle WebSocket
Use the basic subtitle websocket when you only need the current subtitle line and a ready-to-render HTML sentence string. Use the basic subtitle websocket when you only need the current subtitle line as plain text.
- **Default URL:** `ws://127.0.0.1:6677` - **Default URL:** `ws://127.0.0.1:6677`
- **Transport:** local WebSocket server bound to `127.0.0.1` - **Transport:** local WebSocket server bound to `127.0.0.1`
@@ -64,6 +64,36 @@ When a client connects, SubMiner immediately sends the latest subtitle payload i
#### Message shape #### Message shape
```json
{
"version": 1,
"text": "無事",
"sentence": "無事",
"tokens": []
}
```
#### Field reference
| Field | Type | Notes |
| --- | --- | --- |
| `version` | number | Current websocket payload version. Today this is `1`. |
| `text` | string | Raw subtitle text. |
| `sentence` | string | Plain subtitle text with line breaks represented as `<br>`. No annotation spans or attributes. |
| `tokens` | array | Always empty on the basic subtitle websocket. |
### 2. Annotation WebSocket
Use the annotation websocket for custom clients that want the same structured token payload the bundled texthooker UI consumes.
- **Default URL:** `ws://127.0.0.1:6678`
- **Payload shape:** JSON payload with `text`, rendered `sentence` HTML, and token metadata
- **Primary difference:** this stream is intended to stay on even when the basic websocket auto-disables because `mpv_websocket` is installed
In practice, if you are building a new client, prefer `annotationWebsocket` unless you specifically need compatibility with an existing `websocket` consumer.
#### Message shape
```json ```json
{ {
"version": 1, "version": 1,
@@ -91,16 +121,7 @@ When a client connects, SubMiner immediately sends the latest subtitle payload i
} }
``` ```
#### Field reference Each annotation token may include:
| Field | Type | Notes |
| --- | --- | --- |
| `version` | number | Current websocket payload version. Today this is `1`. |
| `text` | string | Raw subtitle text. |
| `sentence` | string | HTML string with `<span>` wrappers and `data-*` attributes for client rendering. |
| `tokens` | array | Token metadata; empty when the subtitle is not tokenized yet. |
Each token may include:
| Token field | Type | Notes | | Token field | Type | Notes |
| --- | --- | --- | | --- | --- | --- |
@@ -119,16 +140,6 @@ Each token may include:
| `frequencyRankLabel` | string or `null` | Preformatted rank label for UIs | | `frequencyRankLabel` | string or `null` | Preformatted rank label for UIs |
| `jlptLevelLabel` | string or `null` | Preformatted JLPT label for UIs | | `jlptLevelLabel` | string or `null` | Preformatted JLPT label for UIs |
### 2. Annotation WebSocket
Use the annotation websocket for custom clients that want the same structured token payload the bundled texthooker UI consumes.
- **Default URL:** `ws://127.0.0.1:6678`
- **Payload shape:** same JSON contract as the basic subtitle websocket
- **Primary difference:** this stream is intended to stay on even when the basic websocket auto-disables because `mpv_websocket` is installed
In practice, if you are building a new client, prefer `annotationWebsocket` unless you specifically need compatibility with an existing `websocket` consumer.
### 3. HTML markup conventions ### 3. HTML markup conventions
The `sentence` field is pre-rendered HTML generated by SubMiner. Depending on token state, it can include classes such as: The `sentence` field is pre-rendered HTML generated by SubMiner. Depending on token state, it can include classes such as:
@@ -206,3 +206,65 @@ test('plugin auto-start playback leaves app lifetime to managed-playback owner',
state.overlayManagedByLauncher = false; state.overlayManagedByLauncher = false;
} }
}); });
test('plugin auto-start playback attaches a warm background app through the launcher', async () => {
const context = createContext();
context.args = {
...context.args,
target: '/tmp/movie.mkv',
targetKind: 'file',
};
context.pluginRuntimeConfig = {
socketPath: '/tmp/subminer.sock',
binaryPath: '',
backend: 'auto',
autoStart: true,
autoStartVisibleOverlay: true,
autoStartPauseUntilReady: true,
texthookerEnabled: true,
aniskipEnabled: true,
aniskipButtonKey: 'TAB',
};
const calls: string[] = [];
const receivedStartMpvOptions: Record<string, unknown>[] = [];
await runPlaybackCommandWithDeps(context, {
ensurePlaybackSetupReady: async () => {},
chooseTarget: async () => ({ target: context.args.target, kind: 'file' }),
checkDependencies: () => {},
registerCleanup: () => {},
startMpv: async (
_target,
_targetKind,
_args,
_socketPath,
_appPath,
_preloadedSubtitles,
options,
) => {
calls.push('startMpv');
if (options) {
receivedStartMpvOptions.push(options as Record<string, unknown>);
}
},
waitForUnixSocketReady: async () => true,
startOverlay: async (_appPath, _args, _socketPath, extraAppArgs = []) => {
calls.push(`startOverlay:${extraAppArgs.join(' ')}`);
},
launchAppCommandDetached: () => {},
log: () => {},
cleanupPlaybackSession: async () => {},
getMpvProc: () => null,
isAppControlServerAvailable: async () => true,
} as Parameters<typeof runPlaybackCommandWithDeps>[1] & {
isAppControlServerAvailable: () => Promise<boolean>;
});
assert.deepEqual(calls, ['startMpv', 'startOverlay:--show-visible-overlay --texthooker']);
assert.equal(receivedStartMpvOptions[0]?.startPaused, false);
assert.equal(
(receivedStartMpvOptions[0]?.runtimePluginConfig as { autoStart?: boolean } | undefined)
?.autoStart,
false,
);
});
+33 -13
View File
@@ -8,6 +8,7 @@ import {
cleanupPlaybackSession, cleanupPlaybackSession,
launchAppCommandDetached, launchAppCommandDetached,
resolveLauncherRuntimePluginPath, resolveLauncherRuntimePluginPath,
isRunningAppControlServerAvailable,
startMpv, startMpv,
startOverlay, startOverlay,
state, state,
@@ -146,6 +147,7 @@ export async function runPlaybackCommand(context: LauncherCommandContext): Promi
waitForUnixSocketReady, waitForUnixSocketReady,
startOverlay, startOverlay,
launchAppCommandDetached, launchAppCommandDetached,
isAppControlServerAvailable: isRunningAppControlServerAvailable,
log, log,
cleanupPlaybackSession, cleanupPlaybackSession,
getMpvProc: () => state.mpvProc, getMpvProc: () => state.mpvProc,
@@ -164,6 +166,7 @@ type PlaybackCommandDeps = {
waitForUnixSocketReady: typeof waitForUnixSocketReady; waitForUnixSocketReady: typeof waitForUnixSocketReady;
startOverlay: typeof startOverlay; startOverlay: typeof startOverlay;
launchAppCommandDetached: typeof launchAppCommandDetached; launchAppCommandDetached: typeof launchAppCommandDetached;
isAppControlServerAvailable?: (logLevel: Args['logLevel']) => Promise<boolean>;
log: typeof log; log: typeof log;
cleanupPlaybackSession: typeof cleanupPlaybackSession; cleanupPlaybackSession: typeof cleanupPlaybackSession;
getMpvProc: () => typeof state.mpvProc; getMpvProc: () => typeof state.mpvProc;
@@ -213,8 +216,19 @@ export async function runPlaybackCommandWithDeps(
deps.log('info', args.logLevel, 'YouTube subtitle flow: app-owned picker after mpv bootstrap'); deps.log('info', args.logLevel, 'YouTube subtitle flow: app-owned picker after mpv bootstrap');
} }
const pluginAutoStartEnabled = pluginRuntimeConfig.autoStart;
const shouldLauncherAttachRunningApp =
pluginAutoStartEnabled &&
!args.startOverlay &&
!args.autoStartOverlay &&
!isAppOwnedYoutubeFlow &&
((await deps.isAppControlServerAvailable?.(args.logLevel)) ?? false);
const effectivePluginRuntimeConfig = shouldLauncherAttachRunningApp
? { ...pluginRuntimeConfig, autoStart: false }
: pluginRuntimeConfig;
const shouldPauseUntilOverlayReady = const shouldPauseUntilOverlayReady =
pluginRuntimeConfig.autoStart && effectivePluginRuntimeConfig.autoStart &&
pluginRuntimeConfig.autoStartVisibleOverlay && pluginRuntimeConfig.autoStartVisibleOverlay &&
pluginRuntimeConfig.autoStartPauseUntilReady; pluginRuntimeConfig.autoStartPauseUntilReady;
@@ -238,16 +252,19 @@ export async function runPlaybackCommandWithDeps(
disableYoutubeSubtitleAutoLoad: isAppOwnedYoutubeFlow, disableYoutubeSubtitleAutoLoad: isAppOwnedYoutubeFlow,
runtimePluginPath: resolveLauncherRuntimePluginPath({ appPath, scriptPath }), runtimePluginPath: resolveLauncherRuntimePluginPath({ appPath, scriptPath }),
runtimePluginConfig: { runtimePluginConfig: {
...pluginRuntimeConfig, ...effectivePluginRuntimeConfig,
backend: args.backend, backend: args.backend,
texthookerEnabled: args.useTexthooker && pluginRuntimeConfig.texthookerEnabled, texthookerEnabled: args.useTexthooker && effectivePluginRuntimeConfig.texthookerEnabled,
}, },
}, },
); );
const ready = await deps.waitForUnixSocketReady(mpvSocketPath, 10000); const ready = await deps.waitForUnixSocketReady(mpvSocketPath, 10000);
const pluginAutoStartEnabled = pluginRuntimeConfig.autoStart; const shouldStartOverlay =
const shouldStartOverlay = args.startOverlay || args.autoStartOverlay || isAppOwnedYoutubeFlow; args.startOverlay ||
args.autoStartOverlay ||
isAppOwnedYoutubeFlow ||
shouldLauncherAttachRunningApp;
if (shouldStartOverlay) { if (shouldStartOverlay) {
if (ready) { if (ready) {
deps.log('info', args.logLevel, 'MPV IPC socket ready, starting SubMiner overlay'); deps.log('info', args.logLevel, 'MPV IPC socket ready, starting SubMiner overlay');
@@ -258,14 +275,17 @@ export async function runPlaybackCommandWithDeps(
'MPV IPC socket not ready after timeout, starting SubMiner overlay anyway', 'MPV IPC socket not ready after timeout, starting SubMiner overlay anyway',
); );
} }
await deps.startOverlay( const extraAppArgs = isAppOwnedYoutubeFlow
appPath, ? ['--youtube-play', selectedTarget.target, '--youtube-mode', youtubeMode]
args, : shouldLauncherAttachRunningApp
mpvSocketPath, ? [
isAppOwnedYoutubeFlow pluginRuntimeConfig.autoStartVisibleOverlay
? ['--youtube-play', selectedTarget.target, '--youtube-mode', youtubeMode] ? '--show-visible-overlay'
: [], : '--hide-visible-overlay',
); ...(pluginRuntimeConfig.texthookerEnabled ? ['--texthooker'] : []),
]
: [];
await deps.startOverlay(appPath, args, mpvSocketPath, extraAppArgs);
} else if (pluginAutoStartEnabled) { } else if (pluginAutoStartEnabled) {
if (ready) { if (ready) {
deps.log('info', args.logLevel, 'MPV IPC socket ready, relying on mpv plugin auto-start'); deps.log('info', args.logLevel, 'MPV IPC socket ready, relying on mpv plugin auto-start');
+126
View File
@@ -655,6 +655,48 @@ test('startOverlay captures app stdout and stderr into app log', async () => {
} }
}); });
test('startOverlay starts launcher-owned playback in background managed mode', async () => {
const { dir, socketPath } = createTempSocketPath();
const appPath = path.join(dir, 'fake-subminer.sh');
const appInvocationsPath = path.join(dir, 'app-invocations.log');
fs.writeFileSync(
appPath,
[
'#!/bin/sh',
`printf '%s\\n' "$@" >> ${JSON.stringify(appInvocationsPath)}`,
'if [ "$1" = "--app-ping" ]; then exit 1; fi',
'exit 0',
'',
].join('\n'),
);
fs.chmodSync(appPath, 0o755);
fs.writeFileSync(socketPath, '');
const originalCreateConnection = net.createConnection;
try {
net.createConnection = (() => {
const socket = new EventEmitter() as net.Socket;
socket.destroy = (() => socket) as net.Socket['destroy'];
socket.setTimeout = (() => socket) as net.Socket['setTimeout'];
setTimeout(() => socket.emit('connect'), 10);
return socket;
}) as typeof net.createConnection;
await startOverlay(appPath, makeArgs(), socketPath);
const invocationText = fs.readFileSync(appInvocationsPath, 'utf8');
assert.match(invocationText, /--background/);
assert.match(invocationText, /--managed-playback/);
assert.equal(state.overlayManagedByLauncher, true);
assert.equal(state.appPath, appPath);
} finally {
net.createConnection = originalCreateConnection;
state.overlayProc = null;
state.overlayManagedByLauncher = false;
state.appPath = '';
fs.rmSync(dir, { recursive: true, force: true });
}
});
test('startOverlay borrows an already-running background app instead of owning its lifecycle', async () => { test('startOverlay borrows an already-running background app instead of owning its lifecycle', async () => {
const { dir, socketPath } = createTempSocketPath(); const { dir, socketPath } = createTempSocketPath();
const appPath = path.join(dir, 'fake-subminer.sh'); const appPath = path.join(dir, 'fake-subminer.sh');
@@ -686,6 +728,7 @@ test('startOverlay borrows an already-running background app instead of owning i
const invocationText = fs.readFileSync(appInvocationsPath, 'utf8'); const invocationText = fs.readFileSync(appInvocationsPath, 'utf8');
assert.match(invocationText, /--app-ping/); assert.match(invocationText, /--app-ping/);
assert.match(invocationText, /--start/); assert.match(invocationText, /--start/);
assert.doesNotMatch(invocationText, /--background/);
assert.equal(state.overlayManagedByLauncher, false); assert.equal(state.overlayManagedByLauncher, false);
assert.equal(state.appPath, ''); assert.equal(state.appPath, '');
} finally { } finally {
@@ -697,6 +740,89 @@ test('startOverlay borrows an already-running background app instead of owning i
} }
}); });
test('startOverlay attaches through the running app control socket without spawning another app command', async () => {
if (process.platform === 'win32') return;
const { dir, socketPath } = createTempSocketPath();
const controlSocketPath = path.join(dir, 'control.sock');
const appPath = path.join(dir, 'fake-subminer.sh');
const appInvocationsPath = path.join(dir, 'app-invocations.log');
const receivedControlArgv: string[][] = [];
const originalControlSocket = process.env.SUBMINER_APP_CONTROL_SOCKET;
fs.writeFileSync(
appPath,
[
'#!/bin/sh',
`printf '%s\\n' "$@" >> ${JSON.stringify(appInvocationsPath)}`,
'if [ "$1" = "--app-ping" ]; then exit 0; fi',
'exit 0',
'',
].join('\n'),
);
fs.chmodSync(appPath, 0o755);
const mpvServer = net.createServer((socket) => socket.end());
const controlServer = net.createServer((socket) => {
let buffer = '';
socket.on('data', (chunk) => {
buffer += chunk.toString('utf8');
const line = buffer.split(/\r?\n/, 1)[0];
if (!line) return;
const payload = JSON.parse(line) as { argv?: unknown };
if (Array.isArray(payload.argv)) {
receivedControlArgv.push(
payload.argv.filter((value): value is string => typeof value === 'string'),
);
}
socket.end(JSON.stringify({ ok: true }) + '\n');
});
});
try {
process.env.SUBMINER_APP_CONTROL_SOCKET = controlSocketPath;
await new Promise<void>((resolve, reject) => {
mpvServer.once('error', reject);
mpvServer.listen(socketPath, resolve);
});
await new Promise<void>((resolve, reject) => {
controlServer.once('error', reject);
controlServer.listen(controlSocketPath, resolve);
});
await startOverlay(appPath, makeArgs(), socketPath);
const invocationText = fs.existsSync(appInvocationsPath)
? fs.readFileSync(appInvocationsPath, 'utf8')
: '';
assert.equal(invocationText, '');
assert.equal(receivedControlArgv.length, 1);
assert.deepEqual(receivedControlArgv[0]?.slice(0, 7), [
'--start',
'--managed-playback',
'--backend',
'x11',
'--socket',
socketPath,
'--log-level',
]);
assert.equal(state.overlayManagedByLauncher, false);
assert.equal(state.appPath, '');
} finally {
if (originalControlSocket === undefined) {
delete process.env.SUBMINER_APP_CONTROL_SOCKET;
} else {
process.env.SUBMINER_APP_CONTROL_SOCKET = originalControlSocket;
}
await new Promise<void>((resolve) => mpvServer.close(() => resolve()));
await new Promise<void>((resolve) => controlServer.close(() => resolve()));
state.overlayProc = null;
state.overlayManagedByLauncher = false;
state.appPath = '';
fs.rmSync(dir, { recursive: true, force: true });
}
});
test('startOverlay keeps lifecycle ownership for its already-managed app', async () => { test('startOverlay keeps lifecycle ownership for its already-managed app', async () => {
const { dir, socketPath } = createTempSocketPath(); const { dir, socketPath } = createTempSocketPath();
const appPath = path.join(dir, 'fake-subminer.sh'); const appPath = path.join(dir, 'fake-subminer.sh');
+77 -4
View File
@@ -4,6 +4,11 @@ import os from 'node:os';
import net from 'node:net'; import net from 'node:net';
import { spawn, spawnSync } from 'node:child_process'; import { spawn, spawnSync } from 'node:child_process';
import { buildMpvLaunchModeArgs } from '../src/shared/mpv-launch-mode.js'; import { buildMpvLaunchModeArgs } from '../src/shared/mpv-launch-mode.js';
import {
isAppControlServerAvailable as checkAppControlServerAvailable,
sendAppControlCommand,
} from '../src/shared/app-control-client.js';
import { getDefaultConfigDir } from '../src/shared/setup-state.js';
import { import {
detectInstalledMpvPlugin, detectInstalledMpvPlugin,
type InstalledMpvPluginDetection, type InstalledMpvPluginDetection,
@@ -1004,19 +1009,70 @@ export async function startOverlay(
): Promise<void> { ): Promise<void> {
const backend = detectBackend(args.backend); const backend = detectBackend(args.backend);
log('info', args.logLevel, `Starting SubMiner overlay (backend: ${backend})...`); log('info', args.logLevel, `Starting SubMiner overlay (backend: ${backend})...`);
const appAlreadyRunning = isAppAlreadyRunning(appPath, args.logLevel); const alreadyManagedByLauncher = state.overlayManagedByLauncher && state.appPath === appPath;
const overlayArgs = ['--start', '--backend', backend, '--socket', socketPath, ...extraAppArgs]; const overlayArgs = [
'--start',
'--managed-playback',
'--backend',
backend,
'--socket',
socketPath,
...extraAppArgs,
];
if (args.logLevel !== 'info') overlayArgs.push('--log-level', args.logLevel); if (args.logLevel !== 'info') overlayArgs.push('--log-level', args.logLevel);
if (args.useTexthooker) overlayArgs.push('--texthooker'); if (args.useTexthooker) overlayArgs.push('--texthooker');
const target = resolveAppSpawnTarget(appPath, overlayArgs); const controlResult = await sendAppControlCommand(overlayArgs, {
configDir: getLauncherConfigDir(),
});
if (controlResult.ok) {
log('debug', args.logLevel, 'Attached to running SubMiner app via control socket');
if (alreadyManagedByLauncher) {
markOverlayManagedByLauncher(appPath);
} else {
clearOverlayManagedByLauncher();
state.overlayProc = null;
}
const socketReady = await waitForUnixSocketReady(
socketPath,
OVERLAY_START_SOCKET_READY_TIMEOUT_MS,
);
if (!socketReady) {
log(
'debug',
args.logLevel,
'Overlay start continuing before mpv socket readiness was confirmed',
);
}
return;
}
if (controlResult.unavailable !== true) {
log(
'warn',
args.logLevel,
`Running SubMiner app control command failed: ${controlResult.error ?? 'unknown error'}`,
);
if (!alreadyManagedByLauncher) {
clearOverlayManagedByLauncher();
state.overlayProc = null;
}
return;
}
const appAlreadyRunning = isAppAlreadyRunning(appPath, args.logLevel);
const borrowingExistingApp = appAlreadyRunning && !alreadyManagedByLauncher;
const spawnOverlayArgs = [...overlayArgs];
if (!borrowingExistingApp) spawnOverlayArgs.unshift('--background');
const target = resolveAppSpawnTarget(appPath, spawnOverlayArgs);
state.overlayProc = spawn(target.command, target.args, { state.overlayProc = spawn(target.command, target.args, {
stdio: ['ignore', 'pipe', 'pipe'], stdio: ['ignore', 'pipe', 'pipe'],
env: buildAppEnv(process.env, target.env), env: buildAppEnv(process.env, target.env),
}); });
attachAppProcessLogging(state.overlayProc); attachAppProcessLogging(state.overlayProc);
if (appAlreadyRunning && !(state.overlayManagedByLauncher && state.appPath === appPath)) { if (borrowingExistingApp) {
log( log(
'debug', 'debug',
args.logLevel, args.logLevel,
@@ -1045,6 +1101,23 @@ export async function startOverlay(
} }
} }
function getLauncherConfigDir(): string {
return getDefaultConfigDir({
xdgConfigHome: process.env.XDG_CONFIG_HOME,
homeDir: os.homedir(),
});
}
export async function isRunningAppControlServerAvailable(logLevel: LogLevel): Promise<boolean> {
const available = await checkAppControlServerAvailable({
configDir: getLauncherConfigDir(),
});
if (available) {
log('debug', logLevel, 'Running SubMiner app control socket detected');
}
return available;
}
export function markOverlayManagedByLauncher(appPath?: string): void { export function markOverlayManagedByLauncher(appPath?: string): void {
if (appPath) { if (appPath) {
state.appPath = appPath; state.appPath = appPath;
+118 -29
View File
@@ -238,6 +238,84 @@ async function waitForJsonLines(
} }
} }
async function waitForFile(filePath: string, timeoutMs = 1500): Promise<void> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (fs.existsSync(filePath)) return;
await new Promise<void>((resolve) => setTimeout(resolve, 50));
}
}
async function startFakeControlServer(
smokeCase: SmokeCase,
): Promise<{ socketPath: string; logPath: string; stop: () => Promise<void> }> {
const socketPath = path.join(smokeCase.socketDir, 'app-control.sock');
const logPath = path.join(smokeCase.artifactsDir, 'fake-control.log');
const readyPath = path.join(smokeCase.artifactsDir, 'fake-control.ready');
const scriptPath = path.join(smokeCase.artifactsDir, 'fake-control-server.js');
fs.writeFileSync(
scriptPath,
`const fs = require('node:fs');
const net = require('node:net');
const path = require('node:path');
const socketPath = ${JSON.stringify(socketPath)};
const logPath = ${JSON.stringify(logPath)};
const readyPath = ${JSON.stringify(readyPath)};
try { fs.rmSync(socketPath, { force: true }); } catch {}
fs.mkdirSync(path.dirname(socketPath), { recursive: true });
const server = net.createServer((socket) => {
let buffer = '';
socket.on('data', (chunk) => {
buffer += chunk.toString('utf8');
const line = buffer.split(/\\r?\\n/, 1)[0];
if (!line) return;
fs.appendFileSync(logPath, line + '\\n');
socket.end(JSON.stringify({ ok: true }) + '\\n');
});
});
server.listen(socketPath, () => {
fs.writeFileSync(readyPath, 'ready');
});
const shutdown = () => {
server.close(() => {
try { fs.rmSync(socketPath, { force: true }); } catch {}
process.exit(0);
});
};
process.on('SIGTERM', shutdown);
process.on('SIGINT', shutdown);
setInterval(() => {}, 1000);
`,
);
const proc = spawn(process.execPath, [scriptPath], { stdio: 'ignore' });
await waitForFile(readyPath);
return {
socketPath,
logPath,
stop: async () => {
if (proc.exitCode !== null || proc.signalCode !== null) return;
proc.kill('SIGTERM');
await new Promise<void>((resolve) => {
const timer = setTimeout(() => {
proc.kill('SIGKILL');
resolve();
}, 1000);
proc.once('close', () => {
clearTimeout(timer);
resolve();
});
});
},
};
}
test('launcher smoke fixture seeds completed setup state', () => { test('launcher smoke fixture seeds completed setup state', () => {
const smokeCase = createSmokeCase('setup-state'); const smokeCase = createSmokeCase('setup-state');
try { try {
@@ -295,7 +373,7 @@ test('launcher mpv status returns ready when socket is connectable', async () =>
}); });
test( test(
'launcher start-overlay run forwards socket/backend and keeps background app alive after mpv exits', 'launcher start-overlay run forwards socket/backend and stops owned background app after mpv exits',
{ timeout: LONG_SMOKE_TEST_TIMEOUT_MS }, { timeout: LONG_SMOKE_TEST_TIMEOUT_MS },
async () => { async () => {
await withSmokeCase('overlay-start-stop', async (smokeCase) => { await withSmokeCase('overlay-start-stop', async (smokeCase) => {
@@ -330,7 +408,9 @@ test(
const appStartArgs = appStartEntries[0]?.argv; const appStartArgs = appStartEntries[0]?.argv;
assert.equal(Array.isArray(appStartArgs), true); assert.equal(Array.isArray(appStartArgs), true);
assert.equal((appStartArgs as string[]).includes('--background'), true);
assert.equal((appStartArgs as string[]).includes('--start'), true); assert.equal((appStartArgs as string[]).includes('--start'), true);
assert.equal((appStartArgs as string[]).includes('--managed-playback'), true);
assert.equal((appStartArgs as string[]).includes('--backend'), true); assert.equal((appStartArgs as string[]).includes('--backend'), true);
assert.equal((appStartArgs as string[]).includes('x11'), true); assert.equal((appStartArgs as string[]).includes('x11'), true);
assert.equal((appStartArgs as string[]).includes('--socket'), true); assert.equal((appStartArgs as string[]).includes('--socket'), true);
@@ -351,44 +431,53 @@ test(
); );
test( test(
'launcher start-overlay borrows a running background app and does not stop it after mpv exits', 'launcher start-overlay attaches to a running background app without spawning another app command',
{ timeout: LONG_SMOKE_TEST_TIMEOUT_MS }, { timeout: LONG_SMOKE_TEST_TIMEOUT_MS },
async () => { async () => {
await withSmokeCase('overlay-borrow-background', async (smokeCase) => { await withSmokeCase('overlay-borrow-background', async (smokeCase) => {
const controlServer = await startFakeControlServer(smokeCase);
const env = { const env = {
...makeTestEnv(smokeCase), ...makeTestEnv(smokeCase),
SUBMINER_FAKE_APP_RUNNING: '1', SUBMINER_FAKE_APP_RUNNING: '1',
SUBMINER_APP_CONTROL_SOCKET: controlServer.socketPath,
}; };
const result = runLauncher( try {
smokeCase, const result = runLauncher(
['--backend', 'x11', '--start-overlay', smokeCase.videoPath], smokeCase,
env, ['--backend', 'x11', '--start-overlay', smokeCase.videoPath],
'overlay-borrow-background', env,
); 'overlay-borrow-background',
);
const appLogPath = path.join(smokeCase.artifactsDir, 'fake-app.log'); const appLogPath = path.join(smokeCase.artifactsDir, 'fake-app.log');
const appStartPath = path.join(smokeCase.artifactsDir, 'fake-app-start.log'); const appStartPath = path.join(smokeCase.artifactsDir, 'fake-app-start.log');
const appStopPath = path.join(smokeCase.artifactsDir, 'fake-app-stop.log'); const appStopPath = path.join(smokeCase.artifactsDir, 'fake-app-stop.log');
await waitForJsonLines(appStartPath, 1); await waitForJsonLines(controlServer.logPath, 1);
const appEntries = readJsonLines(appLogPath); const appEntries = readJsonLines(appLogPath);
const appStartEntries = readJsonLines(appStartPath); const appStartEntries = readJsonLines(appStartPath);
const appStopEntries = readJsonLines(appStopPath); const appStopEntries = readJsonLines(appStopPath);
const mpvEntries = readJsonLines(path.join(smokeCase.artifactsDir, 'fake-mpv.log')); const controlEntries = readJsonLines(controlServer.logPath);
const mpvError = mpvEntries.find( const mpvEntries = readJsonLines(path.join(smokeCase.artifactsDir, 'fake-mpv.log'));
(entry): entry is { error: string } => typeof entry.error === 'string', const mpvError = mpvEntries.find(
)?.error; (entry): entry is { error: string } => typeof entry.error === 'string',
const unixSocketDenied = )?.error;
typeof mpvError === 'string' && /eperm|operation not permitted/i.test(mpvError); const unixSocketDenied =
typeof mpvError === 'string' && /eperm|operation not permitted/i.test(mpvError);
assert.equal(result.status, unixSocketDenied ? 3 : 0); assert.equal(result.status, unixSocketDenied ? 3 : 0);
assert.ok( assert.equal(appEntries.length, 0);
appEntries.some( assert.equal(appStartEntries.length, 0);
(entry) => Array.isArray(entry.argv) && (entry.argv as string[]).includes('--app-ping'), assert.equal(appStopEntries.length, 0);
), assert.equal(controlEntries.length, 1);
); const controlArgs = controlEntries[0]?.argv;
assert.equal(appStartEntries.length, 1); assert.equal(Array.isArray(controlArgs), true);
assert.equal(appStopEntries.length, 0); assert.equal((controlArgs as string[]).includes('--background'), false);
assert.equal((controlArgs as string[]).includes('--start'), true);
assert.equal((controlArgs as string[]).includes('--managed-playback'), true);
} finally {
await controlServer.stop();
}
}); });
}, },
); );
+10 -4
View File
@@ -207,6 +207,9 @@ function M.create(ctx)
end end
if action == "start" then if action == "start" then
if overrides.background ~= false then
table.insert(args, "--background")
end
table.insert(args, "--managed-playback") table.insert(args, "--managed-playback")
local backend = resolve_backend(overrides.backend) local backend = resolve_backend(overrides.backend)
@@ -504,10 +507,13 @@ function M.create(ctx)
end) end)
end end
launch_overlay_with_retry(1) environment.is_subminer_app_running_async(function(app_running)
if texthooker_enabled then overrides.background = not app_running
ensure_texthooker_running(function() end) launch_overlay_with_retry(1)
end if texthooker_enabled then
ensure_texthooker_running(function() end)
end
end, { force_refresh = true })
end end
local function start_overlay_from_script_message(...) local function start_overlay_from_script_message(...)
+54 -11
View File
@@ -757,17 +757,17 @@ do
assert_true(call ~= nil, "AppImage start should issue an async subprocess") assert_true(call ~= nil, "AppImage start should issue an async subprocess")
assert_true(#call.args == 1 and call.args[1] == appimage_path, "AppImage subprocess should not receive raw CLI flags") assert_true(#call.args == 1 and call.args[1] == appimage_path, "AppImage subprocess should not receive raw CLI flags")
assert_true(env_has(call, "PATH=/usr/bin"), "AppImage subprocess should preserve existing environment") assert_true(env_has(call, "PATH=/usr/bin"), "AppImage subprocess should preserve existing environment")
assert_true(env_has(call, "SUBMINER_APP_ARGC=7"), "AppImage subprocess should transport app arg count") assert_true(env_has(call, "SUBMINER_APP_ARGC=8"), "AppImage subprocess should transport app arg count")
assert_true(env_has(call, "SUBMINER_APP_ARG_0=--start"), "AppImage subprocess should transport --start") assert_true(env_has(call, "SUBMINER_APP_ARG_0=--start"), "AppImage subprocess should transport --start")
assert_true( assert_true(
env_has(call, "SUBMINER_APP_ARG_1=--managed-playback"), env_has(call, "SUBMINER_APP_ARG_1=--background"),
"AppImage subprocess should transport --managed-playback" "AppImage subprocess should transport --background"
) )
assert_true( assert_true(
not env_has(call, "SUBMINER_APP_ARG_1=--background"), env_has(call, "SUBMINER_APP_ARG_2=--managed-playback"),
"AppImage subprocess should not transport --background for video-owned playback" "AppImage subprocess should transport --managed-playback"
) )
assert_true(env_has(call, "SUBMINER_APP_ARG_6=--hide-visible-overlay"), "AppImage subprocess should transport visibility flag") assert_true(env_has(call, "SUBMINER_APP_ARG_7=--hide-visible-overlay"), "AppImage subprocess should transport visibility flag")
assert_true(env_has_prefix(call, "SUBMINER_APP_LOG="), "AppImage subprocess should include app log env") assert_true(env_has_prefix(call, "SUBMINER_APP_LOG="), "AppImage subprocess should include app log env")
assert_true(env_has_prefix(call, "SUBMINER_MPV_LOG="), "AppImage subprocess should include mpv log env") assert_true(env_has_prefix(call, "SUBMINER_MPV_LOG="), "AppImage subprocess should include mpv log env")
assert_true( assert_true(
@@ -1274,12 +1274,12 @@ do
local start_call = find_start_call(recorded.async_calls) local start_call = find_start_call(recorded.async_calls)
assert_true(start_call ~= nil, "auto-start should issue --start command") assert_true(start_call ~= nil, "auto-start should issue --start command")
assert_true( assert_true(
not call_has_arg(start_call, "--background"), call_has_arg(start_call, "--background"),
"auto-start should not mark video-owned playback as background/tray mode" "auto-start should launch SubMiner in background/tray mode"
) )
assert_true( assert_true(
call_has_arg(start_call, "--managed-playback"), call_has_arg(start_call, "--managed-playback"),
"auto-start should mark SubMiner as launcher-managed playback" "auto-start should mark SubMiner as managed playback"
) )
assert_true(call_has_arg(start_call, "--texthooker"), "auto-start should include --texthooker on the main --start command when enabled") assert_true(call_has_arg(start_call, "--texthooker"), "auto-start should include --texthooker on the main --start command when enabled")
assert_true(find_control_call(recorded.async_calls, "--texthooker") == nil, "auto-start should not issue a separate texthooker helper command") assert_true(find_control_call(recorded.async_calls, "--texthooker") == nil, "auto-start should not issue a separate texthooker helper command")
@@ -1596,7 +1596,7 @@ do
[binary_path] = true, [binary_path] = true,
}, },
}) })
assert_true(recorded ~= nil, "plugin failed to load for shutdown-preserve-background scenario: " .. tostring(err)) assert_true(recorded ~= nil, "plugin failed to load for shutdown-managed-background scenario: " .. tostring(err))
fire_event(recorded, "file-loaded") fire_event(recorded, "file-loaded")
fire_event(recorded, "end-file", { reason = "quit" }) fire_event(recorded, "end-file", { reason = "quit" })
assert_true( assert_true(
@@ -1606,7 +1606,7 @@ do
fire_event(recorded, "shutdown") fire_event(recorded, "shutdown")
assert_true( assert_true(
find_control_call(recorded.async_calls, "--stop") == nil, find_control_call(recorded.async_calls, "--stop") == nil,
"mpv shutdown should not stop the background SubMiner process" "mpv shutdown should leave managed-playback ownership to the app process"
) )
assert_true( assert_true(
find_control_call(recorded.async_calls, "--hide-visible-overlay") == nil, find_control_call(recorded.async_calls, "--hide-visible-overlay") == nil,
@@ -1614,6 +1614,41 @@ do
) )
end end
do
local recorded, err = run_plugin_scenario({
process_list = "/opt/SubMiner/subminer --background\n",
option_overrides = {
binary_path = binary_path,
auto_start = "yes",
auto_start_visible_overlay = "yes",
socket_path = "/tmp/subminer-socket",
},
input_ipc_server = "/tmp/subminer-socket",
media_title = "Random Movie",
files = {
[binary_path] = true,
},
})
assert_true(recorded ~= nil, "plugin failed to load for shutdown-borrowed-background scenario: " .. tostring(err))
fire_event(recorded, "file-loaded")
local start_call = find_start_call(recorded.async_calls)
assert_true(start_call ~= nil, "auto-start should attach playback to the existing app")
assert_true(
not call_has_arg(start_call, "--background"),
"borrowed app auto-start should not use the background launch wrapper"
)
assert_true(
call_has_arg(start_call, "--managed-playback"),
"borrowed app auto-start should still attach managed playback to the existing app"
)
fire_event(recorded, "end-file", { reason = "quit" })
fire_event(recorded, "shutdown")
assert_true(
find_control_call(recorded.async_calls, "--stop") == nil,
"mpv shutdown should leave a pre-existing background SubMiner process running"
)
end
do do
local recorded, err = run_plugin_scenario({ local recorded, err = run_plugin_scenario({
process_list = "", process_list = "",
@@ -1633,6 +1668,14 @@ do
fire_event(recorded, "file-loaded") fire_event(recorded, "file-loaded")
local start_call = find_start_call(recorded.async_calls) local start_call = find_start_call(recorded.async_calls)
assert_true(start_call ~= nil, "auto-start should issue --start command") assert_true(start_call ~= nil, "auto-start should issue --start command")
assert_true(
call_has_arg(start_call, "--background"),
"auto-start should launch SubMiner in background mode"
)
assert_true(
call_has_arg(start_call, "--managed-playback"),
"auto-start should mark SubMiner as managed playback"
)
assert_true( assert_true(
call_has_arg(start_call, "--hide-visible-overlay"), call_has_arg(start_call, "--hide-visible-overlay"),
"auto-start with visible overlay disabled should include --hide-visible-overlay on --start" "auto-start with visible overlay disabled should include --hide-visible-overlay on --start"
+1
View File
@@ -101,6 +101,7 @@ test('loads defaults when config is missing', () => {
assert.equal(config.subtitleStyle.autoPauseVideoOnHover, true); assert.equal(config.subtitleStyle.autoPauseVideoOnHover, true);
assert.equal(config.subtitleStyle.autoPauseVideoOnYomitanPopup, true); assert.equal(config.subtitleStyle.autoPauseVideoOnYomitanPopup, true);
assert.equal(config.subtitleSidebar.enabled, true); assert.equal(config.subtitleSidebar.enabled, true);
assert.equal(config.subtitleSidebar.pauseVideoOnHover, true);
assert.equal(config.subtitleStyle.hoverTokenColor, '#f4dbd6'); assert.equal(config.subtitleStyle.hoverTokenColor, '#f4dbd6');
assert.equal(config.subtitleStyle.hoverTokenBackgroundColor, 'transparent'); assert.equal(config.subtitleStyle.hoverTokenBackgroundColor, 'transparent');
assert.equal(config.subtitleStyle.fontFamily, DEFAULT_SUBTITLE_FONT_FAMILY); assert.equal(config.subtitleStyle.fontFamily, DEFAULT_SUBTITLE_FONT_FAMILY);
+1 -1
View File
@@ -69,7 +69,7 @@ export const SUBTITLE_DEFAULT_CONFIG: Pick<ResolvedConfig, 'subtitleStyle' | 'su
autoOpen: false, autoOpen: false,
layout: 'overlay', layout: 'overlay',
toggleKey: 'Backslash', toggleKey: 'Backslash',
pauseVideoOnHover: false, pauseVideoOnHover: true,
autoScroll: true, autoScroll: true,
css: {}, css: {},
maxWidth: 420, maxWidth: 420,
+8
View File
@@ -79,6 +79,14 @@ test('settings registry orders websocket server immediately after annotation web
assert.equal(integrationSections[annotationIndex + 1], 'WebSocket server'); assert.equal(integrationSections[annotationIndex + 1], 'WebSocket server');
}); });
test('settings registry explains websocket auto mode and keeps it disabled by default', () => {
assert.equal(field('websocket.enabled').defaultValue, false);
assert.equal(
field('websocket.enabled').description,
'Built-in subtitle WebSocket server mode. Auto starts the built-in server only when mpv_websocket is not detected; otherwise it defers to the plugin.',
);
});
test('settings registry places immersion tracking after other tracking and app sections', () => { test('settings registry places immersion tracking after other tracking and app sections', () => {
const trackingSections = [ const trackingSections = [
...new Set( ...new Set(
+2
View File
@@ -247,6 +247,8 @@ const DESCRIPTION_OVERRIDES: Record<string, string> = {
'CSS declarations applied to secondary subtitles. Includes color, background-color, and all font properties.', 'CSS declarations applied to secondary subtitles. Includes color, background-color, and all font properties.',
'subtitleSidebar.css': 'subtitleSidebar.css':
'CSS declarations applied to the subtitle sidebar. Includes color, background-color, all font properties, and sidebar CSS variables.', 'CSS declarations applied to the subtitle sidebar. Includes color, background-color, all font properties, and sidebar CSS variables.',
'websocket.enabled':
'Built-in subtitle WebSocket server mode. Auto starts the built-in server only when mpv_websocket is not detected; otherwise it defers to the plugin.',
'discordPresence.updateIntervalMs': 'discordPresence.updateIntervalMs':
'Minimum interval between presence payload updates, in milliseconds.', 'Minimum interval between presence payload updates, in milliseconds.',
}; };
+56
View File
@@ -224,6 +224,62 @@ test('startAppLifecycle queues second-instance commands until app ready runtime
assert.deepEqual(handled, ['ready', 'second-instance:start', 'second-instance:start']); assert.deepEqual(handled, ['ready', 'second-instance:start', 'second-instance:start']);
}); });
test('startAppLifecycle routes control socket commands through the second-instance queue', async () => {
const handled: string[] = [];
let controlArgvHandler: ((argv: string[]) => void) | null = null;
let readyHandler: (() => Promise<void>) | null = null;
let releaseReady: (() => void) | null = null;
const readyFinished = new Promise<void>((resolve) => {
releaseReady = resolve;
});
const { deps } = createDeps({
shouldStartApp: () => true,
parseArgs: (argv) => makeArgs({ start: argv.includes('--start') }),
handleCliCommand: (args, source) => {
handled.push(`${source}:${args.start ? 'start' : 'other'}`);
},
startControlServer: (handler) => {
controlArgvHandler = handler;
return () => {
handled.push('control-close');
};
},
whenReady: (handler) => {
readyHandler = handler;
},
onReady: async () => {
await readyFinished;
handled.push('ready');
},
});
let willQuitHandler: (() => void) | null = null;
deps.onWillQuit = (handler) => {
willQuitHandler = handler;
};
startAppLifecycle(makeArgs({ background: true }), deps);
assert.ok(controlArgvHandler);
(controlArgvHandler as (argv: string[]) => void)(['--start']);
assert.deepEqual(handled, []);
assert.ok(readyHandler);
const readyRun = (readyHandler as () => Promise<void>)();
await Promise.resolve();
assert.deepEqual(handled, []);
assert.ok(releaseReady);
(releaseReady as () => void)();
await readyRun;
assert.deepEqual(handled, ['ready', 'second-instance:start']);
assert.ok(willQuitHandler);
(willQuitHandler as () => void)();
assert.deepEqual(handled, ['ready', 'second-instance:start', 'control-close']);
});
test('startAppLifecycle quits macOS config-only launch when all windows close', () => { test('startAppLifecycle quits macOS config-only launch when all windows close', () => {
let windowAllClosedHandler: (() => void) | null = null; let windowAllClosedHandler: (() => void) | null = null;
const { deps, calls } = createDeps({ const { deps, calls } = createDeps({
+17 -1
View File
@@ -13,6 +13,7 @@ export interface AppLifecycleServiceDeps {
handleCliCommand: (args: CliArgs, source: CliCommandSource) => void; handleCliCommand: (args: CliArgs, source: CliCommandSource) => void;
printHelp: () => void; printHelp: () => void;
logNoRunningInstance: () => void; logNoRunningInstance: () => void;
startControlServer?: (handleArgv: (argv: string[]) => void) => (() => void) | void;
whenReady: (handler: () => Promise<void>) => void; whenReady: (handler: () => Promise<void>) => void;
onWindowAllClosed: (handler: () => void) => void; onWindowAllClosed: (handler: () => void) => void;
onWillQuit: (handler: () => void) => void; onWillQuit: (handler: () => void) => void;
@@ -41,6 +42,7 @@ export interface AppLifecycleDepsRuntimeOptions {
handleCliCommand: (args: CliArgs, source: CliCommandSource) => void; handleCliCommand: (args: CliArgs, source: CliCommandSource) => void;
printHelp: () => void; printHelp: () => void;
logNoRunningInstance: () => void; logNoRunningInstance: () => void;
startControlServer?: (handleArgv: (argv: string[]) => void) => (() => void) | void;
onReady: () => Promise<void>; onReady: () => Promise<void>;
onWillQuitCleanup: () => void; onWillQuitCleanup: () => void;
shouldRestoreWindowsOnActivate: () => boolean; shouldRestoreWindowsOnActivate: () => boolean;
@@ -70,6 +72,7 @@ export function createAppLifecycleDepsRuntime(
handleCliCommand: options.handleCliCommand, handleCliCommand: options.handleCliCommand,
printHelp: options.printHelp, printHelp: options.printHelp,
logNoRunningInstance: options.logNoRunningInstance, logNoRunningInstance: options.logNoRunningInstance,
startControlServer: options.startControlServer,
whenReady: (handler) => { whenReady: (handler) => {
options.app options.app
.whenReady() .whenReady()
@@ -116,6 +119,7 @@ export function startAppLifecycle(initialArgs: CliArgs, deps: AppLifecycleServic
let appReadyRuntimeComplete = false; let appReadyRuntimeComplete = false;
const pendingSecondInstanceCommands: CliArgs[] = []; const pendingSecondInstanceCommands: CliArgs[] = [];
let stopControlServer: (() => void) | null = null;
const handleSecondInstanceCommand = (args: CliArgs): void => { const handleSecondInstanceCommand = (args: CliArgs): void => {
try { try {
deps.handleCliCommand(args, 'second-instance'); deps.handleCliCommand(args, 'second-instance');
@@ -133,7 +137,7 @@ export function startAppLifecycle(initialArgs: CliArgs, deps: AppLifecycleServic
} }
}; };
deps.onSecondInstance((_event, argv) => { const dispatchSecondInstanceArgv = (argv: string[]): void => {
try { try {
const nextArgs = deps.parseArgs(argv); const nextArgs = deps.parseArgs(argv);
if (!appReadyRuntimeComplete) { if (!appReadyRuntimeComplete) {
@@ -145,6 +149,10 @@ export function startAppLifecycle(initialArgs: CliArgs, deps: AppLifecycleServic
} catch (error) { } catch (error) {
logger.error('Failed to handle second-instance CLI command:', error); logger.error('Failed to handle second-instance CLI command:', error);
} }
};
deps.onSecondInstance((_event, argv) => {
dispatchSecondInstanceArgv(argv);
}); });
if (!deps.shouldStartApp(initialArgs)) { if (!deps.shouldStartApp(initialArgs)) {
@@ -157,6 +165,12 @@ export function startAppLifecycle(initialArgs: CliArgs, deps: AppLifecycleServic
return; return;
} }
try {
stopControlServer = deps.startControlServer?.(dispatchSecondInstanceArgv) ?? null;
} catch (error) {
logger.error('Failed to start app control socket:', error);
}
deps.whenReady(async () => { deps.whenReady(async () => {
await deps.onReady(); await deps.onReady();
appReadyRuntimeComplete = true; appReadyRuntimeComplete = true;
@@ -173,6 +187,8 @@ export function startAppLifecycle(initialArgs: CliArgs, deps: AppLifecycleServic
}); });
deps.onWillQuit(() => { deps.onWillQuit(() => {
stopControlServer?.();
stopControlServer = null;
deps.onWillQuitCleanup(); deps.onWillQuitCleanup();
}); });
+131 -1
View File
@@ -1,7 +1,11 @@
import test from 'node:test'; import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { CliArgs } from '../../cli/args'; import { CliArgs } from '../../cli/args';
import { CliCommandServiceDeps, handleCliCommand } from './cli-command'; import {
CliCommandServiceDeps,
createCliCommandDepsRuntime,
handleCliCommand,
} from './cli-command';
function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs { function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
return { return {
@@ -501,6 +505,132 @@ test('handleCliCommand applies socket path and connects on start', () => {
assert.ok(calls.includes('connectMpvClient')); assert.ok(calls.includes('connectMpvClient'));
}); });
test('createCliCommandDepsRuntime reconnects MPV client when reconnect hook exists', () => {
const calls: string[] = [];
const client = {
setSocketPath: (socketPath: string) => {
calls.push(`setSocketPath:${socketPath}`);
},
connect: () => {
calls.push('connect');
},
reconnect: () => {
calls.push('reconnect');
},
};
const deps = createCliCommandDepsRuntime({
mpv: {
getSocketPath: () => '/tmp/runtime.sock',
setSocketPath: () => {},
getClient: () => client,
showOsd: () => {},
},
texthooker: {
service: { isRunning: () => false, start: () => {} },
getPort: () => 5174,
setPort: () => {},
getWebsocketUrl: () => undefined,
shouldOpenBrowser: () => false,
openInBrowser: () => {},
},
overlay: {
isInitialized: () => true,
initialize: () => {},
toggleVisible: () => {},
togglePrimarySubtitleBar: () => {},
setVisible: () => {},
},
mining: {
copyCurrentSubtitle: () => {},
startPendingMultiCopy: () => {},
mineSentenceCard: async () => {},
startPendingMineSentenceMultiple: () => {},
updateLastCardFromClipboard: async () => {},
refreshKnownWords: async () => {},
triggerFieldGrouping: async () => {},
triggerSubsyncFromConfig: async () => {},
markLastCardAsAudioCard: async () => {},
},
anilist: {
getStatus: () => ({
tokenStatus: 'not_checked',
tokenSource: 'none',
tokenMessage: null,
tokenResolvedAt: null,
tokenErrorAt: null,
queuePending: 0,
queueReady: 0,
queueDeadLetter: 0,
queueLastAttemptAt: null,
queueLastError: null,
}),
clearToken: () => {},
openSetup: () => {},
getQueueStatus: () => ({
pending: 0,
ready: 0,
deadLetter: 0,
lastAttemptAt: null,
lastError: null,
}),
retryQueueNow: async () => ({ ok: true, message: 'ok' }),
},
dictionary: {
generate: async () => ({
zipPath: '/tmp/test.zip',
fromCache: false,
mediaId: 1,
mediaTitle: 'Test',
entryCount: 0,
}),
getSelection: async () => ({
seriesKey: 'test',
guessTitle: null,
current: null,
override: null,
candidates: [],
}),
setSelection: async () => ({
ok: true,
seriesKey: 'test',
selected: { id: 1, title: 'Test', episodes: null },
staleMediaIds: [],
}),
},
jellyfin: {
openSetup: () => {},
runStatsCommand: async () => {},
runCommand: async () => {},
},
ui: {
openFirstRunSetup: () => {},
openYomitanSettings: () => {},
openConfigSettingsWindow: () => {},
cycleSecondarySubMode: () => {},
openRuntimeOptionsPalette: () => {},
printHelp: () => {},
},
app: {
stop: () => {},
hasMainWindow: () => true,
runUpdateCommand: async () => {},
runYoutubePlaybackFlow: async () => {},
},
dispatchSessionAction: async () => {},
getMultiCopyTimeoutMs: () => 2500,
schedule: () => undefined,
log: () => {},
logDebug: () => {},
warn: () => {},
error: () => {},
});
deps.setMpvClientSocketPath('/tmp/runtime.sock');
deps.connectMpvClient();
assert.deepEqual(calls, ['setSocketPath:/tmp/runtime.sock', 'reconnect']);
});
test('handleCliCommand warns when texthooker port override used while running', () => { test('handleCliCommand warns when texthooker port override used while running', () => {
const { deps, calls } = createDeps({ const { deps, calls } = createDeps({
isTexthookerRunning: () => true, isTexthookerRunning: () => true,
+5
View File
@@ -115,6 +115,7 @@ export interface CliCommandServiceDeps {
interface MpvClientLike { interface MpvClientLike {
setSocketPath: (socketPath: string) => void; setSocketPath: (socketPath: string) => void;
connect: () => void; connect: () => void;
reconnect?: () => void;
} }
interface TexthookerServiceLike { interface TexthookerServiceLike {
@@ -235,6 +236,10 @@ export function createCliCommandDepsRuntime(
connectMpvClient: () => { connectMpvClient: () => {
const client = options.mpv.getClient(); const client = options.mpv.getClient();
if (!client) return; if (!client) return;
if (client.reconnect) {
client.reconnect();
return;
}
client.connect(); client.connect();
}, },
isTexthookerRunning: () => options.texthooker.service.isRunning(), isTexthookerRunning: () => options.texthooker.service.isRunning(),
+51 -1
View File
@@ -32,6 +32,12 @@ class FakeSocket extends EventEmitter {
} }
} }
class ManualCloseSocket extends FakeSocket {
override destroy(): void {
this.destroyed = true;
}
}
const wait = () => new Promise((resolve) => setTimeout(resolve, 0)); const wait = () => new Promise((resolve) => setTimeout(resolve, 0));
test('getMpvReconnectDelay follows existing reconnect ramp', () => { test('getMpvReconnectDelay follows existing reconnect ramp', () => {
@@ -203,12 +209,15 @@ test('MpvSocketTransport ignores connect requests while already connecting or co
}); });
test('MpvSocketTransport.shutdown clears socket and lifecycle flags', async () => { test('MpvSocketTransport.shutdown clears socket and lifecycle flags', async () => {
const events: string[] = [];
const transport = new MpvSocketTransport({ const transport = new MpvSocketTransport({
socketPath: '/tmp/mpv.sock', socketPath: '/tmp/mpv.sock',
onConnect: () => {}, onConnect: () => {},
onData: () => {}, onData: () => {},
onError: () => {}, onError: () => {},
onClose: () => {}, onClose: () => {
events.push('close');
},
socketFactory: () => new FakeSocket() as unknown as net.Socket, socketFactory: () => new FakeSocket() as unknown as net.Socket,
}); });
@@ -220,4 +229,45 @@ test('MpvSocketTransport.shutdown clears socket and lifecycle flags', async () =
assert.equal(transport.isConnected, false); assert.equal(transport.isConnected, false);
assert.equal(transport.isConnecting, false); assert.equal(transport.isConnecting, false);
assert.equal(transport.getSocket(), null); assert.equal(transport.getSocket(), null);
assert.deepEqual(events, []);
});
test('MpvSocketTransport ignores stale socket events after shutdown and reconnect', async () => {
const events: string[] = [];
const sockets: ManualCloseSocket[] = [];
const transport = new MpvSocketTransport({
socketPath: '/tmp/mpv.sock',
onConnect: () => {
events.push('connect');
},
onData: () => {
events.push('data');
},
onError: () => {
events.push('error');
},
onClose: () => {
events.push('close');
},
socketFactory: () => {
const socket = new ManualCloseSocket();
sockets.push(socket);
return socket as unknown as net.Socket;
},
});
transport.connect();
await wait();
transport.shutdown();
transport.connect();
await wait();
const eventsBeforeStaleSocket = [...events];
sockets[0]!.emit('data', Buffer.from('{}'));
sockets[0]!.emit('error', new Error('stale'));
sockets[0]!.emit('close');
assert.deepEqual(events, eventsBeforeStaleSocket);
assert.equal(transport.isConnected, true);
assert.equal(transport.getSocket(), sockets[1]);
}); });
+16 -10
View File
@@ -105,32 +105,37 @@ export class MpvSocketTransport {
} }
this.connecting = true; this.connecting = true;
this.socketRef = this.socketFactory(); const socket = this.socketFactory();
this.socket = this.socketRef; this.socketRef = socket;
this.socket = socket;
this.socketRef.on('connect', () => { socket.on('connect', () => {
if (this.socketRef !== socket) return;
this.connected = true; this.connected = true;
this.connecting = false; this.connecting = false;
this.callbacks.onConnect(); this.callbacks.onConnect();
}); });
this.socketRef.on('data', (data: Buffer) => { socket.on('data', (data: Buffer) => {
if (this.socketRef !== socket) return;
this.callbacks.onData(data); this.callbacks.onData(data);
}); });
this.socketRef.on('error', (error: Error) => { socket.on('error', (error: Error) => {
if (this.socketRef !== socket) return;
this.connected = false; this.connected = false;
this.connecting = false; this.connecting = false;
this.callbacks.onError(error); this.callbacks.onError(error);
}); });
this.socketRef.on('close', () => { socket.on('close', () => {
if (this.socketRef !== socket) return;
this.connected = false; this.connected = false;
this.connecting = false; this.connecting = false;
this.callbacks.onClose(); this.callbacks.onClose();
}); });
this.socketRef.connect(this.socketPath); socket.connect(this.socketPath);
} }
send(payload: MpvSocketMessagePayload): boolean { send(payload: MpvSocketMessagePayload): boolean {
@@ -144,13 +149,14 @@ export class MpvSocketTransport {
} }
shutdown(): void { shutdown(): void {
if (this.socketRef) { const socket = this.socketRef;
this.socketRef.destroy();
}
this.socketRef = null; this.socketRef = null;
this.socket = null; this.socket = null;
this.connected = false; this.connected = false;
this.connecting = false; this.connecting = false;
if (socket) {
socket.destroy();
}
} }
getSocket(): net.Socket | null { getSocket(): net.Socket | null {
+26
View File
@@ -168,6 +168,32 @@ test('MpvIpcClient connect logs connect-request at debug level', () => {
assert.equal(requestLogs.length, 1); assert.equal(requestLogs.length, 1);
}); });
test('MpvIpcClient reconnect clears stale connected state and starts a fresh transport connect', () => {
const client = new MpvIpcClient('/tmp/mpv.sock', makeDeps());
const calls: string[] = [];
const resolved: unknown[] = [];
(client as any).connected = true;
(client as any).connecting = false;
(client as any).socket = {};
(client as any).pendingRequests.set(10, (message: unknown) => {
resolved.push(message);
});
(client as any).transport.shutdown = () => {
calls.push('shutdown');
};
(client as any).transport.connect = () => {
calls.push('connect');
};
client.reconnect();
assert.deepEqual(calls, ['shutdown', 'connect']);
assert.equal(client.connected, false);
assert.equal((client as any).connecting, true);
assert.equal((client as any).socket, null);
assert.deepEqual(resolved, [{ request_id: 10, error: 'disconnected' }]);
});
test('MpvIpcClient failPendingRequests resolves outstanding requests as disconnected', () => { test('MpvIpcClient failPendingRequests resolves outstanding requests as disconnected', () => {
const client = new MpvIpcClient('/tmp/mpv.sock', makeDeps()); const client = new MpvIpcClient('/tmp/mpv.sock', makeDeps());
const resolved: unknown[] = []; const resolved: unknown[] = [];
+11
View File
@@ -275,6 +275,17 @@ export class MpvIpcClient implements MpvClient {
this.transport.connect(); this.transport.connect();
} }
reconnect(): void {
logger.debug('MPV IPC reconnect requested.');
this.transport.shutdown();
this.connected = false;
this.connecting = false;
this.socket = null;
this.playbackPaused = null;
this.failPendingRequests();
this.connect();
}
private scheduleReconnect(): void { private scheduleReconnect(): void {
this.reconnectAttempt = scheduleMpvReconnect({ this.reconnectAttempt = scheduleMpvReconnect({
attempt: this.reconnectAttempt, attempt: this.reconnectAttempt,
+32
View File
@@ -217,6 +217,38 @@ test('serializeSubtitleWebsocketMessage emits structured token api payload', ()
}); });
}); });
test('serializeSubtitleWebsocketMessage can force plain subtitle payloads', () => {
const payload: SubtitleData = {
text: '無事',
tokens: [
{
surface: '無事',
reading: 'ぶじ',
headword: '無事',
startPos: 0,
endPos: 2,
partOfSpeech: PartOfSpeech.other,
isMerged: false,
isKnown: true,
isNPlusOneTarget: false,
jlptLevel: 'N2',
frequencyRank: 745,
},
],
};
const raw = serializeSubtitleWebsocketMessage(payload, frequencyOptions, {
payloadMode: 'plain',
});
assert.deepEqual(JSON.parse(raw), {
version: 1,
text: '無事',
sentence: '無事',
tokens: [],
});
});
test('serializeInitialSubtitleWebsocketMessage keeps annotated current subtitle content', () => { test('serializeInitialSubtitleWebsocketMessage keeps annotated current subtitle content', () => {
const payload: SubtitleData = { const payload: SubtitleData = {
text: 'ignored fallback', text: 'ignored fallback',
+24 -2
View File
@@ -18,6 +18,12 @@ export type SubtitleWebsocketFrequencyOptions = {
mode: 'single' | 'banded'; mode: 'single' | 'banded';
}; };
export type SubtitleWebsocketPayloadMode = 'plain' | 'annotated';
type SubtitleWebsocketMessageOptions = {
payloadMode?: SubtitleWebsocketPayloadMode;
};
type SerializedSubtitleToken = Pick< type SerializedSubtitleToken = Pick<
MergedToken, MergedToken,
| 'surface' | 'surface'
@@ -198,7 +204,17 @@ export function serializeSubtitleMarkup(
export function serializeSubtitleWebsocketMessage( export function serializeSubtitleWebsocketMessage(
payload: SubtitleData, payload: SubtitleData,
options: SubtitleWebsocketFrequencyOptions, options: SubtitleWebsocketFrequencyOptions,
messageOptions: SubtitleWebsocketMessageOptions = {},
): string { ): string {
if (messageOptions.payloadMode === 'plain') {
return JSON.stringify({
version: 1,
text: payload.text,
sentence: escapeHtml(payload.text).replaceAll('\n', '<br>'),
tokens: [],
});
}
return JSON.stringify({ return JSON.stringify({
version: 1, version: 1,
text: payload.text, text: payload.text,
@@ -210,18 +226,21 @@ export function serializeSubtitleWebsocketMessage(
export function serializeInitialSubtitleWebsocketMessage( export function serializeInitialSubtitleWebsocketMessage(
payload: SubtitleData | null, payload: SubtitleData | null,
options: SubtitleWebsocketFrequencyOptions, options: SubtitleWebsocketFrequencyOptions,
messageOptions: SubtitleWebsocketMessageOptions = {},
): string | null { ): string | null {
if (!payload || !payload.text.trim()) { if (!payload || !payload.text.trim()) {
return null; return null;
} }
return serializeSubtitleWebsocketMessage(payload, options); return serializeSubtitleWebsocketMessage(payload, options, messageOptions);
} }
export class SubtitleWebSocket { export class SubtitleWebSocket {
private server: WebSocket.Server | null = null; private server: WebSocket.Server | null = null;
private latestMessage = ''; private latestMessage = '';
public constructor(private readonly payloadMode: SubtitleWebsocketPayloadMode = 'annotated') {}
public isRunning(): boolean { public isRunning(): boolean {
return this.server !== null; return this.server !== null;
} }
@@ -247,6 +266,7 @@ export class SubtitleWebSocket {
const currentMessage = serializeInitialSubtitleWebsocketMessage( const currentMessage = serializeInitialSubtitleWebsocketMessage(
getCurrentSubtitleData(), getCurrentSubtitleData(),
getFrequencyOptions(), getFrequencyOptions(),
{ payloadMode: this.payloadMode },
); );
if (currentMessage) { if (currentMessage) {
ws.send(currentMessage); ws.send(currentMessage);
@@ -262,7 +282,9 @@ export class SubtitleWebSocket {
public broadcast(payload: SubtitleData, options: SubtitleWebsocketFrequencyOptions): void { public broadcast(payload: SubtitleData, options: SubtitleWebsocketFrequencyOptions): void {
if (!this.server) return; if (!this.server) return;
const message = serializeSubtitleWebsocketMessage(payload, options); const message = serializeSubtitleWebsocketMessage(payload, options, {
payloadMode: this.payloadMode,
});
this.latestMessage = message; this.latestMessage = message;
for (const client of this.server.clients) { for (const client of this.server.clients) {
if (client.readyState === WebSocket.OPEN) { if (client.readyState === WebSocket.OPEN) {
+21 -3
View File
@@ -34,6 +34,8 @@ import {
import { applyControllerConfigUpdate } from './main/controller-config-update.js'; import { applyControllerConfigUpdate } from './main/controller-config-update.js';
import { openPlaylistBrowser as openPlaylistBrowserRuntime } from './main/runtime/playlist-browser-open'; import { openPlaylistBrowser as openPlaylistBrowserRuntime } from './main/runtime/playlist-browser-open';
import { createDiscordRpcClient } from './main/runtime/discord-rpc-client.js'; import { createDiscordRpcClient } from './main/runtime/discord-rpc-client.js';
import { startAppControlServer } from './main/runtime/app-control-server';
import { getAppControlSocketPath } from './shared/app-control';
import { import {
type CancelLinuxMpvFullscreenOverlayRefreshBurst, type CancelLinuxMpvFullscreenOverlayRefreshBurst,
clearLinuxMpvFullscreenOverlayRefreshTimeouts, clearLinuxMpvFullscreenOverlayRefreshTimeouts,
@@ -166,6 +168,7 @@ import {
rememberAnilistAttemptedUpdateKey, rememberAnilistAttemptedUpdateKey,
} from './main/runtime/domains/anilist'; } from './main/runtime/domains/anilist';
import { DEFAULT_MIN_WATCH_RATIO } from './shared/watch-threshold'; import { DEFAULT_MIN_WATCH_RATIO } from './shared/watch-threshold';
import { shouldShowTexthookerTrayEntry } from './main/runtime/tray-main-actions';
import { import {
createApplyJellyfinMpvDefaultsHandler, createApplyJellyfinMpvDefaultsHandler,
createBuildApplyJellyfinMpvDefaultsMainDepsHandler, createBuildApplyJellyfinMpvDefaultsMainDepsHandler,
@@ -790,7 +793,7 @@ const bootServices = createMainBootServices({
warn: (message: string, details?: unknown) => console.warn(message, details), warn: (message: string, details?: unknown) => console.warn(message, details),
error: (message: string, details?: unknown) => console.error(message, details), error: (message: string, details?: unknown) => console.error(message, details),
}), }),
createSubtitleWebSocket: () => new SubtitleWebSocket(), createSubtitleWebSocket: (payloadMode) => new SubtitleWebSocket(payloadMode),
createLogger, createLogger,
createMainRuntimeRegistry, createMainRuntimeRegistry,
createOverlayManager, createOverlayManager,
@@ -3073,6 +3076,12 @@ const openFirstRunSetupWindowHandler = createOpenFirstRunSetupWindowHandler({
: 'Yomitan settings are unavailable while external read-only profile mode is enabled.'; : 'Yomitan settings are unavailable while external read-only profile mode is enabled.';
return; return;
} }
if (submission.action === 'open-config-settings') {
firstRunSetupMessage = openConfigSettingsWindow()
? 'Opened SubMiner settings.'
: 'SubMiner settings are unavailable.';
return { skipRender: true };
}
if (submission.action === 'refresh') { if (submission.action === 'refresh') {
const snapshot = await firstRunSetupService.refreshStatus('Status refreshed.'); const snapshot = await firstRunSetupService.refreshStatus('Status refreshed.');
firstRunSetupMessage = snapshot.message; firstRunSetupMessage = snapshot.message;
@@ -5796,6 +5805,16 @@ const { runAndApplyStartupState } = composeHeadlessStartupHandlers<
handleCliCommand(nextArgs, source), handleCliCommand(nextArgs, source),
printHelp: () => printHelp(DEFAULT_TEXTHOOKER_PORT), printHelp: () => printHelp(DEFAULT_TEXTHOOKER_PORT),
logNoRunningInstance: () => appLogger.logNoRunningInstance(), logNoRunningInstance: () => appLogger.logNoRunningInstance(),
startControlServer: (handleArgv: (argv: string[]) => void) => {
const server = startAppControlServer({
socketPath: getAppControlSocketPath({ configDir: CONFIG_DIR }),
platform: process.platform,
handleArgv,
logDebug: (message) => logger.debug(message),
logWarn: (message, error) => logger.warn(message, error),
});
return () => server.close();
},
onReady: runAppReadyRuntimeWithFatalReporting, onReady: runAppReadyRuntimeWithFatalReporting,
onWillQuitCleanup: () => onWillQuitCleanupHandler(), onWillQuitCleanup: () => onWillQuitCleanupHandler(),
shouldRestoreWindowsOnActivate: () => shouldRestoreWindowsOnActivateHandler(), shouldRestoreWindowsOnActivate: () => shouldRestoreWindowsOnActivateHandler(),
@@ -5943,12 +5962,11 @@ const { ensureTray: ensureTrayHandler, destroyTray: destroyTrayHandler } =
openSessionHelpModal: () => openSessionHelpOverlay(), openSessionHelpModal: () => openSessionHelpOverlay(),
openTexthookerInBrowser: () => openTexthookerInBrowser: () =>
handleCliCommand(parseArgs(['--texthooker', '--open-browser'])), handleCliCommand(parseArgs(['--texthooker', '--open-browser'])),
showTexthookerPage: () => getResolvedConfig().texthooker.launchAtStartup !== false, showTexthookerPage: () => shouldShowTexthookerTrayEntry(getResolvedConfig()),
showFirstRunSetup: () => !firstRunSetupService.isSetupCompleted(), showFirstRunSetup: () => !firstRunSetupService.isSetupCompleted(),
openFirstRunSetupWindow: () => openFirstRunSetupWindow(), openFirstRunSetupWindow: () => openFirstRunSetupWindow(),
showWindowsMpvLauncherSetup: () => process.platform === 'win32', showWindowsMpvLauncherSetup: () => process.platform === 'win32',
openYomitanSettings: () => openYomitanSettings(), openYomitanSettings: () => openYomitanSettings(),
openRuntimeOptionsPalette: () => openRuntimeOptionsPalette(),
openConfigSettingsWindow: () => openConfigSettingsWindow(), openConfigSettingsWindow: () => openConfigSettingsWindow(),
openJellyfinSetupWindow: () => openJellyfinSetupWindow(), openJellyfinSetupWindow: () => openJellyfinSetupWindow(),
isJellyfinConfigured: () => isJellyfinConfigured: () =>
+2
View File
@@ -11,6 +11,7 @@ export interface AppLifecycleRuntimeDepsFactoryInput {
handleCliCommand: (nextArgs: CliArgs, source: CliCommandSource) => void; handleCliCommand: (nextArgs: CliArgs, source: CliCommandSource) => void;
printHelp: () => void; printHelp: () => void;
logNoRunningInstance: () => void; logNoRunningInstance: () => void;
startControlServer?: (handleArgv: (argv: string[]) => void) => (() => void) | void;
onReady: () => Promise<void>; onReady: () => Promise<void>;
onWillQuitCleanup: () => void; onWillQuitCleanup: () => void;
shouldRestoreWindowsOnActivate: () => boolean; shouldRestoreWindowsOnActivate: () => boolean;
@@ -73,6 +74,7 @@ export function createAppLifecycleRuntimeDeps(
handleCliCommand: params.handleCliCommand, handleCliCommand: params.handleCliCommand,
printHelp: params.printHelp, printHelp: params.printHelp,
logNoRunningInstance: params.logNoRunningInstance, logNoRunningInstance: params.logNoRunningInstance,
startControlServer: params.startControlServer,
onReady: params.onReady, onReady: params.onReady,
onWillQuitCleanup: params.onWillQuitCleanup, onWillQuitCleanup: params.onWillQuitCleanup,
shouldRestoreWindowsOnActivate: params.shouldRestoreWindowsOnActivate, shouldRestoreWindowsOnActivate: params.shouldRestoreWindowsOnActivate,
+7 -2
View File
@@ -21,7 +21,7 @@ test('createMainBootServices builds boot-phase service bundle', () => {
{ targetPath: string }, { targetPath: string },
{ targetPath: string }, { targetPath: string },
{ targetPath: string }, { targetPath: string },
{ kind: string }, { kind: string; payloadMode: 'plain' | 'annotated' },
{ scope: string; warn: () => void; info: () => void; error: () => void }, { scope: string; warn: () => void; info: () => void; error: () => void },
{ registry: boolean }, { registry: boolean },
{ getMainWindow: () => null; getModalWindow: () => null }, { getMainWindow: () => null; getModalWindow: () => null },
@@ -76,7 +76,7 @@ test('createMainBootServices builds boot-phase service bundle', () => {
createAnilistTokenStore: (targetPath) => ({ targetPath }), createAnilistTokenStore: (targetPath) => ({ targetPath }),
createJellyfinTokenStore: (targetPath) => ({ targetPath }), createJellyfinTokenStore: (targetPath) => ({ targetPath }),
createAnilistUpdateQueue: (targetPath) => ({ targetPath }), createAnilistUpdateQueue: (targetPath) => ({ targetPath }),
createSubtitleWebSocket: () => ({ kind: 'ws' }), createSubtitleWebSocket: (payloadMode) => ({ kind: 'ws', payloadMode }),
createLogger: (scope) => createLogger: (scope) =>
({ ({
scope, scope,
@@ -115,6 +115,11 @@ test('createMainBootServices builds boot-phase service bundle', () => {
assert.deepEqual(services.anilistUpdateQueue, { assert.deepEqual(services.anilistUpdateQueue, {
targetPath: '/tmp/subminer-config/anilist-retry-queue.json', targetPath: '/tmp/subminer-config/anilist-retry-queue.json',
}); });
assert.deepEqual(services.subtitleWsService, { kind: 'ws', payloadMode: 'plain' });
assert.deepEqual(services.annotationSubtitleWsService, {
kind: 'ws',
payloadMode: 'annotated',
});
assert.deepEqual(services.appState, { assert.deepEqual(services.appState, {
mpvSocketPath: '/tmp/subminer.sock', mpvSocketPath: '/tmp/subminer.sock',
texthookerPort: 5174, texthookerPort: 5174,
+3 -3
View File
@@ -64,7 +64,7 @@ export interface MainBootServicesParams<
createAnilistTokenStore: (targetPath: string) => TAnilistTokenStore; createAnilistTokenStore: (targetPath: string) => TAnilistTokenStore;
createJellyfinTokenStore: (targetPath: string) => TJellyfinTokenStore; createJellyfinTokenStore: (targetPath: string) => TJellyfinTokenStore;
createAnilistUpdateQueue: (targetPath: string) => TAnilistUpdateQueue; createAnilistUpdateQueue: (targetPath: string) => TAnilistUpdateQueue;
createSubtitleWebSocket: () => TSubtitleWebSocket; createSubtitleWebSocket: (payloadMode: 'plain' | 'annotated') => TSubtitleWebSocket;
createLogger: (scope: string) => TLogger & { createLogger: (scope: string) => TLogger & {
warn: (message: string) => void; warn: (message: string) => void;
info: (message: string) => void; info: (message: string) => void;
@@ -205,8 +205,8 @@ export function createMainBootServices<
const anilistUpdateQueue = params.createAnilistUpdateQueue( const anilistUpdateQueue = params.createAnilistUpdateQueue(
params.joinPath(userDataPath, 'anilist-retry-queue.json'), params.joinPath(userDataPath, 'anilist-retry-queue.json'),
); );
const subtitleWsService = params.createSubtitleWebSocket(); const subtitleWsService = params.createSubtitleWebSocket('plain');
const annotationSubtitleWsService = params.createSubtitleWebSocket(); const annotationSubtitleWsService = params.createSubtitleWebSocket('annotated');
const logger = params.createLogger('main'); const logger = params.createLogger('main');
const runtimeRegistry = params.createMainRuntimeRegistry(); const runtimeRegistry = params.createMainRuntimeRegistry();
const overlayManager = params.createOverlayManager(); const overlayManager = params.createOverlayManager();
@@ -0,0 +1,43 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import test from 'node:test';
import { sendAppControlCommand } from '../../shared/app-control-client';
import { startAppControlServer } from './app-control-server';
async function waitForSocketPath(socketPath: string): Promise<void> {
const deadline = Date.now() + 1000;
while (Date.now() < deadline) {
if (fs.existsSync(socketPath)) return;
await new Promise<void>((resolve) => setTimeout(resolve, 10));
}
}
test('app control server dispatches argv requests and replies ok', async () => {
if (process.platform === 'win32') return;
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-control-test-'));
const socketPath = path.join(dir, 'control.sock');
const received: string[][] = [];
const server = startAppControlServer({
socketPath,
platform: 'linux',
handleArgv: (argv) => {
received.push(argv);
},
});
try {
await waitForSocketPath(socketPath);
const result = await sendAppControlCommand(['--start', '--socket', '/tmp/mpv.sock'], {
socketPath,
});
assert.deepEqual(result, { ok: true });
assert.deepEqual(received, [['--start', '--socket', '/tmp/mpv.sock']]);
} finally {
server.close();
fs.rmSync(dir, { recursive: true, force: true });
}
});
+96
View File
@@ -0,0 +1,96 @@
import fs from 'node:fs';
import net from 'node:net';
import path from 'node:path';
import {
encodeAppControlResponse,
parseAppControlRequestLine,
type AppControlResponse,
} from '../../shared/app-control';
export interface AppControlServerOptions {
socketPath: string;
platform?: NodeJS.Platform;
handleArgv: (argv: string[]) => void;
logDebug?: (message: string) => void;
logWarn?: (message: string, error?: unknown) => void;
}
export interface AppControlServerHandle {
close: () => void;
}
function prepareSocketPath(socketPath: string, platform: NodeJS.Platform): void {
if (platform === 'win32') return;
fs.mkdirSync(path.dirname(socketPath), { recursive: true });
fs.rmSync(socketPath, { force: true });
}
function cleanupSocketPath(socketPath: string, platform: NodeJS.Platform): void {
if (platform === 'win32') return;
try {
fs.rmSync(socketPath, { force: true });
} catch {
// ignore
}
}
function writeResponse(socket: net.Socket, response: AppControlResponse): void {
socket.end(encodeAppControlResponse(response));
}
export function startAppControlServer(options: AppControlServerOptions): AppControlServerHandle {
const platform = options.platform ?? process.platform;
prepareSocketPath(options.socketPath, platform);
const server = net.createServer((socket) => {
let buffer = '';
let handled = false;
socket.on('data', (chunk) => {
if (handled) return;
buffer += chunk.toString('utf8');
if (buffer.length > 65536) {
handled = true;
writeResponse(socket, { ok: false, error: 'App control request too large' });
return;
}
const newlineIndex = buffer.indexOf('\n');
if (newlineIndex < 0) return;
handled = true;
try {
const request = parseAppControlRequestLine(buffer.slice(0, newlineIndex));
options.handleArgv(request.argv);
writeResponse(socket, { ok: true });
} catch (error) {
options.logWarn?.('Failed to handle app control command.', error);
writeResponse(socket, {
ok: false,
error: error instanceof Error ? error.message : String(error),
});
}
});
});
server.on('error', (error) => {
options.logWarn?.(`App control socket failed: ${options.socketPath}`, error);
});
server.listen(options.socketPath, () => {
options.logDebug?.(`App control socket listening: ${options.socketPath}`);
});
let closed = false;
return {
close: () => {
if (closed) return;
closed = true;
try {
server.close();
} catch {
// ignore
}
cleanupSocketPath(options.socketPath, platform);
},
};
}
+101 -5
View File
@@ -59,10 +59,15 @@ test('buildFirstRunSetupHtml renders macchiato setup actions and disabled finish
assert.match(html, /SubMiner setup/); assert.match(html, /SubMiner setup/);
assert.doesNotMatch(html, /Install legacy mpv plugin/); assert.doesNotMatch(html, /Install legacy mpv plugin/);
assert.doesNotMatch(html, /action=install-plugin/); assert.doesNotMatch(html, /action=install-plugin/);
assert.match(html, /Ready/); assert.doesNotMatch(html, /mpv runtime plugin/);
assert.doesNotMatch(html, /Bundled ready/); assert.doesNotMatch(html, /Bundled ready/);
assert.match(html, /Managed mpv launches use the bundled runtime plugin\./); assert.doesNotMatch(html, /Managed mpv launches use the bundled runtime plugin\./);
assert.match(html, /Open Yomitan Settings/); assert.match(html, /Open Yomitan Settings/);
assert.match(html, /Open SubMiner Settings/);
assert.match(
html,
/action=open-yomitan-settings'">Open Yomitan Settings<\/button>\s*<button class="ghost" onclick="window\.location\.href='subminer:\/\/first-run-setup\?action=refresh'">Refresh status<\/button>\s*<button onclick="window\.location\.href='subminer:\/\/first-run-setup\?action=open-config-settings'">Open SubMiner Settings<\/button>\s*<button class="primary" disabled onclick="window\.location\.href='subminer:\/\/first-run-setup\?action=finish'">Finish setup<\/button>/,
);
assert.match(html, /Finish setup/); assert.match(html, /Finish setup/);
assert.match(html, /disabled/); assert.match(html, /disabled/);
assert.match(html, /html,\s*body\s*{\s*min-height:\s*100%;/); assert.match(html, /html,\s*body\s*{\s*min-height:\s*100%;/);
@@ -70,7 +75,7 @@ test('buildFirstRunSetupHtml renders macchiato setup actions and disabled finish
assert.match(html, /box-sizing:\s*border-box;/); assert.match(html, /box-sizing:\s*border-box;/);
}); });
test('buildFirstRunSetupHtml switches plugin action to reinstall when already installed', () => { test('buildFirstRunSetupHtml omits bundled mpv plugin readiness when already installed', () => {
const html = buildFirstRunSetupHtml({ const html = buildFirstRunSetupHtml({
configReady: true, configReady: true,
dictionaryCount: 1, dictionaryCount: 1,
@@ -94,10 +99,11 @@ test('buildFirstRunSetupHtml switches plugin action to reinstall when already in
assert.doesNotMatch(html, /Reinstall mpv plugin/); assert.doesNotMatch(html, /Reinstall mpv plugin/);
assert.doesNotMatch(html, /action=install-plugin/); assert.doesNotMatch(html, /action=install-plugin/);
assert.doesNotMatch(html, /mpv runtime plugin/);
assert.match(html, /mpv executable path/); assert.match(html, /mpv executable path/);
assert.match(html, /Leave blank to auto-discover mpv\.exe from PATH\./); assert.match(html, /Leave blank to auto-discover mpv\.exe from PATH\./);
assert.match(html, /aria-label="Path to mpv\.exe"/); assert.match(html, /aria-label="Path to mpv\.exe"/);
assert.match(html, /SubMiner-managed mpv launches use the bundled runtime plugin\./); assert.doesNotMatch(html, /SubMiner-managed mpv launches use the bundled runtime plugin\./);
}); });
test('buildFirstRunSetupHtml shows legacy mpv plugin removal action with confirmation', () => { test('buildFirstRunSetupHtml shows legacy mpv plugin removal action with confirmation', () => {
@@ -124,7 +130,8 @@ test('buildFirstRunSetupHtml shows legacy mpv plugin removal action with confirm
}); });
assert.match(html, /Legacy mpv plugin/); assert.match(html, /Legacy mpv plugin/);
assert.match(html, /Legacy detected/); assert.doesNotMatch(html, /mpv runtime plugin/);
assert.match(html, /Found/);
assert.match(html, /\/tmp\/mpv\/scripts\/subminer/); assert.match(html, /\/tmp\/mpv\/scripts\/subminer/);
assert.match(html, /\/tmp\/mpv\/scripts\/subminer\.lua/); assert.match(html, /\/tmp\/mpv\/scripts\/subminer\.lua/);
assert.match(html, /Remove legacy mpv plugin/); assert.match(html, /Remove legacy mpv plugin/);
@@ -251,6 +258,12 @@ test('parseFirstRunSetupSubmissionUrl parses supported custom actions', () => {
action: 'remove-legacy-plugin', action: 'remove-legacy-plugin',
}, },
); );
assert.deepEqual(
parseFirstRunSetupSubmissionUrl('subminer://first-run-setup?action=open-config-settings'),
{
action: 'open-config-settings',
},
);
assert.equal( assert.equal(
parseFirstRunSetupSubmissionUrl('subminer://first-run-setup?action=skip-plugin'), parseFirstRunSetupSubmissionUrl('subminer://first-run-setup?action=skip-plugin'),
null, null,
@@ -542,6 +555,89 @@ test('opening first-run setup skips rendering if window is destroyed after snaps
assert.deepEqual(calls, ['set', 'show', 'focus', 'in-progress', 'snapshot']); assert.deepEqual(calls, ['set', 'show', 'focus', 'in-progress', 'snapshot']);
}); });
test('first-run setup action can skip rerender after launching another window', async () => {
const calls: string[] = [];
let navigateHandler: ((event: unknown, url: string) => void) | undefined;
const handler = createOpenFirstRunSetupWindowHandler({
maybeFocusExistingSetupWindow: () => false,
createSetupWindow: () =>
({
webContents: {
on: (_event: 'will-navigate', callback: (event: unknown, url: string) => void) => {
navigateHandler = callback;
},
},
loadURL: async () => {
calls.push('load');
},
on: () => {},
isDestroyed: () => false,
close: () => {},
show: () => calls.push('show'),
focus: () => calls.push('focus'),
}) as never,
getSetupSnapshot: async () => ({
configReady: true,
dictionaryCount: 1,
canFinish: true,
externalYomitanConfigured: false,
pluginStatus: 'installed',
pluginInstallPathSummary: null,
mpvExecutablePath: '',
mpvExecutablePathStatus: 'blank',
windowsMpvShortcuts: {
supported: false,
startMenuEnabled: true,
desktopEnabled: true,
startMenuInstalled: false,
desktopInstalled: false,
status: 'optional',
},
commandLineLauncher: createCommandLineLauncherSnapshot(),
message: null,
}),
buildSetupHtml: () => '<html></html>',
parseSubmissionUrl: (url) => parseFirstRunSetupSubmissionUrl(url),
handleAction: async () => {
calls.push('action');
return { skipRender: true };
},
markSetupInProgress: async () => {
calls.push('in-progress');
},
markSetupCancelled: async () => undefined,
isSetupCompleted: () => true,
shouldQuitWhenClosedIncomplete: () => false,
quitApp: () => {},
clearSetupWindow: () => {},
setSetupWindow: () => {
calls.push('set');
},
encodeURIComponent: (value) => value,
logError: () => {},
});
handler();
await new Promise((resolve) => setTimeout(resolve, 0));
navigateHandler?.(
{ preventDefault: () => calls.push('preventDefault') },
'subminer://first-run-setup?action=open-config-settings',
);
await new Promise((resolve) => setTimeout(resolve, 0));
assert.deepEqual(calls, [
'set',
'show',
'focus',
'in-progress',
'load',
'show',
'focus',
'preventDefault',
'action',
]);
});
test('closing incomplete first-run setup quits app outside background mode', async () => { test('closing incomplete first-run setup quits app outside background mode', async () => {
const calls: string[] = []; const calls: string[] = [];
let closedHandler: (() => void) | undefined; let closedHandler: (() => void) | undefined;
+10 -18
View File
@@ -29,6 +29,7 @@ export type FirstRunSetupAction =
| 'install-bun' | 'install-bun'
| 'install-command-line-launcher' | 'install-command-line-launcher'
| 'open-yomitan-settings' | 'open-yomitan-settings'
| 'open-config-settings'
| 'refresh' | 'refresh'
| 'finish'; | 'finish';
@@ -200,14 +201,6 @@ export function buildFirstRunSetupHtml(model: FirstRunSetupHtmlModel): string {
legacyMpvPluginPaths.length > 0 && model.canFinish legacyMpvPluginPaths.length > 0 && model.canFinish
? 'Continue without removing' ? 'Continue without removing'
: 'Finish setup'; : 'Finish setup';
const pluginLabel =
legacyMpvPluginPaths.length > 0
? 'Legacy detected'
: model.pluginStatus === 'failed'
? 'Failed'
: 'Ready';
const pluginTone =
legacyMpvPluginPaths.length > 0 ? 'warn' : model.pluginStatus === 'failed' ? 'danger' : 'ready';
const windowsShortcutLabel = const windowsShortcutLabel =
model.windowsMpvShortcuts.status === 'installed' model.windowsMpvShortcuts.status === 'installed'
? 'Installed' ? 'Installed'
@@ -326,7 +319,7 @@ export function buildFirstRunSetupHtml(model: FirstRunSetupHtmlModel): string {
: model.canFinish : model.canFinish
? model.externalYomitanConfigured ? model.externalYomitanConfigured
? 'Finish stays unlocked while SubMiner is reusing an external Yomitan profile. If you later launch without yomitan.externalProfilePath, setup will require at least one internal dictionary.' ? 'Finish stays unlocked while SubMiner is reusing an external Yomitan profile. If you later launch without yomitan.externalProfilePath, setup will require at least one internal dictionary.'
: 'Finish stays unlocked once Yomitan reports at least one installed dictionary. SubMiner-managed mpv launches use the bundled runtime plugin.' : 'Finish stays unlocked once Yomitan reports at least one installed dictionary.'
: 'Finish stays locked until Yomitan reports at least one installed dictionary.'; : 'Finish stays locked until Yomitan reports at least one installed dictionary.';
return `<!doctype html> return `<!doctype html>
@@ -522,14 +515,6 @@ export function buildFirstRunSetupHtml(model: FirstRunSetupHtmlModel): string {
</div> </div>
${renderStatusBadge(model.configReady ? 'Ready' : 'Missing', model.configReady ? 'ready' : 'danger')} ${renderStatusBadge(model.configReady ? 'Ready' : 'Missing', model.configReady ? 'ready' : 'danger')}
</div> </div>
<div class="card">
<div>
<strong>mpv runtime plugin</strong>
<div class="meta">${escapeHtml(model.pluginInstallPathSummary ?? 'Default mpv scripts location')}</div>
<div class="meta">Managed mpv launches use the bundled runtime plugin.</div>
</div>
${renderStatusBadge(pluginLabel, pluginTone)}
</div>
<div class="card"> <div class="card">
<div> <div>
<strong>Yomitan dictionaries</strong> <strong>Yomitan dictionaries</strong>
@@ -544,6 +529,7 @@ export function buildFirstRunSetupHtml(model: FirstRunSetupHtmlModel): string {
<div class="actions"> <div class="actions">
<button onclick="window.location.href='subminer://first-run-setup?action=open-yomitan-settings'">Open Yomitan Settings</button> <button onclick="window.location.href='subminer://first-run-setup?action=open-yomitan-settings'">Open Yomitan Settings</button>
<button class="ghost" onclick="window.location.href='subminer://first-run-setup?action=refresh'">Refresh status</button> <button class="ghost" onclick="window.location.href='subminer://first-run-setup?action=refresh'">Refresh status</button>
<button onclick="window.location.href='subminer://first-run-setup?action=open-config-settings'">Open SubMiner Settings</button>
<button class="primary" ${model.canFinish ? '' : 'disabled'} onclick="window.location.href='subminer://first-run-setup?action=finish'">${finishButtonLabel}</button> <button class="primary" ${model.canFinish ? '' : 'disabled'} onclick="window.location.href='subminer://first-run-setup?action=finish'">${finishButtonLabel}</button>
</div> </div>
<div class="message">${model.message ? escapeHtml(model.message) : ''}</div> <div class="message">${model.message ? escapeHtml(model.message) : ''}</div>
@@ -566,6 +552,7 @@ export function parseFirstRunSetupSubmissionUrl(rawUrl: string): FirstRunSetupSu
action !== 'install-bun' && action !== 'install-bun' &&
action !== 'install-command-line-launcher' && action !== 'install-command-line-launcher' &&
action !== 'open-yomitan-settings' && action !== 'open-yomitan-settings' &&
action !== 'open-config-settings' &&
action !== 'refresh' && action !== 'refresh' &&
action !== 'finish' action !== 'finish'
) { ) {
@@ -632,7 +619,9 @@ export function createOpenFirstRunSetupWindowHandler<
getSetupSnapshot: () => Promise<FirstRunSetupHtmlModel>; getSetupSnapshot: () => Promise<FirstRunSetupHtmlModel>;
buildSetupHtml: (model: FirstRunSetupHtmlModel) => string; buildSetupHtml: (model: FirstRunSetupHtmlModel) => string;
parseSubmissionUrl: (rawUrl: string) => FirstRunSetupSubmission | null; parseSubmissionUrl: (rawUrl: string) => FirstRunSetupSubmission | null;
handleAction: (submission: FirstRunSetupSubmission) => Promise<{ closeWindow?: boolean } | void>; handleAction: (
submission: FirstRunSetupSubmission,
) => Promise<{ closeWindow?: boolean; skipRender?: boolean } | void>;
markSetupInProgress: () => Promise<unknown>; markSetupInProgress: () => Promise<unknown>;
markSetupCancelled: () => Promise<unknown>; markSetupCancelled: () => Promise<unknown>;
isSetupCompleted: () => boolean; isSetupCompleted: () => boolean;
@@ -680,6 +669,9 @@ export function createOpenFirstRunSetupWindowHandler<
} }
return; return;
} }
if (result?.skipRender) {
return;
}
if (!setupWindow.isDestroyed()) { if (!setupWindow.isDestroyed()) {
await render(); await render();
} }
@@ -144,3 +144,34 @@ test('managed local subtitle selection runtime promotes a single unlabeled exter
['set_property', 'secondary-sid', 1], ['set_property', 'secondary-sid', 1],
]); ]);
}); });
test('managed local subtitle selection keeps waiting for primary after early secondary-only track list', () => {
const commands: Array<Array<string | number>> = [];
const runtime = createManagedLocalSubtitleSelectionRuntime({
getCurrentMediaPath: () => '/videos/example.mkv',
getMpvClient: () => null,
getPrimarySubtitleLanguages: () => [],
getSecondarySubtitleLanguages: () => [],
sendMpvCommand: (command) => {
commands.push(command);
},
schedule: () => 1 as never,
clearScheduled: () => {},
});
runtime.handleMediaPathChange('/videos/example.mkv');
runtime.handleSubtitleTrackListChange([
{ type: 'sub', id: 1, lang: 'eng', title: 'ASS', external: false },
{ type: 'sub', id: 2, lang: 'en', title: 'en.srt', external: true },
]);
runtime.handleSubtitleTrackListChange([
{ type: 'sub', id: 1, lang: 'eng', title: 'ASS', external: false },
{ type: 'sub', id: 2, lang: 'en', title: 'en.srt', external: true },
{ type: 'sub', id: 3, lang: 'ja', title: 'ja.srt', external: true },
]);
assert.deepEqual(commands, [
['set_property', 'secondary-sid', 2],
['set_property', 'sid', 3],
]);
});
+17 -8
View File
@@ -200,7 +200,8 @@ export function createManagedLocalSubtitleSelectionRuntime(deps: {
}) { }) {
const delayMs = deps.delayMs ?? 400; const delayMs = deps.delayMs ?? 400;
let currentMediaPath: string | null = null; let currentMediaPath: string | null = null;
let appliedMediaPath: string | null = null; let appliedPrimaryMediaPath: string | null = null;
let appliedSecondaryMediaPath: string | null = null;
let pendingTimer: ReturnType<typeof setTimeout> | null = null; let pendingTimer: ReturnType<typeof setTimeout> | null = null;
const clearPendingTimer = (): void => { const clearPendingTimer = (): void => {
@@ -212,7 +213,11 @@ export function createManagedLocalSubtitleSelectionRuntime(deps: {
}; };
const maybeApplySelection = (trackList: unknown[] | null): void => { const maybeApplySelection = (trackList: unknown[] | null): void => {
if (!currentMediaPath || appliedMediaPath === currentMediaPath) { if (
!currentMediaPath ||
(appliedPrimaryMediaPath === currentMediaPath &&
appliedSecondaryMediaPath === currentMediaPath)
) {
return; return;
} }
const selection = resolveManagedLocalSubtitleSelection({ const selection = resolveManagedLocalSubtitleSelection({
@@ -223,14 +228,17 @@ export function createManagedLocalSubtitleSelectionRuntime(deps: {
if (!selection.hasPrimaryMatch && !selection.hasSecondaryMatch) { if (!selection.hasPrimaryMatch && !selection.hasSecondaryMatch) {
return; return;
} }
if (selection.primaryTrackId !== null) { if (selection.primaryTrackId !== null && appliedPrimaryMediaPath !== currentMediaPath) {
deps.sendMpvCommand(['set_property', 'sid', selection.primaryTrackId]); deps.sendMpvCommand(['set_property', 'sid', selection.primaryTrackId]);
appliedPrimaryMediaPath = currentMediaPath;
} }
if (selection.secondaryTrackId !== null) { if (selection.secondaryTrackId !== null && appliedSecondaryMediaPath !== currentMediaPath) {
deps.sendMpvCommand(['set_property', 'secondary-sid', selection.secondaryTrackId]); deps.sendMpvCommand(['set_property', 'secondary-sid', selection.secondaryTrackId]);
appliedSecondaryMediaPath = currentMediaPath;
}
if (appliedPrimaryMediaPath === currentMediaPath) {
clearPendingTimer();
} }
appliedMediaPath = currentMediaPath;
clearPendingTimer();
}; };
const refreshFromMpv = async (): Promise<void> => { const refreshFromMpv = async (): Promise<void> => {
@@ -252,7 +260,7 @@ export function createManagedLocalSubtitleSelectionRuntime(deps: {
const scheduleRefresh = (): void => { const scheduleRefresh = (): void => {
clearPendingTimer(); clearPendingTimer();
if (!currentMediaPath || appliedMediaPath === currentMediaPath) { if (!currentMediaPath || appliedPrimaryMediaPath === currentMediaPath) {
return; return;
} }
pendingTimer = deps.schedule(() => { pendingTimer = deps.schedule(() => {
@@ -265,7 +273,8 @@ export function createManagedLocalSubtitleSelectionRuntime(deps: {
handleMediaPathChange: (mediaPath: string | null | undefined): void => { handleMediaPathChange: (mediaPath: string | null | undefined): void => {
const normalizedPath = normalizeLocalMediaPath(mediaPath); const normalizedPath = normalizeLocalMediaPath(mediaPath);
if (normalizedPath !== currentMediaPath) { if (normalizedPath !== currentMediaPath) {
appliedMediaPath = null; appliedPrimaryMediaPath = null;
appliedSecondaryMediaPath = null;
} }
currentMediaPath = normalizedPath; currentMediaPath = normalizedPath;
if (!currentMediaPath) { if (!currentMediaPath) {
@@ -161,3 +161,67 @@ test('main mpv event binder runs mpv-connected callback on connection', () => {
assert.ok(calls.includes('mpv-connected')); assert.ok(calls.includes('mpv-connected'));
}); });
test('main mpv event binder clears media path on disconnect', () => {
const handlers = new Map<string, (payload: unknown) => void>();
const calls: string[] = [];
const bind = createBindMpvMainEventHandlersHandler({
reportJellyfinRemoteStopped: () => calls.push('remote-stopped'),
syncOverlayMpvSubtitleSuppression: () => calls.push('sync-overlay-mpv-sub'),
resetSubtitleSidebarEmbeddedLayout: () => calls.push('reset-sidebar-layout'),
hasInitialPlaybackQuitOnDisconnectArg: () => false,
isOverlayRuntimeInitialized: () => true,
shouldQuitOnDisconnectWhenOverlayRuntimeInitialized: () => false,
isQuitOnDisconnectArmed: () => false,
scheduleQuitCheck: () => {},
isMpvConnected: () => false,
quitApp: () => {},
recordImmersionSubtitleLine: () => {},
hasSubtitleTimingTracker: () => false,
recordSubtitleTiming: () => {},
maybeRunAnilistPostWatchUpdate: async () => {},
logSubtitleTimingError: () => {},
setCurrentSubText: () => {},
broadcastSubtitle: () => {},
onSubtitleChange: () => {},
refreshDiscordPresence: () => calls.push('presence-refresh'),
setCurrentSubAssText: () => {},
broadcastSubtitleAss: () => {},
broadcastSecondarySubtitle: () => {},
updateCurrentMediaPath: (path) => calls.push(`media-path:${path}`),
restoreMpvSubVisibility: () => {},
getCurrentAnilistMediaKey: () => null,
resetAnilistMediaTracking: () => {},
maybeProbeAnilistDuration: () => {},
ensureAnilistMediaGuess: () => {},
syncImmersionMediaState: () => {},
updateCurrentMediaTitle: () => {},
resetAnilistMediaGuessState: () => {},
notifyImmersionTitleUpdate: () => {},
recordPlaybackPosition: () => {},
recordMediaDuration: () => {},
reportJellyfinRemoteProgress: () => {},
recordPauseState: () => {},
updateSubtitleRenderMetrics: () => {},
setPreviousSecondarySubVisibility: () => {},
});
bind({
on: (event, handler) => {
handlers.set(event, handler as (payload: unknown) => void);
},
});
handlers.get('connection-change')?.({ connected: false });
assert.ok(calls.includes('media-path:'));
assert.ok(calls.includes('remote-stopped'));
assert.ok(calls.includes('presence-refresh'));
});
@@ -101,6 +101,8 @@ export function createBindMpvMainEventHandlersHandler(deps: {
}): void => { }): void => {
if (connected) { if (connected) {
deps.resetSubtitleSidebarEmbeddedLayout(); deps.resetSubtitleSidebarEmbeddedLayout();
} else {
deps.updateCurrentMediaPath('');
} }
handleMpvConnectionChange({ connected }); handleMpvConnectionChange({ connected });
}; };
@@ -11,6 +11,7 @@ export function createBuildAppLifecycleRuntimeRunnerMainDepsHandler(
handleCliCommand: deps.handleCliCommand, handleCliCommand: deps.handleCliCommand,
printHelp: deps.printHelp, printHelp: deps.printHelp,
logNoRunningInstance: deps.logNoRunningInstance, logNoRunningInstance: deps.logNoRunningInstance,
startControlServer: deps.startControlServer,
onReady: deps.onReady, onReady: deps.onReady,
onWillQuitCleanup: deps.onWillQuitCleanup, onWillQuitCleanup: deps.onWillQuitCleanup,
shouldRestoreWindowsOnActivate: deps.shouldRestoreWindowsOnActivate, shouldRestoreWindowsOnActivate: deps.shouldRestoreWindowsOnActivate,
+13 -2
View File
@@ -44,14 +44,14 @@ test('window-all-closed keeps background app alive without tray', () => {
); );
}); });
test('mpv shutdown keeps managed background tray app alive', () => { test('mpv shutdown quits managed background playback despite tray residency', () => {
assert.equal( assert.equal(
shouldQuitOnMpvShutdownForTrayState({ shouldQuitOnMpvShutdownForTrayState({
managedPlayback: true, managedPlayback: true,
backgroundMode: true, backgroundMode: true,
hasTray: true, hasTray: true,
}), }),
false, true,
); );
}); });
@@ -65,3 +65,14 @@ test('mpv shutdown quits standalone managed playback without tray residency', ()
true, true,
); );
}); });
test('mpv shutdown keeps unmanaged background tray app alive', () => {
assert.equal(
shouldQuitOnMpvShutdownForTrayState({
managedPlayback: false,
backgroundMode: true,
hasTray: true,
}),
false,
);
});
+2 -4
View File
@@ -27,8 +27,6 @@ export function shouldQuitOnMpvShutdownForTrayState(options: {
backgroundMode: boolean; backgroundMode: boolean;
hasTray: boolean; hasTray: boolean;
}): boolean { }): boolean {
if (!options.managedPlayback) return false; // managedPlayback marks process ownership; tray/background only affect window-close policy.
if (options.backgroundMode) return false; return options.managedPlayback;
if (options.hasTray) return false;
return true;
} }
+32 -3
View File
@@ -3,6 +3,7 @@ import assert from 'node:assert/strict';
import { import {
createBuildTrayMenuTemplateHandler, createBuildTrayMenuTemplateHandler,
createResolveTrayIconPathHandler, createResolveTrayIconPathHandler,
shouldShowTexthookerTrayEntry,
} from './tray-main-actions'; } from './tray-main-actions';
test('resolve tray icon path handler forwards runtime dependencies', () => { test('resolve tray icon path handler forwards runtime dependencies', () => {
@@ -47,7 +48,6 @@ test('build tray template handler wires actions and init guards', () => {
handlers.openFirstRunSetup(); handlers.openFirstRunSetup();
handlers.openWindowsMpvLauncherSetup(); handlers.openWindowsMpvLauncherSetup();
handlers.openYomitanSettings(); handlers.openYomitanSettings();
handlers.openRuntimeOptions();
handlers.openConfigSettings(); handlers.openConfigSettings();
handlers.openJellyfinSetup(); handlers.openJellyfinSetup();
handlers.toggleJellyfinDiscovery(); handlers.toggleJellyfinDiscovery();
@@ -68,7 +68,6 @@ test('build tray template handler wires actions and init guards', () => {
openFirstRunSetupWindow: () => calls.push('setup'), openFirstRunSetupWindow: () => calls.push('setup'),
showWindowsMpvLauncherSetup: () => true, showWindowsMpvLauncherSetup: () => true,
openYomitanSettings: () => calls.push('yomitan'), openYomitanSettings: () => calls.push('yomitan'),
openRuntimeOptionsPalette: () => calls.push('runtime-options'),
openConfigSettingsWindow: () => calls.push('configuration'), openConfigSettingsWindow: () => calls.push('configuration'),
openJellyfinSetupWindow: () => calls.push('jellyfin'), openJellyfinSetupWindow: () => calls.push('jellyfin'),
isJellyfinConfigured: () => true, isJellyfinConfigured: () => true,
@@ -91,7 +90,6 @@ test('build tray template handler wires actions and init guards', () => {
'setup', 'setup',
'setup', 'setup',
'yomitan', 'yomitan',
'runtime-options',
'configuration', 'configuration',
'jellyfin', 'jellyfin',
'jellyfin-discovery', 'jellyfin-discovery',
@@ -100,3 +98,34 @@ test('build tray template handler wires actions and init guards', () => {
'quit', 'quit',
]); ]);
}); });
test('texthooker tray visibility follows websocket server enabled state', () => {
assert.equal(
shouldShowTexthookerTrayEntry({
websocket: { enabled: false },
annotationWebsocket: { enabled: false },
}),
false,
);
assert.equal(
shouldShowTexthookerTrayEntry({
websocket: { enabled: true },
annotationWebsocket: { enabled: false },
}),
true,
);
assert.equal(
shouldShowTexthookerTrayEntry({
websocket: { enabled: 'auto' },
annotationWebsocket: { enabled: false },
}),
true,
);
assert.equal(
shouldShowTexthookerTrayEntry({
websocket: { enabled: false },
annotationWebsocket: { enabled: true },
}),
true,
);
});
+9 -8
View File
@@ -26,6 +26,15 @@ export function createResolveTrayIconPathHandler(deps: {
}; };
} }
export function shouldShowTexthookerTrayEntry(config: {
websocket?: { enabled?: boolean | 'auto' };
annotationWebsocket?: { enabled?: boolean };
}): boolean {
const websocketEnabled = config.websocket?.enabled ?? false;
const annotationWebsocketEnabled = config.annotationWebsocket?.enabled ?? false;
return websocketEnabled !== false || annotationWebsocketEnabled !== false;
}
export function createBuildTrayMenuTemplateHandler<TMenuItem>(deps: { export function createBuildTrayMenuTemplateHandler<TMenuItem>(deps: {
buildTrayMenuTemplateRuntime: (handlers: { buildTrayMenuTemplateRuntime: (handlers: {
openSessionHelp: () => void; openSessionHelp: () => void;
@@ -36,7 +45,6 @@ export function createBuildTrayMenuTemplateHandler<TMenuItem>(deps: {
openWindowsMpvLauncherSetup: () => void; openWindowsMpvLauncherSetup: () => void;
showWindowsMpvLauncherSetup: boolean; showWindowsMpvLauncherSetup: boolean;
openYomitanSettings: () => void; openYomitanSettings: () => void;
openRuntimeOptions: () => void;
openConfigSettings: () => void; openConfigSettings: () => void;
openJellyfinSetup: () => void; openJellyfinSetup: () => void;
showJellyfinDiscovery: boolean; showJellyfinDiscovery: boolean;
@@ -55,7 +63,6 @@ export function createBuildTrayMenuTemplateHandler<TMenuItem>(deps: {
openFirstRunSetupWindow: () => void; openFirstRunSetupWindow: () => void;
showWindowsMpvLauncherSetup: () => boolean; showWindowsMpvLauncherSetup: () => boolean;
openYomitanSettings: () => void; openYomitanSettings: () => void;
openRuntimeOptionsPalette: () => void;
openConfigSettingsWindow: () => void; openConfigSettingsWindow: () => void;
openJellyfinSetupWindow: () => void; openJellyfinSetupWindow: () => void;
isJellyfinConfigured: () => boolean; isJellyfinConfigured: () => boolean;
@@ -88,12 +95,6 @@ export function createBuildTrayMenuTemplateHandler<TMenuItem>(deps: {
openYomitanSettings: () => { openYomitanSettings: () => {
deps.openYomitanSettings(); deps.openYomitanSettings();
}, },
openRuntimeOptions: () => {
if (!deps.isOverlayRuntimeInitialized()) {
deps.initializeOverlayRuntime();
}
deps.openRuntimeOptionsPalette();
},
openConfigSettings: () => { openConfigSettings: () => {
deps.openConfigSettingsWindow(); deps.openConfigSettingsWindow();
}, },
-2
View File
@@ -31,7 +31,6 @@ test('tray main deps builders return mapped handlers', () => {
openFirstRunSetupWindow: () => calls.push('setup'), openFirstRunSetupWindow: () => calls.push('setup'),
showWindowsMpvLauncherSetup: () => true, showWindowsMpvLauncherSetup: () => true,
openYomitanSettings: () => calls.push('yomitan'), openYomitanSettings: () => calls.push('yomitan'),
openRuntimeOptionsPalette: () => calls.push('runtime-options'),
openConfigSettingsWindow: () => calls.push('configuration'), openConfigSettingsWindow: () => calls.push('configuration'),
openJellyfinSetupWindow: () => calls.push('jellyfin'), openJellyfinSetupWindow: () => calls.push('jellyfin'),
isJellyfinConfigured: () => true, isJellyfinConfigured: () => true,
@@ -53,7 +52,6 @@ test('tray main deps builders return mapped handlers', () => {
openWindowsMpvLauncherSetup: () => calls.push('open-windows-mpv'), openWindowsMpvLauncherSetup: () => calls.push('open-windows-mpv'),
showWindowsMpvLauncherSetup: true, showWindowsMpvLauncherSetup: true,
openYomitanSettings: () => calls.push('open-yomitan'), openYomitanSettings: () => calls.push('open-yomitan'),
openRuntimeOptions: () => calls.push('open-runtime-options'),
openConfigSettings: () => calls.push('open-configuration'), openConfigSettings: () => calls.push('open-configuration'),
openJellyfinSetup: () => calls.push('open-jellyfin'), openJellyfinSetup: () => calls.push('open-jellyfin'),
showJellyfinDiscovery: true, showJellyfinDiscovery: true,
-3
View File
@@ -35,7 +35,6 @@ export function createBuildTrayMenuTemplateMainDepsHandler<TMenuItem>(deps: {
openWindowsMpvLauncherSetup: () => void; openWindowsMpvLauncherSetup: () => void;
showWindowsMpvLauncherSetup: boolean; showWindowsMpvLauncherSetup: boolean;
openYomitanSettings: () => void; openYomitanSettings: () => void;
openRuntimeOptions: () => void;
openConfigSettings: () => void; openConfigSettings: () => void;
openJellyfinSetup: () => void; openJellyfinSetup: () => void;
showJellyfinDiscovery: boolean; showJellyfinDiscovery: boolean;
@@ -54,7 +53,6 @@ export function createBuildTrayMenuTemplateMainDepsHandler<TMenuItem>(deps: {
openFirstRunSetupWindow: () => void; openFirstRunSetupWindow: () => void;
showWindowsMpvLauncherSetup: () => boolean; showWindowsMpvLauncherSetup: () => boolean;
openYomitanSettings: () => void; openYomitanSettings: () => void;
openRuntimeOptionsPalette: () => void;
openConfigSettingsWindow: () => void; openConfigSettingsWindow: () => void;
openJellyfinSetupWindow: () => void; openJellyfinSetupWindow: () => void;
isJellyfinConfigured: () => boolean; isJellyfinConfigured: () => boolean;
@@ -75,7 +73,6 @@ export function createBuildTrayMenuTemplateMainDepsHandler<TMenuItem>(deps: {
openFirstRunSetupWindow: deps.openFirstRunSetupWindow, openFirstRunSetupWindow: deps.openFirstRunSetupWindow,
showWindowsMpvLauncherSetup: deps.showWindowsMpvLauncherSetup, showWindowsMpvLauncherSetup: deps.showWindowsMpvLauncherSetup,
openYomitanSettings: deps.openYomitanSettings, openYomitanSettings: deps.openYomitanSettings,
openRuntimeOptionsPalette: deps.openRuntimeOptionsPalette,
openConfigSettingsWindow: deps.openConfigSettingsWindow, openConfigSettingsWindow: deps.openConfigSettingsWindow,
openJellyfinSetupWindow: deps.openJellyfinSetupWindow, openJellyfinSetupWindow: deps.openJellyfinSetupWindow,
isJellyfinConfigured: deps.isJellyfinConfigured, isJellyfinConfigured: deps.isJellyfinConfigured,
@@ -31,7 +31,6 @@ test('tray runtime handlers compose resolve/menu/ensure/destroy handlers', () =>
openFirstRunSetupWindow: () => {}, openFirstRunSetupWindow: () => {},
showWindowsMpvLauncherSetup: () => true, showWindowsMpvLauncherSetup: () => true,
openYomitanSettings: () => {}, openYomitanSettings: () => {},
openRuntimeOptionsPalette: () => {},
openConfigSettingsWindow: () => {}, openConfigSettingsWindow: () => {},
openJellyfinSetupWindow: () => {}, openJellyfinSetupWindow: () => {},
isJellyfinConfigured: () => false, isJellyfinConfigured: () => false,
+10 -9
View File
@@ -37,7 +37,6 @@ test('tray menu template contains expected entries and handlers', () => {
openWindowsMpvLauncherSetup: () => calls.push('windows-mpv'), openWindowsMpvLauncherSetup: () => calls.push('windows-mpv'),
showWindowsMpvLauncherSetup: true, showWindowsMpvLauncherSetup: true,
openYomitanSettings: () => calls.push('yomitan'), openYomitanSettings: () => calls.push('yomitan'),
openRuntimeOptions: () => calls.push('runtime'),
openConfigSettings: () => calls.push('configuration'), openConfigSettings: () => calls.push('configuration'),
openJellyfinSetup: () => calls.push('jellyfin'), openJellyfinSetup: () => calls.push('jellyfin'),
showJellyfinDiscovery: true, showJellyfinDiscovery: true,
@@ -48,7 +47,11 @@ test('tray menu template contains expected entries and handlers', () => {
quitApp: () => calls.push('quit'), quitApp: () => calls.push('quit'),
}); });
assert.equal(template.length, 13); assert.equal(template.length, 12);
assert.equal(
template.some((entry) => entry.label === 'Open Runtime Options'),
false,
);
assert.equal( assert.equal(
template.some((entry) => entry.label === 'Open Overlay'), template.some((entry) => entry.label === 'Open Overlay'),
false, false,
@@ -61,10 +64,11 @@ test('tray menu template contains expected entries and handlers', () => {
template[0]!.click?.(); template[0]!.click?.();
assert.equal(template[1]!.label, 'Open Texthooker'); assert.equal(template[1]!.label, 'Open Texthooker');
template[1]!.click?.(); template[1]!.click?.();
assert.equal(template[10]!.label, 'Check for Updates'); assert.equal(template[5]!.label, 'Open SubMiner Settings');
template[10]!.click?.(); assert.equal(template[9]!.label, 'Check for Updates');
template[11]!.type === 'separator' ? calls.push('separator') : calls.push('bad'); template[9]!.click?.();
template[12]!.click?.(); template[10]!.type === 'separator' ? calls.push('separator') : calls.push('bad');
template[11]!.click?.();
assert.deepEqual(calls, [ assert.deepEqual(calls, [
'jellyfin-discovery', 'jellyfin-discovery',
'help', 'help',
@@ -85,7 +89,6 @@ test('tray menu template omits first-run setup entry when setup is complete', ()
openWindowsMpvLauncherSetup: () => undefined, openWindowsMpvLauncherSetup: () => undefined,
showWindowsMpvLauncherSetup: false, showWindowsMpvLauncherSetup: false,
openYomitanSettings: () => undefined, openYomitanSettings: () => undefined,
openRuntimeOptions: () => undefined,
openConfigSettings: () => undefined, openConfigSettings: () => undefined,
openJellyfinSetup: () => undefined, openJellyfinSetup: () => undefined,
showJellyfinDiscovery: false, showJellyfinDiscovery: false,
@@ -113,7 +116,6 @@ test('tray menu template omits texthooker entry when texthooker page is disabled
openWindowsMpvLauncherSetup: () => undefined, openWindowsMpvLauncherSetup: () => undefined,
showWindowsMpvLauncherSetup: false, showWindowsMpvLauncherSetup: false,
openYomitanSettings: () => undefined, openYomitanSettings: () => undefined,
openRuntimeOptions: () => undefined,
openConfigSettings: () => undefined, openConfigSettings: () => undefined,
openJellyfinSetup: () => undefined, openJellyfinSetup: () => undefined,
showJellyfinDiscovery: false, showJellyfinDiscovery: false,
@@ -139,7 +141,6 @@ test('tray menu template renders active jellyfin discovery checkbox', () => {
openWindowsMpvLauncherSetup: () => undefined, openWindowsMpvLauncherSetup: () => undefined,
showWindowsMpvLauncherSetup: false, showWindowsMpvLauncherSetup: false,
openYomitanSettings: () => undefined, openYomitanSettings: () => undefined,
openRuntimeOptions: () => undefined,
openConfigSettings: () => undefined, openConfigSettings: () => undefined,
openJellyfinSetup: () => undefined, openJellyfinSetup: () => undefined,
showJellyfinDiscovery: true, showJellyfinDiscovery: true,
+1 -6
View File
@@ -38,7 +38,6 @@ export type TrayMenuActionHandlers = {
openWindowsMpvLauncherSetup: () => void; openWindowsMpvLauncherSetup: () => void;
showWindowsMpvLauncherSetup: boolean; showWindowsMpvLauncherSetup: boolean;
openYomitanSettings: () => void; openYomitanSettings: () => void;
openRuntimeOptions: () => void;
openConfigSettings: () => void; openConfigSettings: () => void;
openJellyfinSetup: () => void; openJellyfinSetup: () => void;
showJellyfinDiscovery: boolean; showJellyfinDiscovery: boolean;
@@ -90,11 +89,7 @@ export function buildTrayMenuTemplateRuntime(handlers: TrayMenuActionHandlers):
click: handlers.openYomitanSettings, click: handlers.openYomitanSettings,
}, },
{ {
label: 'Open Runtime Options', label: 'Open SubMiner Settings',
click: handlers.openRuntimeOptions,
},
{
label: 'Open Settings',
click: handlers.openConfigSettings, click: handlers.openConfigSettings,
}, },
{ {
+2
View File
@@ -12,6 +12,7 @@ export interface AppLifecycleRuntimeRunnerParams {
handleCliCommand: (nextArgs: CliArgs, source: CliCommandSource) => void; handleCliCommand: (nextArgs: CliArgs, source: CliCommandSource) => void;
printHelp: () => void; printHelp: () => void;
logNoRunningInstance: () => void; logNoRunningInstance: () => void;
startControlServer?: (handleArgv: (argv: string[]) => void) => (() => void) | void;
onReady: () => Promise<void>; onReady: () => Promise<void>;
onWillQuitCleanup: () => void; onWillQuitCleanup: () => void;
shouldRestoreWindowsOnActivate: () => boolean; shouldRestoreWindowsOnActivate: () => boolean;
@@ -34,6 +35,7 @@ export function createAppLifecycleRuntimeRunner(
handleCliCommand: params.handleCliCommand, handleCliCommand: params.handleCliCommand,
printHelp: params.printHelp, printHelp: params.printHelp,
logNoRunningInstance: params.logNoRunningInstance, logNoRunningInstance: params.logNoRunningInstance,
startControlServer: params.startControlServer,
onReady: params.onReady, onReady: params.onReady,
onWillQuitCleanup: params.onWillQuitCleanup, onWillQuitCleanup: params.onWillQuitCleanup,
shouldRestoreWindowsOnActivate: params.shouldRestoreWindowsOnActivate, shouldRestoreWindowsOnActivate: params.shouldRestoreWindowsOnActivate,
+8
View File
@@ -156,3 +156,11 @@ test('discord presence update interval displays seconds while saving millisecond
assert.equal(toSettingsDisplayValue(path, 3000), 3); assert.equal(toSettingsDisplayValue(path, 3000), 3);
assert.equal(toConfigDraftValue(path, 2.5), 2500); assert.equal(toConfigDraftValue(path, 2.5), 2500);
}); });
test('websocket enabled select values save booleans instead of strings', () => {
assert.equal(toSettingsDisplayValue('websocket.enabled', true), 'true');
assert.equal(toSettingsDisplayValue('websocket.enabled', false), 'false');
assert.equal(toConfigDraftValue('websocket.enabled', 'true'), true);
assert.equal(toConfigDraftValue('websocket.enabled', 'false'), false);
assert.equal(toConfigDraftValue('websocket.enabled', 'auto'), 'auto');
});
+7
View File
@@ -75,6 +75,9 @@ export function toSettingsDisplayValue(
path: string, path: string,
value: ConfigSettingsSnapshotValue, value: ConfigSettingsSnapshotValue,
): ConfigSettingsSnapshotValue { ): ConfigSettingsSnapshotValue {
if (path === 'websocket.enabled' && typeof value === 'boolean') {
return value ? 'true' : 'false';
}
if (path === 'discordPresence.updateIntervalMs' && typeof value === 'number') { if (path === 'discordPresence.updateIntervalMs' && typeof value === 'number') {
return value / 1000; return value / 1000;
} }
@@ -85,6 +88,10 @@ export function toConfigDraftValue(
path: string, path: string,
value: ConfigSettingsSnapshotValue, value: ConfigSettingsSnapshotValue,
): ConfigSettingsSnapshotValue { ): ConfigSettingsSnapshotValue {
if (path === 'websocket.enabled') {
if (value === 'true') return true;
if (value === 'false') return false;
}
if (path === 'discordPresence.updateIntervalMs' && typeof value === 'number') { if (path === 'discordPresence.updateIntervalMs' && typeof value === 'number') {
return Math.round(value * 1000); return Math.round(value * 1000);
} }
+103
View File
@@ -0,0 +1,103 @@
import net from 'node:net';
import {
encodeAppControlRequest,
getAppControlSocketPath,
parseAppControlResponseLine,
type AppControlSocketPathOptions,
} from './app-control';
export interface AppControlClientOptions extends AppControlSocketPathOptions {
socketPath?: string;
timeoutMs?: number;
}
export interface AppControlCommandResult {
ok: boolean;
unavailable?: boolean;
error?: string;
}
function resolveSocketPath(options: AppControlClientOptions): string {
return options.socketPath ?? getAppControlSocketPath(options);
}
export function isAppControlServerAvailable(
options: AppControlClientOptions = {},
): Promise<boolean> {
const socketPath = resolveSocketPath(options);
const timeoutMs = options.timeoutMs ?? 350;
return new Promise<boolean>((resolve) => {
const socket = net.createConnection(socketPath);
let settled = false;
const finish = (available: boolean): void => {
if (settled) return;
settled = true;
try {
socket.destroy();
} catch {
// ignore
}
resolve(available);
};
socket.once('connect', () => finish(typeof socket.write === 'function'));
socket.once('error', () => finish(false));
socket.setTimeout(timeoutMs, () => finish(false));
});
}
export function sendAppControlCommand(
argv: string[],
options: AppControlClientOptions = {},
): Promise<AppControlCommandResult> {
const socketPath = resolveSocketPath(options);
const timeoutMs = options.timeoutMs ?? 1000;
return new Promise<AppControlCommandResult>((resolve) => {
const socket = net.createConnection(socketPath);
let settled = false;
let connected = false;
let responseBuffer = '';
const finish = (result: AppControlCommandResult): void => {
if (settled) return;
settled = true;
try {
socket.destroy();
} catch {
// ignore
}
resolve(result);
};
socket.once('connect', () => {
connected = true;
if (typeof socket.write !== 'function') {
finish({ ok: false, unavailable: true, error: 'App control socket is not writable' });
return;
}
socket.write(encodeAppControlRequest(argv));
});
socket.on('data', (chunk) => {
responseBuffer += chunk.toString('utf8');
const newlineIndex = responseBuffer.indexOf('\n');
if (newlineIndex < 0) return;
try {
finish(parseAppControlResponseLine(responseBuffer.slice(0, newlineIndex)));
} catch (error) {
finish({ ok: false, error: error instanceof Error ? error.message : String(error) });
}
});
socket.once('error', (error) => {
finish({ ok: false, unavailable: !connected, error: error.message });
});
socket.once('close', () => {
finish({ ok: false, unavailable: !connected, error: 'App control socket closed' });
});
socket.setTimeout(timeoutMs, () => {
finish({ ok: false, unavailable: !connected, error: 'App control socket timed out' });
});
});
}
+98
View File
@@ -0,0 +1,98 @@
import crypto from 'node:crypto';
import os from 'node:os';
import path from 'node:path';
export const SUBMINER_APP_CONTROL_SOCKET_ENV = 'SUBMINER_APP_CONTROL_SOCKET';
export interface AppControlSocketPathOptions {
configDir?: string;
env?: NodeJS.ProcessEnv;
platform?: NodeJS.Platform;
tmpDir?: string;
}
export interface AppControlRequest {
argv: string[];
}
export interface AppControlResponse {
ok: boolean;
error?: string;
}
function getUserKey(): string {
if (typeof process.getuid === 'function') {
return String(process.getuid());
}
try {
const user = os.userInfo();
if (typeof user.uid === 'number') {
return String(user.uid);
}
if (user.username) {
return user.username.replace(/[^\w.-]/g, '_');
}
} catch {
// Fall back below.
}
return 'user';
}
export function getAppControlSocketPath(options: AppControlSocketPathOptions = {}): string {
const env = options.env ?? process.env;
const override = env[SUBMINER_APP_CONTROL_SOCKET_ENV]?.trim();
if (override) return override;
const platform = options.platform ?? process.platform;
const identity = options.configDir?.trim() || 'default';
const digest = crypto.createHash('sha256').update(identity).digest('hex').slice(0, 16);
if (platform === 'win32') {
return `\\\\.\\pipe\\subminer-control-${digest}`;
}
return path.join(
options.tmpDir ?? os.tmpdir(),
`subminer-control-${getUserKey()}-${digest}.sock`,
);
}
export function encodeAppControlRequest(argv: string[]): string {
return `${JSON.stringify({ argv })}\n`;
}
export function encodeAppControlResponse(response: AppControlResponse): string {
return `${JSON.stringify(response)}\n`;
}
function normalizeArgv(value: unknown): string[] | null {
if (!Array.isArray(value) || value.length > 128) return null;
const argv: string[] = [];
for (const entry of value) {
if (typeof entry !== 'string' || entry.length > 8192) {
return null;
}
argv.push(entry);
}
return argv;
}
export function parseAppControlRequestLine(line: string): AppControlRequest {
const payload = JSON.parse(line) as { argv?: unknown };
const argv = normalizeArgv(payload.argv);
if (!argv) {
throw new Error('Invalid app-control argv payload');
}
return { argv };
}
export function parseAppControlResponseLine(line: string): AppControlResponse {
const payload = JSON.parse(line) as { ok?: unknown; error?: unknown };
if (payload.ok === true) {
return { ok: true };
}
return {
ok: false,
error: typeof payload.error === 'string' ? payload.error : 'App control command failed',
};
}