mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-05-25 12:55:18 -07:00
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:
@@ -4,7 +4,7 @@
|
||||
|
||||
# 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)
|
||||
|
||||
@@ -23,7 +23,7 @@ Integrates Yomitan with mpv - look up words, mine to Anki, and track your immers
|
||||
|
||||
### 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">
|
||||
<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
|
||||
|
||||
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">
|
||||
<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
|
||||
|
||||
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">
|
||||
<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>
|
||||
<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>
|
||||
</table>
|
||||
|
||||
@@ -110,16 +110,17 @@ Browse sibling episode files and the active mpv queue in one overlay modal. Open
|
||||
|
||||
## 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 |
|
||||
| -------------------- | ----------- | ------------------------------------------------- |
|
||||
| mpv | Required | The video player SubMiner overlays on |
|
||||
| ffmpeg | Recommended | Audio clips & screenshots for Anki cards |
|
||||
| MeCab + mecab-ipadic | Recommended | More precise N+1, JLPT, and frequency annotations |
|
||||
| yt-dlp | Optional | YouTube playback |
|
||||
| fzf / rofi | Optional | Video picker in the launcher |
|
||||
| alass / ffsubsync | Optional | Subtitle sync |
|
||||
| Dependency | Status | What it does |
|
||||
| -------------------- | ----------- | ---------------------------------------- |
|
||||
| mpv | Required | The video player SubMiner overlays on |
|
||||
| Anki + AnkiConnect | Required | Card creation from the Yomitan popup |
|
||||
| ffmpeg | Recommended | Audio clips & screenshots for Anki cards |
|
||||
| MeCab + mecab-ipadic | Recommended | More precise annotations and filtering |
|
||||
| yt-dlp | Optional | YouTube playback |
|
||||
| fzf / rofi | Optional | Video picker in the launcher |
|
||||
| alass / ffsubsync | Optional | Subtitle sync |
|
||||
|
||||
<details>
|
||||
<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.
|
||||
|
||||
```bash
|
||||
# Linux (AUR)
|
||||
# Linux
|
||||
subminer app --setup
|
||||
|
||||
# macOS — open SubMiner.app, or:
|
||||
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
|
||||
subminer video.mkv # play video with overlay
|
||||
subminer stats # open immersion dashboard
|
||||
subminer settings # open settings window
|
||||
subminer --settings # open settings window via flag
|
||||
subminer video.mkv # launch mpv with SubMiner
|
||||
subminer /path/to/dir # pick a file with fzf
|
||||
subminer -R /path/to/dir # pick a file with rofi (Linux only)
|
||||
```
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
type: fixed
|
||||
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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -438,7 +438,7 @@
|
||||
"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
|
||||
"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
|
||||
"css": {
|
||||
"font-family": "Hiragino Sans, M PLUS 1, Source Han Sans JP, Noto Sans CJK JP", // Font family setting.
|
||||
|
||||
@@ -275,7 +275,7 @@ Defaults warm local tokenizer/dictionary work (`true` for `mecab`, `yomitanExten
|
||||
|
||||
### 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).
|
||||
|
||||
@@ -443,7 +443,7 @@ Configure the parsed-subtitle sidebar modal.
|
||||
"autoOpen": false,
|
||||
"layout": "overlay",
|
||||
"toggleKey": "Backslash",
|
||||
"pauseVideoOnHover": false,
|
||||
"pauseVideoOnHover": true,
|
||||
"autoScroll": true,
|
||||
"fontFamily": "\"M PLUS 1\", \"Noto Sans CJK JP\", sans-serif",
|
||||
"fontSize": 16
|
||||
@@ -457,7 +457,7 @@ Configure the parsed-subtitle sidebar modal.
|
||||
| `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 |
|
||||
| `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 |
|
||||
| `maxWidth` | number | Maximum sidebar width in CSS pixels (default: `420`) |
|
||||
| `opacity` | number | Sidebar opacity between `0` and `1` (default: `0.95`) |
|
||||
|
||||
@@ -438,7 +438,7 @@
|
||||
"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
|
||||
"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
|
||||
"css": {
|
||||
"font-family": "Hiragino Sans, M PLUS 1, Source Han Sans JP, Noto Sans CJK JP", // Font family setting.
|
||||
|
||||
@@ -33,7 +33,7 @@ Enable and configure the sidebar under `subtitleSidebar` in your config file:
|
||||
"autoOpen": false,
|
||||
"layout": "overlay",
|
||||
"toggleKey": "Backslash",
|
||||
"pauseVideoOnHover": false,
|
||||
"pauseVideoOnHover": true,
|
||||
"autoScroll": true,
|
||||
"fontFamily": "\"M PLUS 1\", \"Noto Sans CJK JP\", sans-serif",
|
||||
"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 |
|
||||
| `layout` | string | `"overlay"` | `"overlay"` floats over mpv; `"embedded"` reserves right-side player space |
|
||||
| `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 |
|
||||
| `maxWidth` | number | `420` | Maximum sidebar width in CSS pixels |
|
||||
| `opacity` | number | `0.95` | Sidebar opacity between `0` and `1` |
|
||||
|
||||
@@ -52,7 +52,7 @@ If you use the [mpv plugin](/mpv-plugin), it can also start a texthooker-only he
|
||||
|
||||
### 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`
|
||||
- **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
|
||||
|
||||
```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
|
||||
{
|
||||
"version": 1,
|
||||
@@ -91,16 +121,7 @@ When a client connects, SubMiner immediately sends the latest subtitle payload i
|
||||
}
|
||||
```
|
||||
|
||||
#### Field reference
|
||||
|
||||
| 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:
|
||||
Each annotation token may include:
|
||||
|
||||
| Token field | Type | Notes |
|
||||
| --- | --- | --- |
|
||||
@@ -119,16 +140,6 @@ Each token may include:
|
||||
| `frequencyRankLabel` | string or `null` | Preformatted rank 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
|
||||
|
||||
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;
|
||||
}
|
||||
});
|
||||
|
||||
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,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
cleanupPlaybackSession,
|
||||
launchAppCommandDetached,
|
||||
resolveLauncherRuntimePluginPath,
|
||||
isRunningAppControlServerAvailable,
|
||||
startMpv,
|
||||
startOverlay,
|
||||
state,
|
||||
@@ -146,6 +147,7 @@ export async function runPlaybackCommand(context: LauncherCommandContext): Promi
|
||||
waitForUnixSocketReady,
|
||||
startOverlay,
|
||||
launchAppCommandDetached,
|
||||
isAppControlServerAvailable: isRunningAppControlServerAvailable,
|
||||
log,
|
||||
cleanupPlaybackSession,
|
||||
getMpvProc: () => state.mpvProc,
|
||||
@@ -164,6 +166,7 @@ type PlaybackCommandDeps = {
|
||||
waitForUnixSocketReady: typeof waitForUnixSocketReady;
|
||||
startOverlay: typeof startOverlay;
|
||||
launchAppCommandDetached: typeof launchAppCommandDetached;
|
||||
isAppControlServerAvailable?: (logLevel: Args['logLevel']) => Promise<boolean>;
|
||||
log: typeof log;
|
||||
cleanupPlaybackSession: typeof cleanupPlaybackSession;
|
||||
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');
|
||||
}
|
||||
|
||||
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 =
|
||||
pluginRuntimeConfig.autoStart &&
|
||||
effectivePluginRuntimeConfig.autoStart &&
|
||||
pluginRuntimeConfig.autoStartVisibleOverlay &&
|
||||
pluginRuntimeConfig.autoStartPauseUntilReady;
|
||||
|
||||
@@ -238,16 +252,19 @@ export async function runPlaybackCommandWithDeps(
|
||||
disableYoutubeSubtitleAutoLoad: isAppOwnedYoutubeFlow,
|
||||
runtimePluginPath: resolveLauncherRuntimePluginPath({ appPath, scriptPath }),
|
||||
runtimePluginConfig: {
|
||||
...pluginRuntimeConfig,
|
||||
...effectivePluginRuntimeConfig,
|
||||
backend: args.backend,
|
||||
texthookerEnabled: args.useTexthooker && pluginRuntimeConfig.texthookerEnabled,
|
||||
texthookerEnabled: args.useTexthooker && effectivePluginRuntimeConfig.texthookerEnabled,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const ready = await deps.waitForUnixSocketReady(mpvSocketPath, 10000);
|
||||
const pluginAutoStartEnabled = pluginRuntimeConfig.autoStart;
|
||||
const shouldStartOverlay = args.startOverlay || args.autoStartOverlay || isAppOwnedYoutubeFlow;
|
||||
const shouldStartOverlay =
|
||||
args.startOverlay ||
|
||||
args.autoStartOverlay ||
|
||||
isAppOwnedYoutubeFlow ||
|
||||
shouldLauncherAttachRunningApp;
|
||||
if (shouldStartOverlay) {
|
||||
if (ready) {
|
||||
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',
|
||||
);
|
||||
}
|
||||
await deps.startOverlay(
|
||||
appPath,
|
||||
args,
|
||||
mpvSocketPath,
|
||||
isAppOwnedYoutubeFlow
|
||||
? ['--youtube-play', selectedTarget.target, '--youtube-mode', youtubeMode]
|
||||
: [],
|
||||
);
|
||||
const extraAppArgs = isAppOwnedYoutubeFlow
|
||||
? ['--youtube-play', selectedTarget.target, '--youtube-mode', youtubeMode]
|
||||
: shouldLauncherAttachRunningApp
|
||||
? [
|
||||
pluginRuntimeConfig.autoStartVisibleOverlay
|
||||
? '--show-visible-overlay'
|
||||
: '--hide-visible-overlay',
|
||||
...(pluginRuntimeConfig.texthookerEnabled ? ['--texthooker'] : []),
|
||||
]
|
||||
: [];
|
||||
await deps.startOverlay(appPath, args, mpvSocketPath, extraAppArgs);
|
||||
} else if (pluginAutoStartEnabled) {
|
||||
if (ready) {
|
||||
deps.log('info', args.logLevel, 'MPV IPC socket ready, relying on mpv plugin auto-start');
|
||||
|
||||
@@ -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 () => {
|
||||
const { dir, socketPath } = createTempSocketPath();
|
||||
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');
|
||||
assert.match(invocationText, /--app-ping/);
|
||||
assert.match(invocationText, /--start/);
|
||||
assert.doesNotMatch(invocationText, /--background/);
|
||||
assert.equal(state.overlayManagedByLauncher, false);
|
||||
assert.equal(state.appPath, '');
|
||||
} 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 () => {
|
||||
const { dir, socketPath } = createTempSocketPath();
|
||||
const appPath = path.join(dir, 'fake-subminer.sh');
|
||||
|
||||
+77
-4
@@ -4,6 +4,11 @@ import os from 'node:os';
|
||||
import net from 'node:net';
|
||||
import { spawn, spawnSync } from 'node:child_process';
|
||||
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 {
|
||||
detectInstalledMpvPlugin,
|
||||
type InstalledMpvPluginDetection,
|
||||
@@ -1004,19 +1009,70 @@ export async function startOverlay(
|
||||
): Promise<void> {
|
||||
const backend = detectBackend(args.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.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, {
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
env: buildAppEnv(process.env, target.env),
|
||||
});
|
||||
attachAppProcessLogging(state.overlayProc);
|
||||
if (appAlreadyRunning && !(state.overlayManagedByLauncher && state.appPath === appPath)) {
|
||||
if (borrowingExistingApp) {
|
||||
log(
|
||||
'debug',
|
||||
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 {
|
||||
if (appPath) {
|
||||
state.appPath = appPath;
|
||||
|
||||
+118
-29
@@ -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', () => {
|
||||
const smokeCase = createSmokeCase('setup-state');
|
||||
try {
|
||||
@@ -295,7 +373,7 @@ test('launcher mpv status returns ready when socket is connectable', async () =>
|
||||
});
|
||||
|
||||
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 },
|
||||
async () => {
|
||||
await withSmokeCase('overlay-start-stop', async (smokeCase) => {
|
||||
@@ -330,7 +408,9 @@ test(
|
||||
|
||||
const appStartArgs = appStartEntries[0]?.argv;
|
||||
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('--managed-playback'), true);
|
||||
assert.equal((appStartArgs as string[]).includes('--backend'), true);
|
||||
assert.equal((appStartArgs as string[]).includes('x11'), true);
|
||||
assert.equal((appStartArgs as string[]).includes('--socket'), true);
|
||||
@@ -351,44 +431,53 @@ 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 },
|
||||
async () => {
|
||||
await withSmokeCase('overlay-borrow-background', async (smokeCase) => {
|
||||
const controlServer = await startFakeControlServer(smokeCase);
|
||||
const env = {
|
||||
...makeTestEnv(smokeCase),
|
||||
SUBMINER_FAKE_APP_RUNNING: '1',
|
||||
SUBMINER_APP_CONTROL_SOCKET: controlServer.socketPath,
|
||||
};
|
||||
const result = runLauncher(
|
||||
smokeCase,
|
||||
['--backend', 'x11', '--start-overlay', smokeCase.videoPath],
|
||||
env,
|
||||
'overlay-borrow-background',
|
||||
);
|
||||
try {
|
||||
const result = runLauncher(
|
||||
smokeCase,
|
||||
['--backend', 'x11', '--start-overlay', smokeCase.videoPath],
|
||||
env,
|
||||
'overlay-borrow-background',
|
||||
);
|
||||
|
||||
const appLogPath = path.join(smokeCase.artifactsDir, 'fake-app.log');
|
||||
const appStartPath = path.join(smokeCase.artifactsDir, 'fake-app-start.log');
|
||||
const appStopPath = path.join(smokeCase.artifactsDir, 'fake-app-stop.log');
|
||||
await waitForJsonLines(appStartPath, 1);
|
||||
const appLogPath = path.join(smokeCase.artifactsDir, 'fake-app.log');
|
||||
const appStartPath = path.join(smokeCase.artifactsDir, 'fake-app-start.log');
|
||||
const appStopPath = path.join(smokeCase.artifactsDir, 'fake-app-stop.log');
|
||||
await waitForJsonLines(controlServer.logPath, 1);
|
||||
|
||||
const appEntries = readJsonLines(appLogPath);
|
||||
const appStartEntries = readJsonLines(appStartPath);
|
||||
const appStopEntries = readJsonLines(appStopPath);
|
||||
const mpvEntries = readJsonLines(path.join(smokeCase.artifactsDir, 'fake-mpv.log'));
|
||||
const mpvError = mpvEntries.find(
|
||||
(entry): entry is { error: string } => typeof entry.error === 'string',
|
||||
)?.error;
|
||||
const unixSocketDenied =
|
||||
typeof mpvError === 'string' && /eperm|operation not permitted/i.test(mpvError);
|
||||
const appEntries = readJsonLines(appLogPath);
|
||||
const appStartEntries = readJsonLines(appStartPath);
|
||||
const appStopEntries = readJsonLines(appStopPath);
|
||||
const controlEntries = readJsonLines(controlServer.logPath);
|
||||
const mpvEntries = readJsonLines(path.join(smokeCase.artifactsDir, 'fake-mpv.log'));
|
||||
const mpvError = mpvEntries.find(
|
||||
(entry): entry is { error: string } => typeof entry.error === 'string',
|
||||
)?.error;
|
||||
const unixSocketDenied =
|
||||
typeof mpvError === 'string' && /eperm|operation not permitted/i.test(mpvError);
|
||||
|
||||
assert.equal(result.status, unixSocketDenied ? 3 : 0);
|
||||
assert.ok(
|
||||
appEntries.some(
|
||||
(entry) => Array.isArray(entry.argv) && (entry.argv as string[]).includes('--app-ping'),
|
||||
),
|
||||
);
|
||||
assert.equal(appStartEntries.length, 1);
|
||||
assert.equal(appStopEntries.length, 0);
|
||||
assert.equal(result.status, unixSocketDenied ? 3 : 0);
|
||||
assert.equal(appEntries.length, 0);
|
||||
assert.equal(appStartEntries.length, 0);
|
||||
assert.equal(appStopEntries.length, 0);
|
||||
assert.equal(controlEntries.length, 1);
|
||||
const controlArgs = controlEntries[0]?.argv;
|
||||
assert.equal(Array.isArray(controlArgs), true);
|
||||
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();
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -207,6 +207,9 @@ function M.create(ctx)
|
||||
end
|
||||
|
||||
if action == "start" then
|
||||
if overrides.background ~= false then
|
||||
table.insert(args, "--background")
|
||||
end
|
||||
table.insert(args, "--managed-playback")
|
||||
|
||||
local backend = resolve_backend(overrides.backend)
|
||||
@@ -504,10 +507,13 @@ function M.create(ctx)
|
||||
end)
|
||||
end
|
||||
|
||||
launch_overlay_with_retry(1)
|
||||
if texthooker_enabled then
|
||||
ensure_texthooker_running(function() end)
|
||||
end
|
||||
environment.is_subminer_app_running_async(function(app_running)
|
||||
overrides.background = not app_running
|
||||
launch_overlay_with_retry(1)
|
||||
if texthooker_enabled then
|
||||
ensure_texthooker_running(function() end)
|
||||
end
|
||||
end, { force_refresh = true })
|
||||
end
|
||||
|
||||
local function start_overlay_from_script_message(...)
|
||||
|
||||
@@ -757,17 +757,17 @@ do
|
||||
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(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_1=--managed-playback"),
|
||||
"AppImage subprocess should transport --managed-playback"
|
||||
env_has(call, "SUBMINER_APP_ARG_1=--background"),
|
||||
"AppImage subprocess should transport --background"
|
||||
)
|
||||
assert_true(
|
||||
not env_has(call, "SUBMINER_APP_ARG_1=--background"),
|
||||
"AppImage subprocess should not transport --background for video-owned playback"
|
||||
env_has(call, "SUBMINER_APP_ARG_2=--managed-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_MPV_LOG="), "AppImage subprocess should include mpv log env")
|
||||
assert_true(
|
||||
@@ -1274,12 +1274,12 @@ do
|
||||
local start_call = find_start_call(recorded.async_calls)
|
||||
assert_true(start_call ~= nil, "auto-start should issue --start command")
|
||||
assert_true(
|
||||
not call_has_arg(start_call, "--background"),
|
||||
"auto-start should not mark video-owned playback as background/tray mode"
|
||||
call_has_arg(start_call, "--background"),
|
||||
"auto-start should launch SubMiner in background/tray mode"
|
||||
)
|
||||
assert_true(
|
||||
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(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,
|
||||
},
|
||||
})
|
||||
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, "end-file", { reason = "quit" })
|
||||
assert_true(
|
||||
@@ -1606,7 +1606,7 @@ do
|
||||
fire_event(recorded, "shutdown")
|
||||
assert_true(
|
||||
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(
|
||||
find_control_call(recorded.async_calls, "--hide-visible-overlay") == nil,
|
||||
@@ -1614,6 +1614,41 @@ do
|
||||
)
|
||||
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
|
||||
local recorded, err = run_plugin_scenario({
|
||||
process_list = "",
|
||||
@@ -1633,6 +1668,14 @@ do
|
||||
fire_event(recorded, "file-loaded")
|
||||
local start_call = find_start_call(recorded.async_calls)
|
||||
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(
|
||||
call_has_arg(start_call, "--hide-visible-overlay"),
|
||||
"auto-start with visible overlay disabled should include --hide-visible-overlay on --start"
|
||||
|
||||
@@ -101,6 +101,7 @@ test('loads defaults when config is missing', () => {
|
||||
assert.equal(config.subtitleStyle.autoPauseVideoOnHover, true);
|
||||
assert.equal(config.subtitleStyle.autoPauseVideoOnYomitanPopup, true);
|
||||
assert.equal(config.subtitleSidebar.enabled, true);
|
||||
assert.equal(config.subtitleSidebar.pauseVideoOnHover, true);
|
||||
assert.equal(config.subtitleStyle.hoverTokenColor, '#f4dbd6');
|
||||
assert.equal(config.subtitleStyle.hoverTokenBackgroundColor, 'transparent');
|
||||
assert.equal(config.subtitleStyle.fontFamily, DEFAULT_SUBTITLE_FONT_FAMILY);
|
||||
|
||||
@@ -69,7 +69,7 @@ export const SUBTITLE_DEFAULT_CONFIG: Pick<ResolvedConfig, 'subtitleStyle' | 'su
|
||||
autoOpen: false,
|
||||
layout: 'overlay',
|
||||
toggleKey: 'Backslash',
|
||||
pauseVideoOnHover: false,
|
||||
pauseVideoOnHover: true,
|
||||
autoScroll: true,
|
||||
css: {},
|
||||
maxWidth: 420,
|
||||
|
||||
@@ -79,6 +79,14 @@ test('settings registry orders websocket server immediately after annotation web
|
||||
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', () => {
|
||||
const trackingSections = [
|
||||
...new Set(
|
||||
|
||||
@@ -247,6 +247,8 @@ const DESCRIPTION_OVERRIDES: Record<string, string> = {
|
||||
'CSS declarations applied to secondary subtitles. Includes color, background-color, and all font properties.',
|
||||
'subtitleSidebar.css':
|
||||
'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':
|
||||
'Minimum interval between presence payload updates, in milliseconds.',
|
||||
};
|
||||
|
||||
@@ -224,6 +224,62 @@ test('startAppLifecycle queues second-instance commands until app ready runtime
|
||||
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', () => {
|
||||
let windowAllClosedHandler: (() => void) | null = null;
|
||||
const { deps, calls } = createDeps({
|
||||
|
||||
@@ -13,6 +13,7 @@ export interface AppLifecycleServiceDeps {
|
||||
handleCliCommand: (args: CliArgs, source: CliCommandSource) => void;
|
||||
printHelp: () => void;
|
||||
logNoRunningInstance: () => void;
|
||||
startControlServer?: (handleArgv: (argv: string[]) => void) => (() => void) | void;
|
||||
whenReady: (handler: () => Promise<void>) => void;
|
||||
onWindowAllClosed: (handler: () => void) => void;
|
||||
onWillQuit: (handler: () => void) => void;
|
||||
@@ -41,6 +42,7 @@ export interface AppLifecycleDepsRuntimeOptions {
|
||||
handleCliCommand: (args: CliArgs, source: CliCommandSource) => void;
|
||||
printHelp: () => void;
|
||||
logNoRunningInstance: () => void;
|
||||
startControlServer?: (handleArgv: (argv: string[]) => void) => (() => void) | void;
|
||||
onReady: () => Promise<void>;
|
||||
onWillQuitCleanup: () => void;
|
||||
shouldRestoreWindowsOnActivate: () => boolean;
|
||||
@@ -70,6 +72,7 @@ export function createAppLifecycleDepsRuntime(
|
||||
handleCliCommand: options.handleCliCommand,
|
||||
printHelp: options.printHelp,
|
||||
logNoRunningInstance: options.logNoRunningInstance,
|
||||
startControlServer: options.startControlServer,
|
||||
whenReady: (handler) => {
|
||||
options.app
|
||||
.whenReady()
|
||||
@@ -116,6 +119,7 @@ export function startAppLifecycle(initialArgs: CliArgs, deps: AppLifecycleServic
|
||||
|
||||
let appReadyRuntimeComplete = false;
|
||||
const pendingSecondInstanceCommands: CliArgs[] = [];
|
||||
let stopControlServer: (() => void) | null = null;
|
||||
const handleSecondInstanceCommand = (args: CliArgs): void => {
|
||||
try {
|
||||
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 {
|
||||
const nextArgs = deps.parseArgs(argv);
|
||||
if (!appReadyRuntimeComplete) {
|
||||
@@ -145,6 +149,10 @@ export function startAppLifecycle(initialArgs: CliArgs, deps: AppLifecycleServic
|
||||
} catch (error) {
|
||||
logger.error('Failed to handle second-instance CLI command:', error);
|
||||
}
|
||||
};
|
||||
|
||||
deps.onSecondInstance((_event, argv) => {
|
||||
dispatchSecondInstanceArgv(argv);
|
||||
});
|
||||
|
||||
if (!deps.shouldStartApp(initialArgs)) {
|
||||
@@ -157,6 +165,12 @@ export function startAppLifecycle(initialArgs: CliArgs, deps: AppLifecycleServic
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
stopControlServer = deps.startControlServer?.(dispatchSecondInstanceArgv) ?? null;
|
||||
} catch (error) {
|
||||
logger.error('Failed to start app control socket:', error);
|
||||
}
|
||||
|
||||
deps.whenReady(async () => {
|
||||
await deps.onReady();
|
||||
appReadyRuntimeComplete = true;
|
||||
@@ -173,6 +187,8 @@ export function startAppLifecycle(initialArgs: CliArgs, deps: AppLifecycleServic
|
||||
});
|
||||
|
||||
deps.onWillQuit(() => {
|
||||
stopControlServer?.();
|
||||
stopControlServer = null;
|
||||
deps.onWillQuitCleanup();
|
||||
});
|
||||
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { CliArgs } from '../../cli/args';
|
||||
import { CliCommandServiceDeps, handleCliCommand } from './cli-command';
|
||||
import {
|
||||
CliCommandServiceDeps,
|
||||
createCliCommandDepsRuntime,
|
||||
handleCliCommand,
|
||||
} from './cli-command';
|
||||
|
||||
function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||
return {
|
||||
@@ -501,6 +505,132 @@ test('handleCliCommand applies socket path and connects on start', () => {
|
||||
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', () => {
|
||||
const { deps, calls } = createDeps({
|
||||
isTexthookerRunning: () => true,
|
||||
|
||||
@@ -115,6 +115,7 @@ export interface CliCommandServiceDeps {
|
||||
interface MpvClientLike {
|
||||
setSocketPath: (socketPath: string) => void;
|
||||
connect: () => void;
|
||||
reconnect?: () => void;
|
||||
}
|
||||
|
||||
interface TexthookerServiceLike {
|
||||
@@ -235,6 +236,10 @@ export function createCliCommandDepsRuntime(
|
||||
connectMpvClient: () => {
|
||||
const client = options.mpv.getClient();
|
||||
if (!client) return;
|
||||
if (client.reconnect) {
|
||||
client.reconnect();
|
||||
return;
|
||||
}
|
||||
client.connect();
|
||||
},
|
||||
isTexthookerRunning: () => options.texthooker.service.isRunning(),
|
||||
|
||||
@@ -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));
|
||||
|
||||
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 () => {
|
||||
const events: string[] = [];
|
||||
const transport = new MpvSocketTransport({
|
||||
socketPath: '/tmp/mpv.sock',
|
||||
onConnect: () => {},
|
||||
onData: () => {},
|
||||
onError: () => {},
|
||||
onClose: () => {},
|
||||
onClose: () => {
|
||||
events.push('close');
|
||||
},
|
||||
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.isConnecting, false);
|
||||
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]);
|
||||
});
|
||||
|
||||
@@ -105,32 +105,37 @@ export class MpvSocketTransport {
|
||||
}
|
||||
|
||||
this.connecting = true;
|
||||
this.socketRef = this.socketFactory();
|
||||
this.socket = this.socketRef;
|
||||
const socket = this.socketFactory();
|
||||
this.socketRef = socket;
|
||||
this.socket = socket;
|
||||
|
||||
this.socketRef.on('connect', () => {
|
||||
socket.on('connect', () => {
|
||||
if (this.socketRef !== socket) return;
|
||||
this.connected = true;
|
||||
this.connecting = false;
|
||||
this.callbacks.onConnect();
|
||||
});
|
||||
|
||||
this.socketRef.on('data', (data: Buffer) => {
|
||||
socket.on('data', (data: Buffer) => {
|
||||
if (this.socketRef !== socket) return;
|
||||
this.callbacks.onData(data);
|
||||
});
|
||||
|
||||
this.socketRef.on('error', (error: Error) => {
|
||||
socket.on('error', (error: Error) => {
|
||||
if (this.socketRef !== socket) return;
|
||||
this.connected = false;
|
||||
this.connecting = false;
|
||||
this.callbacks.onError(error);
|
||||
});
|
||||
|
||||
this.socketRef.on('close', () => {
|
||||
socket.on('close', () => {
|
||||
if (this.socketRef !== socket) return;
|
||||
this.connected = false;
|
||||
this.connecting = false;
|
||||
this.callbacks.onClose();
|
||||
});
|
||||
|
||||
this.socketRef.connect(this.socketPath);
|
||||
socket.connect(this.socketPath);
|
||||
}
|
||||
|
||||
send(payload: MpvSocketMessagePayload): boolean {
|
||||
@@ -144,13 +149,14 @@ export class MpvSocketTransport {
|
||||
}
|
||||
|
||||
shutdown(): void {
|
||||
if (this.socketRef) {
|
||||
this.socketRef.destroy();
|
||||
}
|
||||
const socket = this.socketRef;
|
||||
this.socketRef = null;
|
||||
this.socket = null;
|
||||
this.connected = false;
|
||||
this.connecting = false;
|
||||
if (socket) {
|
||||
socket.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
getSocket(): net.Socket | null {
|
||||
|
||||
@@ -168,6 +168,32 @@ test('MpvIpcClient connect logs connect-request at debug level', () => {
|
||||
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', () => {
|
||||
const client = new MpvIpcClient('/tmp/mpv.sock', makeDeps());
|
||||
const resolved: unknown[] = [];
|
||||
|
||||
@@ -275,6 +275,17 @@ export class MpvIpcClient implements MpvClient {
|
||||
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 {
|
||||
this.reconnectAttempt = scheduleMpvReconnect({
|
||||
attempt: this.reconnectAttempt,
|
||||
|
||||
@@ -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', () => {
|
||||
const payload: SubtitleData = {
|
||||
text: 'ignored fallback',
|
||||
|
||||
@@ -18,6 +18,12 @@ export type SubtitleWebsocketFrequencyOptions = {
|
||||
mode: 'single' | 'banded';
|
||||
};
|
||||
|
||||
export type SubtitleWebsocketPayloadMode = 'plain' | 'annotated';
|
||||
|
||||
type SubtitleWebsocketMessageOptions = {
|
||||
payloadMode?: SubtitleWebsocketPayloadMode;
|
||||
};
|
||||
|
||||
type SerializedSubtitleToken = Pick<
|
||||
MergedToken,
|
||||
| 'surface'
|
||||
@@ -198,7 +204,17 @@ export function serializeSubtitleMarkup(
|
||||
export function serializeSubtitleWebsocketMessage(
|
||||
payload: SubtitleData,
|
||||
options: SubtitleWebsocketFrequencyOptions,
|
||||
messageOptions: SubtitleWebsocketMessageOptions = {},
|
||||
): string {
|
||||
if (messageOptions.payloadMode === 'plain') {
|
||||
return JSON.stringify({
|
||||
version: 1,
|
||||
text: payload.text,
|
||||
sentence: escapeHtml(payload.text).replaceAll('\n', '<br>'),
|
||||
tokens: [],
|
||||
});
|
||||
}
|
||||
|
||||
return JSON.stringify({
|
||||
version: 1,
|
||||
text: payload.text,
|
||||
@@ -210,18 +226,21 @@ export function serializeSubtitleWebsocketMessage(
|
||||
export function serializeInitialSubtitleWebsocketMessage(
|
||||
payload: SubtitleData | null,
|
||||
options: SubtitleWebsocketFrequencyOptions,
|
||||
messageOptions: SubtitleWebsocketMessageOptions = {},
|
||||
): string | null {
|
||||
if (!payload || !payload.text.trim()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return serializeSubtitleWebsocketMessage(payload, options);
|
||||
return serializeSubtitleWebsocketMessage(payload, options, messageOptions);
|
||||
}
|
||||
|
||||
export class SubtitleWebSocket {
|
||||
private server: WebSocket.Server | null = null;
|
||||
private latestMessage = '';
|
||||
|
||||
public constructor(private readonly payloadMode: SubtitleWebsocketPayloadMode = 'annotated') {}
|
||||
|
||||
public isRunning(): boolean {
|
||||
return this.server !== null;
|
||||
}
|
||||
@@ -247,6 +266,7 @@ export class SubtitleWebSocket {
|
||||
const currentMessage = serializeInitialSubtitleWebsocketMessage(
|
||||
getCurrentSubtitleData(),
|
||||
getFrequencyOptions(),
|
||||
{ payloadMode: this.payloadMode },
|
||||
);
|
||||
if (currentMessage) {
|
||||
ws.send(currentMessage);
|
||||
@@ -262,7 +282,9 @@ export class SubtitleWebSocket {
|
||||
|
||||
public broadcast(payload: SubtitleData, options: SubtitleWebsocketFrequencyOptions): void {
|
||||
if (!this.server) return;
|
||||
const message = serializeSubtitleWebsocketMessage(payload, options);
|
||||
const message = serializeSubtitleWebsocketMessage(payload, options, {
|
||||
payloadMode: this.payloadMode,
|
||||
});
|
||||
this.latestMessage = message;
|
||||
for (const client of this.server.clients) {
|
||||
if (client.readyState === WebSocket.OPEN) {
|
||||
|
||||
+21
-3
@@ -34,6 +34,8 @@ import {
|
||||
import { applyControllerConfigUpdate } from './main/controller-config-update.js';
|
||||
import { openPlaylistBrowser as openPlaylistBrowserRuntime } from './main/runtime/playlist-browser-open';
|
||||
import { createDiscordRpcClient } from './main/runtime/discord-rpc-client.js';
|
||||
import { startAppControlServer } from './main/runtime/app-control-server';
|
||||
import { getAppControlSocketPath } from './shared/app-control';
|
||||
import {
|
||||
type CancelLinuxMpvFullscreenOverlayRefreshBurst,
|
||||
clearLinuxMpvFullscreenOverlayRefreshTimeouts,
|
||||
@@ -166,6 +168,7 @@ import {
|
||||
rememberAnilistAttemptedUpdateKey,
|
||||
} from './main/runtime/domains/anilist';
|
||||
import { DEFAULT_MIN_WATCH_RATIO } from './shared/watch-threshold';
|
||||
import { shouldShowTexthookerTrayEntry } from './main/runtime/tray-main-actions';
|
||||
import {
|
||||
createApplyJellyfinMpvDefaultsHandler,
|
||||
createBuildApplyJellyfinMpvDefaultsMainDepsHandler,
|
||||
@@ -790,7 +793,7 @@ const bootServices = createMainBootServices({
|
||||
warn: (message: string, details?: unknown) => console.warn(message, details),
|
||||
error: (message: string, details?: unknown) => console.error(message, details),
|
||||
}),
|
||||
createSubtitleWebSocket: () => new SubtitleWebSocket(),
|
||||
createSubtitleWebSocket: (payloadMode) => new SubtitleWebSocket(payloadMode),
|
||||
createLogger,
|
||||
createMainRuntimeRegistry,
|
||||
createOverlayManager,
|
||||
@@ -3073,6 +3076,12 @@ const openFirstRunSetupWindowHandler = createOpenFirstRunSetupWindowHandler({
|
||||
: 'Yomitan settings are unavailable while external read-only profile mode is enabled.';
|
||||
return;
|
||||
}
|
||||
if (submission.action === 'open-config-settings') {
|
||||
firstRunSetupMessage = openConfigSettingsWindow()
|
||||
? 'Opened SubMiner settings.'
|
||||
: 'SubMiner settings are unavailable.';
|
||||
return { skipRender: true };
|
||||
}
|
||||
if (submission.action === 'refresh') {
|
||||
const snapshot = await firstRunSetupService.refreshStatus('Status refreshed.');
|
||||
firstRunSetupMessage = snapshot.message;
|
||||
@@ -5796,6 +5805,16 @@ const { runAndApplyStartupState } = composeHeadlessStartupHandlers<
|
||||
handleCliCommand(nextArgs, source),
|
||||
printHelp: () => printHelp(DEFAULT_TEXTHOOKER_PORT),
|
||||
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,
|
||||
onWillQuitCleanup: () => onWillQuitCleanupHandler(),
|
||||
shouldRestoreWindowsOnActivate: () => shouldRestoreWindowsOnActivateHandler(),
|
||||
@@ -5943,12 +5962,11 @@ const { ensureTray: ensureTrayHandler, destroyTray: destroyTrayHandler } =
|
||||
openSessionHelpModal: () => openSessionHelpOverlay(),
|
||||
openTexthookerInBrowser: () =>
|
||||
handleCliCommand(parseArgs(['--texthooker', '--open-browser'])),
|
||||
showTexthookerPage: () => getResolvedConfig().texthooker.launchAtStartup !== false,
|
||||
showTexthookerPage: () => shouldShowTexthookerTrayEntry(getResolvedConfig()),
|
||||
showFirstRunSetup: () => !firstRunSetupService.isSetupCompleted(),
|
||||
openFirstRunSetupWindow: () => openFirstRunSetupWindow(),
|
||||
showWindowsMpvLauncherSetup: () => process.platform === 'win32',
|
||||
openYomitanSettings: () => openYomitanSettings(),
|
||||
openRuntimeOptionsPalette: () => openRuntimeOptionsPalette(),
|
||||
openConfigSettingsWindow: () => openConfigSettingsWindow(),
|
||||
openJellyfinSetupWindow: () => openJellyfinSetupWindow(),
|
||||
isJellyfinConfigured: () =>
|
||||
|
||||
@@ -11,6 +11,7 @@ export interface AppLifecycleRuntimeDepsFactoryInput {
|
||||
handleCliCommand: (nextArgs: CliArgs, source: CliCommandSource) => void;
|
||||
printHelp: () => void;
|
||||
logNoRunningInstance: () => void;
|
||||
startControlServer?: (handleArgv: (argv: string[]) => void) => (() => void) | void;
|
||||
onReady: () => Promise<void>;
|
||||
onWillQuitCleanup: () => void;
|
||||
shouldRestoreWindowsOnActivate: () => boolean;
|
||||
@@ -73,6 +74,7 @@ export function createAppLifecycleRuntimeDeps(
|
||||
handleCliCommand: params.handleCliCommand,
|
||||
printHelp: params.printHelp,
|
||||
logNoRunningInstance: params.logNoRunningInstance,
|
||||
startControlServer: params.startControlServer,
|
||||
onReady: params.onReady,
|
||||
onWillQuitCleanup: params.onWillQuitCleanup,
|
||||
shouldRestoreWindowsOnActivate: params.shouldRestoreWindowsOnActivate,
|
||||
|
||||
@@ -21,7 +21,7 @@ test('createMainBootServices builds boot-phase service bundle', () => {
|
||||
{ targetPath: string },
|
||||
{ targetPath: string },
|
||||
{ targetPath: string },
|
||||
{ kind: string },
|
||||
{ kind: string; payloadMode: 'plain' | 'annotated' },
|
||||
{ scope: string; warn: () => void; info: () => void; error: () => void },
|
||||
{ registry: boolean },
|
||||
{ getMainWindow: () => null; getModalWindow: () => null },
|
||||
@@ -76,7 +76,7 @@ test('createMainBootServices builds boot-phase service bundle', () => {
|
||||
createAnilistTokenStore: (targetPath) => ({ targetPath }),
|
||||
createJellyfinTokenStore: (targetPath) => ({ targetPath }),
|
||||
createAnilistUpdateQueue: (targetPath) => ({ targetPath }),
|
||||
createSubtitleWebSocket: () => ({ kind: 'ws' }),
|
||||
createSubtitleWebSocket: (payloadMode) => ({ kind: 'ws', payloadMode }),
|
||||
createLogger: (scope) =>
|
||||
({
|
||||
scope,
|
||||
@@ -115,6 +115,11 @@ test('createMainBootServices builds boot-phase service bundle', () => {
|
||||
assert.deepEqual(services.anilistUpdateQueue, {
|
||||
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, {
|
||||
mpvSocketPath: '/tmp/subminer.sock',
|
||||
texthookerPort: 5174,
|
||||
|
||||
@@ -64,7 +64,7 @@ export interface MainBootServicesParams<
|
||||
createAnilistTokenStore: (targetPath: string) => TAnilistTokenStore;
|
||||
createJellyfinTokenStore: (targetPath: string) => TJellyfinTokenStore;
|
||||
createAnilistUpdateQueue: (targetPath: string) => TAnilistUpdateQueue;
|
||||
createSubtitleWebSocket: () => TSubtitleWebSocket;
|
||||
createSubtitleWebSocket: (payloadMode: 'plain' | 'annotated') => TSubtitleWebSocket;
|
||||
createLogger: (scope: string) => TLogger & {
|
||||
warn: (message: string) => void;
|
||||
info: (message: string) => void;
|
||||
@@ -205,8 +205,8 @@ export function createMainBootServices<
|
||||
const anilistUpdateQueue = params.createAnilistUpdateQueue(
|
||||
params.joinPath(userDataPath, 'anilist-retry-queue.json'),
|
||||
);
|
||||
const subtitleWsService = params.createSubtitleWebSocket();
|
||||
const annotationSubtitleWsService = params.createSubtitleWebSocket();
|
||||
const subtitleWsService = params.createSubtitleWebSocket('plain');
|
||||
const annotationSubtitleWsService = params.createSubtitleWebSocket('annotated');
|
||||
const logger = params.createLogger('main');
|
||||
const runtimeRegistry = params.createMainRuntimeRegistry();
|
||||
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 });
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -59,10 +59,15 @@ test('buildFirstRunSetupHtml renders macchiato setup actions and disabled finish
|
||||
assert.match(html, /SubMiner setup/);
|
||||
assert.doesNotMatch(html, /Install legacy mpv plugin/);
|
||||
assert.doesNotMatch(html, /action=install-plugin/);
|
||||
assert.match(html, /Ready/);
|
||||
assert.doesNotMatch(html, /mpv runtime plugin/);
|
||||
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 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, /disabled/);
|
||||
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;/);
|
||||
});
|
||||
|
||||
test('buildFirstRunSetupHtml switches plugin action to reinstall when already installed', () => {
|
||||
test('buildFirstRunSetupHtml omits bundled mpv plugin readiness when already installed', () => {
|
||||
const html = buildFirstRunSetupHtml({
|
||||
configReady: true,
|
||||
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, /action=install-plugin/);
|
||||
assert.doesNotMatch(html, /mpv runtime plugin/);
|
||||
assert.match(html, /mpv executable 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, /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', () => {
|
||||
@@ -124,7 +130,8 @@ test('buildFirstRunSetupHtml shows legacy mpv plugin removal action with confirm
|
||||
});
|
||||
|
||||
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\.lua/);
|
||||
assert.match(html, /Remove legacy mpv plugin/);
|
||||
@@ -251,6 +258,12 @@ test('parseFirstRunSetupSubmissionUrl parses supported custom actions', () => {
|
||||
action: 'remove-legacy-plugin',
|
||||
},
|
||||
);
|
||||
assert.deepEqual(
|
||||
parseFirstRunSetupSubmissionUrl('subminer://first-run-setup?action=open-config-settings'),
|
||||
{
|
||||
action: 'open-config-settings',
|
||||
},
|
||||
);
|
||||
assert.equal(
|
||||
parseFirstRunSetupSubmissionUrl('subminer://first-run-setup?action=skip-plugin'),
|
||||
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']);
|
||||
});
|
||||
|
||||
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 () => {
|
||||
const calls: string[] = [];
|
||||
let closedHandler: (() => void) | undefined;
|
||||
|
||||
@@ -29,6 +29,7 @@ export type FirstRunSetupAction =
|
||||
| 'install-bun'
|
||||
| 'install-command-line-launcher'
|
||||
| 'open-yomitan-settings'
|
||||
| 'open-config-settings'
|
||||
| 'refresh'
|
||||
| 'finish';
|
||||
|
||||
@@ -200,14 +201,6 @@ export function buildFirstRunSetupHtml(model: FirstRunSetupHtmlModel): string {
|
||||
legacyMpvPluginPaths.length > 0 && model.canFinish
|
||||
? 'Continue without removing'
|
||||
: '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 =
|
||||
model.windowsMpvShortcuts.status === 'installed'
|
||||
? 'Installed'
|
||||
@@ -326,7 +319,7 @@ export function buildFirstRunSetupHtml(model: FirstRunSetupHtmlModel): string {
|
||||
: model.canFinish
|
||||
? 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 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.';
|
||||
|
||||
return `<!doctype html>
|
||||
@@ -522,14 +515,6 @@ export function buildFirstRunSetupHtml(model: FirstRunSetupHtmlModel): string {
|
||||
</div>
|
||||
${renderStatusBadge(model.configReady ? 'Ready' : 'Missing', model.configReady ? 'ready' : 'danger')}
|
||||
</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>
|
||||
<strong>Yomitan dictionaries</strong>
|
||||
@@ -544,6 +529,7 @@ export function buildFirstRunSetupHtml(model: FirstRunSetupHtmlModel): string {
|
||||
<div class="actions">
|
||||
<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 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>
|
||||
</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-command-line-launcher' &&
|
||||
action !== 'open-yomitan-settings' &&
|
||||
action !== 'open-config-settings' &&
|
||||
action !== 'refresh' &&
|
||||
action !== 'finish'
|
||||
) {
|
||||
@@ -632,7 +619,9 @@ export function createOpenFirstRunSetupWindowHandler<
|
||||
getSetupSnapshot: () => Promise<FirstRunSetupHtmlModel>;
|
||||
buildSetupHtml: (model: FirstRunSetupHtmlModel) => string;
|
||||
parseSubmissionUrl: (rawUrl: string) => FirstRunSetupSubmission | null;
|
||||
handleAction: (submission: FirstRunSetupSubmission) => Promise<{ closeWindow?: boolean } | void>;
|
||||
handleAction: (
|
||||
submission: FirstRunSetupSubmission,
|
||||
) => Promise<{ closeWindow?: boolean; skipRender?: boolean } | void>;
|
||||
markSetupInProgress: () => Promise<unknown>;
|
||||
markSetupCancelled: () => Promise<unknown>;
|
||||
isSetupCompleted: () => boolean;
|
||||
@@ -680,6 +669,9 @@ export function createOpenFirstRunSetupWindowHandler<
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (result?.skipRender) {
|
||||
return;
|
||||
}
|
||||
if (!setupWindow.isDestroyed()) {
|
||||
await render();
|
||||
}
|
||||
|
||||
@@ -144,3 +144,34 @@ test('managed local subtitle selection runtime promotes a single unlabeled exter
|
||||
['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],
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -200,7 +200,8 @@ export function createManagedLocalSubtitleSelectionRuntime(deps: {
|
||||
}) {
|
||||
const delayMs = deps.delayMs ?? 400;
|
||||
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;
|
||||
|
||||
const clearPendingTimer = (): void => {
|
||||
@@ -212,7 +213,11 @@ export function createManagedLocalSubtitleSelectionRuntime(deps: {
|
||||
};
|
||||
|
||||
const maybeApplySelection = (trackList: unknown[] | null): void => {
|
||||
if (!currentMediaPath || appliedMediaPath === currentMediaPath) {
|
||||
if (
|
||||
!currentMediaPath ||
|
||||
(appliedPrimaryMediaPath === currentMediaPath &&
|
||||
appliedSecondaryMediaPath === currentMediaPath)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const selection = resolveManagedLocalSubtitleSelection({
|
||||
@@ -223,14 +228,17 @@ export function createManagedLocalSubtitleSelectionRuntime(deps: {
|
||||
if (!selection.hasPrimaryMatch && !selection.hasSecondaryMatch) {
|
||||
return;
|
||||
}
|
||||
if (selection.primaryTrackId !== null) {
|
||||
if (selection.primaryTrackId !== null && appliedPrimaryMediaPath !== currentMediaPath) {
|
||||
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]);
|
||||
appliedSecondaryMediaPath = currentMediaPath;
|
||||
}
|
||||
if (appliedPrimaryMediaPath === currentMediaPath) {
|
||||
clearPendingTimer();
|
||||
}
|
||||
appliedMediaPath = currentMediaPath;
|
||||
clearPendingTimer();
|
||||
};
|
||||
|
||||
const refreshFromMpv = async (): Promise<void> => {
|
||||
@@ -252,7 +260,7 @@ export function createManagedLocalSubtitleSelectionRuntime(deps: {
|
||||
|
||||
const scheduleRefresh = (): void => {
|
||||
clearPendingTimer();
|
||||
if (!currentMediaPath || appliedMediaPath === currentMediaPath) {
|
||||
if (!currentMediaPath || appliedPrimaryMediaPath === currentMediaPath) {
|
||||
return;
|
||||
}
|
||||
pendingTimer = deps.schedule(() => {
|
||||
@@ -265,7 +273,8 @@ export function createManagedLocalSubtitleSelectionRuntime(deps: {
|
||||
handleMediaPathChange: (mediaPath: string | null | undefined): void => {
|
||||
const normalizedPath = normalizeLocalMediaPath(mediaPath);
|
||||
if (normalizedPath !== currentMediaPath) {
|
||||
appliedMediaPath = null;
|
||||
appliedPrimaryMediaPath = null;
|
||||
appliedSecondaryMediaPath = null;
|
||||
}
|
||||
currentMediaPath = normalizedPath;
|
||||
if (!currentMediaPath) {
|
||||
|
||||
@@ -161,3 +161,67 @@ test('main mpv event binder runs mpv-connected callback on connection', () => {
|
||||
|
||||
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 => {
|
||||
if (connected) {
|
||||
deps.resetSubtitleSidebarEmbeddedLayout();
|
||||
} else {
|
||||
deps.updateCurrentMediaPath('');
|
||||
}
|
||||
handleMpvConnectionChange({ connected });
|
||||
};
|
||||
|
||||
@@ -11,6 +11,7 @@ export function createBuildAppLifecycleRuntimeRunnerMainDepsHandler(
|
||||
handleCliCommand: deps.handleCliCommand,
|
||||
printHelp: deps.printHelp,
|
||||
logNoRunningInstance: deps.logNoRunningInstance,
|
||||
startControlServer: deps.startControlServer,
|
||||
onReady: deps.onReady,
|
||||
onWillQuitCleanup: deps.onWillQuitCleanup,
|
||||
shouldRestoreWindowsOnActivate: deps.shouldRestoreWindowsOnActivate,
|
||||
|
||||
@@ -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(
|
||||
shouldQuitOnMpvShutdownForTrayState({
|
||||
managedPlayback: true,
|
||||
backgroundMode: true,
|
||||
hasTray: true,
|
||||
}),
|
||||
false,
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -65,3 +65,14 @@ test('mpv shutdown quits standalone managed playback without tray residency', ()
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test('mpv shutdown keeps unmanaged background tray app alive', () => {
|
||||
assert.equal(
|
||||
shouldQuitOnMpvShutdownForTrayState({
|
||||
managedPlayback: false,
|
||||
backgroundMode: true,
|
||||
hasTray: true,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -27,8 +27,6 @@ export function shouldQuitOnMpvShutdownForTrayState(options: {
|
||||
backgroundMode: boolean;
|
||||
hasTray: boolean;
|
||||
}): boolean {
|
||||
if (!options.managedPlayback) return false;
|
||||
if (options.backgroundMode) return false;
|
||||
if (options.hasTray) return false;
|
||||
return true;
|
||||
// managedPlayback marks process ownership; tray/background only affect window-close policy.
|
||||
return options.managedPlayback;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import assert from 'node:assert/strict';
|
||||
import {
|
||||
createBuildTrayMenuTemplateHandler,
|
||||
createResolveTrayIconPathHandler,
|
||||
shouldShowTexthookerTrayEntry,
|
||||
} from './tray-main-actions';
|
||||
|
||||
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.openWindowsMpvLauncherSetup();
|
||||
handlers.openYomitanSettings();
|
||||
handlers.openRuntimeOptions();
|
||||
handlers.openConfigSettings();
|
||||
handlers.openJellyfinSetup();
|
||||
handlers.toggleJellyfinDiscovery();
|
||||
@@ -68,7 +68,6 @@ test('build tray template handler wires actions and init guards', () => {
|
||||
openFirstRunSetupWindow: () => calls.push('setup'),
|
||||
showWindowsMpvLauncherSetup: () => true,
|
||||
openYomitanSettings: () => calls.push('yomitan'),
|
||||
openRuntimeOptionsPalette: () => calls.push('runtime-options'),
|
||||
openConfigSettingsWindow: () => calls.push('configuration'),
|
||||
openJellyfinSetupWindow: () => calls.push('jellyfin'),
|
||||
isJellyfinConfigured: () => true,
|
||||
@@ -91,7 +90,6 @@ test('build tray template handler wires actions and init guards', () => {
|
||||
'setup',
|
||||
'setup',
|
||||
'yomitan',
|
||||
'runtime-options',
|
||||
'configuration',
|
||||
'jellyfin',
|
||||
'jellyfin-discovery',
|
||||
@@ -100,3 +98,34 @@ test('build tray template handler wires actions and init guards', () => {
|
||||
'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,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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: {
|
||||
buildTrayMenuTemplateRuntime: (handlers: {
|
||||
openSessionHelp: () => void;
|
||||
@@ -36,7 +45,6 @@ export function createBuildTrayMenuTemplateHandler<TMenuItem>(deps: {
|
||||
openWindowsMpvLauncherSetup: () => void;
|
||||
showWindowsMpvLauncherSetup: boolean;
|
||||
openYomitanSettings: () => void;
|
||||
openRuntimeOptions: () => void;
|
||||
openConfigSettings: () => void;
|
||||
openJellyfinSetup: () => void;
|
||||
showJellyfinDiscovery: boolean;
|
||||
@@ -55,7 +63,6 @@ export function createBuildTrayMenuTemplateHandler<TMenuItem>(deps: {
|
||||
openFirstRunSetupWindow: () => void;
|
||||
showWindowsMpvLauncherSetup: () => boolean;
|
||||
openYomitanSettings: () => void;
|
||||
openRuntimeOptionsPalette: () => void;
|
||||
openConfigSettingsWindow: () => void;
|
||||
openJellyfinSetupWindow: () => void;
|
||||
isJellyfinConfigured: () => boolean;
|
||||
@@ -88,12 +95,6 @@ export function createBuildTrayMenuTemplateHandler<TMenuItem>(deps: {
|
||||
openYomitanSettings: () => {
|
||||
deps.openYomitanSettings();
|
||||
},
|
||||
openRuntimeOptions: () => {
|
||||
if (!deps.isOverlayRuntimeInitialized()) {
|
||||
deps.initializeOverlayRuntime();
|
||||
}
|
||||
deps.openRuntimeOptionsPalette();
|
||||
},
|
||||
openConfigSettings: () => {
|
||||
deps.openConfigSettingsWindow();
|
||||
},
|
||||
|
||||
@@ -31,7 +31,6 @@ test('tray main deps builders return mapped handlers', () => {
|
||||
openFirstRunSetupWindow: () => calls.push('setup'),
|
||||
showWindowsMpvLauncherSetup: () => true,
|
||||
openYomitanSettings: () => calls.push('yomitan'),
|
||||
openRuntimeOptionsPalette: () => calls.push('runtime-options'),
|
||||
openConfigSettingsWindow: () => calls.push('configuration'),
|
||||
openJellyfinSetupWindow: () => calls.push('jellyfin'),
|
||||
isJellyfinConfigured: () => true,
|
||||
@@ -53,7 +52,6 @@ test('tray main deps builders return mapped handlers', () => {
|
||||
openWindowsMpvLauncherSetup: () => calls.push('open-windows-mpv'),
|
||||
showWindowsMpvLauncherSetup: true,
|
||||
openYomitanSettings: () => calls.push('open-yomitan'),
|
||||
openRuntimeOptions: () => calls.push('open-runtime-options'),
|
||||
openConfigSettings: () => calls.push('open-configuration'),
|
||||
openJellyfinSetup: () => calls.push('open-jellyfin'),
|
||||
showJellyfinDiscovery: true,
|
||||
|
||||
@@ -35,7 +35,6 @@ export function createBuildTrayMenuTemplateMainDepsHandler<TMenuItem>(deps: {
|
||||
openWindowsMpvLauncherSetup: () => void;
|
||||
showWindowsMpvLauncherSetup: boolean;
|
||||
openYomitanSettings: () => void;
|
||||
openRuntimeOptions: () => void;
|
||||
openConfigSettings: () => void;
|
||||
openJellyfinSetup: () => void;
|
||||
showJellyfinDiscovery: boolean;
|
||||
@@ -54,7 +53,6 @@ export function createBuildTrayMenuTemplateMainDepsHandler<TMenuItem>(deps: {
|
||||
openFirstRunSetupWindow: () => void;
|
||||
showWindowsMpvLauncherSetup: () => boolean;
|
||||
openYomitanSettings: () => void;
|
||||
openRuntimeOptionsPalette: () => void;
|
||||
openConfigSettingsWindow: () => void;
|
||||
openJellyfinSetupWindow: () => void;
|
||||
isJellyfinConfigured: () => boolean;
|
||||
@@ -75,7 +73,6 @@ export function createBuildTrayMenuTemplateMainDepsHandler<TMenuItem>(deps: {
|
||||
openFirstRunSetupWindow: deps.openFirstRunSetupWindow,
|
||||
showWindowsMpvLauncherSetup: deps.showWindowsMpvLauncherSetup,
|
||||
openYomitanSettings: deps.openYomitanSettings,
|
||||
openRuntimeOptionsPalette: deps.openRuntimeOptionsPalette,
|
||||
openConfigSettingsWindow: deps.openConfigSettingsWindow,
|
||||
openJellyfinSetupWindow: deps.openJellyfinSetupWindow,
|
||||
isJellyfinConfigured: deps.isJellyfinConfigured,
|
||||
|
||||
@@ -31,7 +31,6 @@ test('tray runtime handlers compose resolve/menu/ensure/destroy handlers', () =>
|
||||
openFirstRunSetupWindow: () => {},
|
||||
showWindowsMpvLauncherSetup: () => true,
|
||||
openYomitanSettings: () => {},
|
||||
openRuntimeOptionsPalette: () => {},
|
||||
openConfigSettingsWindow: () => {},
|
||||
openJellyfinSetupWindow: () => {},
|
||||
isJellyfinConfigured: () => false,
|
||||
|
||||
@@ -37,7 +37,6 @@ test('tray menu template contains expected entries and handlers', () => {
|
||||
openWindowsMpvLauncherSetup: () => calls.push('windows-mpv'),
|
||||
showWindowsMpvLauncherSetup: true,
|
||||
openYomitanSettings: () => calls.push('yomitan'),
|
||||
openRuntimeOptions: () => calls.push('runtime'),
|
||||
openConfigSettings: () => calls.push('configuration'),
|
||||
openJellyfinSetup: () => calls.push('jellyfin'),
|
||||
showJellyfinDiscovery: true,
|
||||
@@ -48,7 +47,11 @@ test('tray menu template contains expected entries and handlers', () => {
|
||||
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(
|
||||
template.some((entry) => entry.label === 'Open Overlay'),
|
||||
false,
|
||||
@@ -61,10 +64,11 @@ test('tray menu template contains expected entries and handlers', () => {
|
||||
template[0]!.click?.();
|
||||
assert.equal(template[1]!.label, 'Open Texthooker');
|
||||
template[1]!.click?.();
|
||||
assert.equal(template[10]!.label, 'Check for Updates');
|
||||
template[10]!.click?.();
|
||||
template[11]!.type === 'separator' ? calls.push('separator') : calls.push('bad');
|
||||
template[12]!.click?.();
|
||||
assert.equal(template[5]!.label, 'Open SubMiner Settings');
|
||||
assert.equal(template[9]!.label, 'Check for Updates');
|
||||
template[9]!.click?.();
|
||||
template[10]!.type === 'separator' ? calls.push('separator') : calls.push('bad');
|
||||
template[11]!.click?.();
|
||||
assert.deepEqual(calls, [
|
||||
'jellyfin-discovery',
|
||||
'help',
|
||||
@@ -85,7 +89,6 @@ test('tray menu template omits first-run setup entry when setup is complete', ()
|
||||
openWindowsMpvLauncherSetup: () => undefined,
|
||||
showWindowsMpvLauncherSetup: false,
|
||||
openYomitanSettings: () => undefined,
|
||||
openRuntimeOptions: () => undefined,
|
||||
openConfigSettings: () => undefined,
|
||||
openJellyfinSetup: () => undefined,
|
||||
showJellyfinDiscovery: false,
|
||||
@@ -113,7 +116,6 @@ test('tray menu template omits texthooker entry when texthooker page is disabled
|
||||
openWindowsMpvLauncherSetup: () => undefined,
|
||||
showWindowsMpvLauncherSetup: false,
|
||||
openYomitanSettings: () => undefined,
|
||||
openRuntimeOptions: () => undefined,
|
||||
openConfigSettings: () => undefined,
|
||||
openJellyfinSetup: () => undefined,
|
||||
showJellyfinDiscovery: false,
|
||||
@@ -139,7 +141,6 @@ test('tray menu template renders active jellyfin discovery checkbox', () => {
|
||||
openWindowsMpvLauncherSetup: () => undefined,
|
||||
showWindowsMpvLauncherSetup: false,
|
||||
openYomitanSettings: () => undefined,
|
||||
openRuntimeOptions: () => undefined,
|
||||
openConfigSettings: () => undefined,
|
||||
openJellyfinSetup: () => undefined,
|
||||
showJellyfinDiscovery: true,
|
||||
|
||||
@@ -38,7 +38,6 @@ export type TrayMenuActionHandlers = {
|
||||
openWindowsMpvLauncherSetup: () => void;
|
||||
showWindowsMpvLauncherSetup: boolean;
|
||||
openYomitanSettings: () => void;
|
||||
openRuntimeOptions: () => void;
|
||||
openConfigSettings: () => void;
|
||||
openJellyfinSetup: () => void;
|
||||
showJellyfinDiscovery: boolean;
|
||||
@@ -90,11 +89,7 @@ export function buildTrayMenuTemplateRuntime(handlers: TrayMenuActionHandlers):
|
||||
click: handlers.openYomitanSettings,
|
||||
},
|
||||
{
|
||||
label: 'Open Runtime Options',
|
||||
click: handlers.openRuntimeOptions,
|
||||
},
|
||||
{
|
||||
label: 'Open Settings',
|
||||
label: 'Open SubMiner Settings',
|
||||
click: handlers.openConfigSettings,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -12,6 +12,7 @@ export interface AppLifecycleRuntimeRunnerParams {
|
||||
handleCliCommand: (nextArgs: CliArgs, source: CliCommandSource) => void;
|
||||
printHelp: () => void;
|
||||
logNoRunningInstance: () => void;
|
||||
startControlServer?: (handleArgv: (argv: string[]) => void) => (() => void) | void;
|
||||
onReady: () => Promise<void>;
|
||||
onWillQuitCleanup: () => void;
|
||||
shouldRestoreWindowsOnActivate: () => boolean;
|
||||
@@ -34,6 +35,7 @@ export function createAppLifecycleRuntimeRunner(
|
||||
handleCliCommand: params.handleCliCommand,
|
||||
printHelp: params.printHelp,
|
||||
logNoRunningInstance: params.logNoRunningInstance,
|
||||
startControlServer: params.startControlServer,
|
||||
onReady: params.onReady,
|
||||
onWillQuitCleanup: params.onWillQuitCleanup,
|
||||
shouldRestoreWindowsOnActivate: params.shouldRestoreWindowsOnActivate,
|
||||
|
||||
@@ -156,3 +156,11 @@ test('discord presence update interval displays seconds while saving millisecond
|
||||
assert.equal(toSettingsDisplayValue(path, 3000), 3);
|
||||
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');
|
||||
});
|
||||
|
||||
@@ -75,6 +75,9 @@ export function toSettingsDisplayValue(
|
||||
path: string,
|
||||
value: ConfigSettingsSnapshotValue,
|
||||
): ConfigSettingsSnapshotValue {
|
||||
if (path === 'websocket.enabled' && typeof value === 'boolean') {
|
||||
return value ? 'true' : 'false';
|
||||
}
|
||||
if (path === 'discordPresence.updateIntervalMs' && typeof value === 'number') {
|
||||
return value / 1000;
|
||||
}
|
||||
@@ -85,6 +88,10 @@ export function toConfigDraftValue(
|
||||
path: string,
|
||||
value: ConfigSettingsSnapshotValue,
|
||||
): ConfigSettingsSnapshotValue {
|
||||
if (path === 'websocket.enabled') {
|
||||
if (value === 'true') return true;
|
||||
if (value === 'false') return false;
|
||||
}
|
||||
if (path === 'discordPresence.updateIntervalMs' && typeof value === 'number') {
|
||||
return Math.round(value * 1000);
|
||||
}
|
||||
|
||||
@@ -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' });
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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',
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user