diff --git a/README.md b/README.md index 41d04cc9..c406c88c 100644 --- a/README.md +++ b/README.md @@ -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.
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.
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.
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 WebSocket - Annotated subtitle feed for external clients (texthooker pages, custom tools) + Plain subtitle feed plus a dedicated annotated feed for texthooker pages and custom tools @@ -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 |
Platform-specific install commands @@ -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 diff --git a/changes/background-launcher-reuse.md b/changes/background-launcher-reuse.md index 14938080..8956e279 100644 --- a/changes/background-launcher-reuse.md +++ b/changes/background-launcher-reuse.md @@ -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. diff --git a/changes/hide-setup-runtime-plugin.md b/changes/hide-setup-runtime-plugin.md new file mode 100644 index 00000000..2274043d --- /dev/null +++ b/changes/hide-setup-runtime-plugin.md @@ -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. diff --git a/changes/plain-websocket-annotations.md b/changes/plain-websocket-annotations.md new file mode 100644 index 00000000..c883df74 --- /dev/null +++ b/changes/plain-websocket-annotations.md @@ -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. diff --git a/changes/setup-subminer-settings-button.md b/changes/setup-subminer-settings-button.md new file mode 100644 index 00000000..8d0b9195 --- /dev/null +++ b/changes/setup-subminer-settings-button.md @@ -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. diff --git a/config.example.jsonc b/config.example.jsonc index 5d6059ad..393e2f29 100644 --- a/config.example.jsonc +++ b/config.example.jsonc @@ -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. diff --git a/docs-site/configuration.md b/docs-site/configuration.md index a192951d..aba9ea72 100644 --- a/docs-site/configuration.md +++ b/docs-site/configuration.md @@ -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`) | diff --git a/docs-site/public/config.example.jsonc b/docs-site/public/config.example.jsonc index 5d6059ad..393e2f29 100644 --- a/docs-site/public/config.example.jsonc +++ b/docs-site/public/config.example.jsonc @@ -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. diff --git a/docs-site/subtitle-sidebar.md b/docs-site/subtitle-sidebar.md index 44df6feb..d0ea946e 100644 --- a/docs-site/subtitle-sidebar.md +++ b/docs-site/subtitle-sidebar.md @@ -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` | diff --git a/docs-site/websocket-texthooker-api.md b/docs-site/websocket-texthooker-api.md index 44fb9066..ae9b5b3b 100644 --- a/docs-site/websocket-texthooker-api.md +++ b/docs-site/websocket-texthooker-api.md @@ -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 `
`. 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 `` 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: diff --git a/launcher/commands/playback-command.test.ts b/launcher/commands/playback-command.test.ts index 23365e37..ec5a93f6 100644 --- a/launcher/commands/playback-command.test.ts +++ b/launcher/commands/playback-command.test.ts @@ -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[] = []; + + 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); + } + }, + 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[1] & { + isAppControlServerAvailable: () => Promise; + }); + + 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, + ); +}); diff --git a/launcher/commands/playback-command.ts b/launcher/commands/playback-command.ts index e45ed7d9..e2a82550 100644 --- a/launcher/commands/playback-command.ts +++ b/launcher/commands/playback-command.ts @@ -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; 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'); diff --git a/launcher/mpv.test.ts b/launcher/mpv.test.ts index 2e1a621b..0b2beda7 100644 --- a/launcher/mpv.test.ts +++ b/launcher/mpv.test.ts @@ -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((resolve, reject) => { + mpvServer.once('error', reject); + mpvServer.listen(socketPath, resolve); + }); + await new Promise((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((resolve) => mpvServer.close(() => resolve())); + await new Promise((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'); diff --git a/launcher/mpv.ts b/launcher/mpv.ts index f8298bfc..ba95ea9f 100644 --- a/launcher/mpv.ts +++ b/launcher/mpv.ts @@ -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 { 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 { + 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; diff --git a/launcher/smoke.e2e.test.ts b/launcher/smoke.e2e.test.ts index 6aee89c6..e9b3201d 100644 --- a/launcher/smoke.e2e.test.ts +++ b/launcher/smoke.e2e.test.ts @@ -238,6 +238,84 @@ async function waitForJsonLines( } } +async function waitForFile(filePath: string, timeoutMs = 1500): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (fs.existsSync(filePath)) return; + await new Promise((resolve) => setTimeout(resolve, 50)); + } +} + +async function startFakeControlServer( + smokeCase: SmokeCase, +): Promise<{ socketPath: string; logPath: string; stop: () => Promise }> { + 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((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(); + } }); }, ); diff --git a/plugin/subminer/process.lua b/plugin/subminer/process.lua index 1306ffbe..b0fcf3cc 100644 --- a/plugin/subminer/process.lua +++ b/plugin/subminer/process.lua @@ -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(...) diff --git a/scripts/test-plugin-start-gate.lua b/scripts/test-plugin-start-gate.lua index adb1511b..fbad4042 100644 --- a/scripts/test-plugin-start-gate.lua +++ b/scripts/test-plugin-start-gate.lua @@ -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" diff --git a/src/config/config.test.ts b/src/config/config.test.ts index ff80ac77..9e975e30 100644 --- a/src/config/config.test.ts +++ b/src/config/config.test.ts @@ -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); diff --git a/src/config/definitions/defaults-subtitle.ts b/src/config/definitions/defaults-subtitle.ts index f3436bfd..94a487c6 100644 --- a/src/config/definitions/defaults-subtitle.ts +++ b/src/config/definitions/defaults-subtitle.ts @@ -69,7 +69,7 @@ export const SUBTITLE_DEFAULT_CONFIG: Pick { + 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( diff --git a/src/config/settings/registry.ts b/src/config/settings/registry.ts index 093a5668..5ae21dcd 100644 --- a/src/config/settings/registry.ts +++ b/src/config/settings/registry.ts @@ -247,6 +247,8 @@ const DESCRIPTION_OVERRIDES: Record = { '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.', }; diff --git a/src/core/services/app-lifecycle.test.ts b/src/core/services/app-lifecycle.test.ts index 2e8715b9..651f292e 100644 --- a/src/core/services/app-lifecycle.test.ts +++ b/src/core/services/app-lifecycle.test.ts @@ -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) | null = null; + let releaseReady: (() => void) | null = null; + const readyFinished = new Promise((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)(); + 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({ diff --git a/src/core/services/app-lifecycle.ts b/src/core/services/app-lifecycle.ts index 59e204d9..cabf8f5c 100644 --- a/src/core/services/app-lifecycle.ts +++ b/src/core/services/app-lifecycle.ts @@ -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; 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; 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(); }); diff --git a/src/core/services/cli-command.test.ts b/src/core/services/cli-command.test.ts index 07f28d0c..73455a44 100644 --- a/src/core/services/cli-command.test.ts +++ b/src/core/services/cli-command.test.ts @@ -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 { 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, diff --git a/src/core/services/cli-command.ts b/src/core/services/cli-command.ts index 5cf22329..a1041ccd 100644 --- a/src/core/services/cli-command.ts +++ b/src/core/services/cli-command.ts @@ -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(), diff --git a/src/core/services/mpv-transport.test.ts b/src/core/services/mpv-transport.test.ts index b602b61c..33cedf56 100644 --- a/src/core/services/mpv-transport.test.ts +++ b/src/core/services/mpv-transport.test.ts @@ -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]); }); diff --git a/src/core/services/mpv-transport.ts b/src/core/services/mpv-transport.ts index 02aa5dd9..17fedd0c 100644 --- a/src/core/services/mpv-transport.ts +++ b/src/core/services/mpv-transport.ts @@ -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 { diff --git a/src/core/services/mpv.test.ts b/src/core/services/mpv.test.ts index f243674f..1ca76ffd 100644 --- a/src/core/services/mpv.test.ts +++ b/src/core/services/mpv.test.ts @@ -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[] = []; diff --git a/src/core/services/mpv.ts b/src/core/services/mpv.ts index 19685ebe..46f81475 100644 --- a/src/core/services/mpv.ts +++ b/src/core/services/mpv.ts @@ -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, diff --git a/src/core/services/subtitle-ws.test.ts b/src/core/services/subtitle-ws.test.ts index 011594f5..b8a1312a 100644 --- a/src/core/services/subtitle-ws.test.ts +++ b/src/core/services/subtitle-ws.test.ts @@ -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', diff --git a/src/core/services/subtitle-ws.ts b/src/core/services/subtitle-ws.ts index 3338ef62..b8cb48ed 100644 --- a/src/core/services/subtitle-ws.ts +++ b/src/core/services/subtitle-ws.ts @@ -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', '
'), + 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) { diff --git a/src/main.ts b/src/main.ts index 8321e1dd..d77c3143 100644 --- a/src/main.ts +++ b/src/main.ts @@ -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: () => diff --git a/src/main/app-lifecycle.ts b/src/main/app-lifecycle.ts index dc907a72..29b24823 100644 --- a/src/main/app-lifecycle.ts +++ b/src/main/app-lifecycle.ts @@ -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; 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, diff --git a/src/main/boot/services.test.ts b/src/main/boot/services.test.ts index dad13311..3725aa6f 100644 --- a/src/main/boot/services.test.ts +++ b/src/main/boot/services.test.ts @@ -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, diff --git a/src/main/boot/services.ts b/src/main/boot/services.ts index f4e796ae..dd80ec7a 100644 --- a/src/main/boot/services.ts +++ b/src/main/boot/services.ts @@ -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(); diff --git a/src/main/runtime/app-control-server.test.ts b/src/main/runtime/app-control-server.test.ts new file mode 100644 index 00000000..737bf2f4 --- /dev/null +++ b/src/main/runtime/app-control-server.test.ts @@ -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 { + const deadline = Date.now() + 1000; + while (Date.now() < deadline) { + if (fs.existsSync(socketPath)) return; + await new Promise((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 }); + } +}); diff --git a/src/main/runtime/app-control-server.ts b/src/main/runtime/app-control-server.ts new file mode 100644 index 00000000..ea45f189 --- /dev/null +++ b/src/main/runtime/app-control-server.ts @@ -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); + }, + }; +} diff --git a/src/main/runtime/first-run-setup-window.test.ts b/src/main/runtime/first-run-setup-window.test.ts index 537e8689..e62dc335 100644 --- a/src/main/runtime/first-run-setup-window.test.ts +++ b/src/main/runtime/first-run-setup-window.test.ts @@ -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*
${renderStatusBadge(model.configReady ? 'Ready' : 'Missing', model.configReady ? 'ready' : 'danger')}
-
-
- mpv runtime plugin -
${escapeHtml(model.pluginInstallPathSummary ?? 'Default mpv scripts location')}
-
Managed mpv launches use the bundled runtime plugin.
-
- ${renderStatusBadge(pluginLabel, pluginTone)} -
Yomitan dictionaries @@ -544,6 +529,7 @@ export function buildFirstRunSetupHtml(model: FirstRunSetupHtmlModel): string {
+
${model.message ? escapeHtml(model.message) : ''}
@@ -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; 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; markSetupCancelled: () => Promise; isSetupCompleted: () => boolean; @@ -680,6 +669,9 @@ export function createOpenFirstRunSetupWindowHandler< } return; } + if (result?.skipRender) { + return; + } if (!setupWindow.isDestroyed()) { await render(); } diff --git a/src/main/runtime/local-subtitle-selection.test.ts b/src/main/runtime/local-subtitle-selection.test.ts index a03dba0b..cf55a0a0 100644 --- a/src/main/runtime/local-subtitle-selection.test.ts +++ b/src/main/runtime/local-subtitle-selection.test.ts @@ -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> = []; + 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], + ]); +}); diff --git a/src/main/runtime/local-subtitle-selection.ts b/src/main/runtime/local-subtitle-selection.ts index 7594327f..fa35bbfc 100644 --- a/src/main/runtime/local-subtitle-selection.ts +++ b/src/main/runtime/local-subtitle-selection.ts @@ -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 | 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 => { @@ -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) { diff --git a/src/main/runtime/mpv-main-event-bindings.test.ts b/src/main/runtime/mpv-main-event-bindings.test.ts index 7b0ce30f..8c452428 100644 --- a/src/main/runtime/mpv-main-event-bindings.test.ts +++ b/src/main/runtime/mpv-main-event-bindings.test.ts @@ -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 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')); +}); diff --git a/src/main/runtime/mpv-main-event-bindings.ts b/src/main/runtime/mpv-main-event-bindings.ts index b1bf530d..65c59fb5 100644 --- a/src/main/runtime/mpv-main-event-bindings.ts +++ b/src/main/runtime/mpv-main-event-bindings.ts @@ -101,6 +101,8 @@ export function createBindMpvMainEventHandlersHandler(deps: { }): void => { if (connected) { deps.resetSubtitleSidebarEmbeddedLayout(); + } else { + deps.updateCurrentMediaPath(''); } handleMpvConnectionChange({ connected }); }; diff --git a/src/main/runtime/startup-lifecycle-main-deps.ts b/src/main/runtime/startup-lifecycle-main-deps.ts index ebe63c4d..f02f8f69 100644 --- a/src/main/runtime/startup-lifecycle-main-deps.ts +++ b/src/main/runtime/startup-lifecycle-main-deps.ts @@ -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, diff --git a/src/main/runtime/startup-tray-policy.test.ts b/src/main/runtime/startup-tray-policy.test.ts index 2234ea04..d487183d 100644 --- a/src/main/runtime/startup-tray-policy.test.ts +++ b/src/main/runtime/startup-tray-policy.test.ts @@ -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, + ); +}); diff --git a/src/main/runtime/startup-tray-policy.ts b/src/main/runtime/startup-tray-policy.ts index 97d8f0ef..54385c8b 100644 --- a/src/main/runtime/startup-tray-policy.ts +++ b/src/main/runtime/startup-tray-policy.ts @@ -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; } diff --git a/src/main/runtime/tray-main-actions.test.ts b/src/main/runtime/tray-main-actions.test.ts index f330b5b0..a3f37872 100644 --- a/src/main/runtime/tray-main-actions.test.ts +++ b/src/main/runtime/tray-main-actions.test.ts @@ -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, + ); +}); diff --git a/src/main/runtime/tray-main-actions.ts b/src/main/runtime/tray-main-actions.ts index 548cd88d..94552903 100644 --- a/src/main/runtime/tray-main-actions.ts +++ b/src/main/runtime/tray-main-actions.ts @@ -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(deps: { buildTrayMenuTemplateRuntime: (handlers: { openSessionHelp: () => void; @@ -36,7 +45,6 @@ export function createBuildTrayMenuTemplateHandler(deps: { openWindowsMpvLauncherSetup: () => void; showWindowsMpvLauncherSetup: boolean; openYomitanSettings: () => void; - openRuntimeOptions: () => void; openConfigSettings: () => void; openJellyfinSetup: () => void; showJellyfinDiscovery: boolean; @@ -55,7 +63,6 @@ export function createBuildTrayMenuTemplateHandler(deps: { openFirstRunSetupWindow: () => void; showWindowsMpvLauncherSetup: () => boolean; openYomitanSettings: () => void; - openRuntimeOptionsPalette: () => void; openConfigSettingsWindow: () => void; openJellyfinSetupWindow: () => void; isJellyfinConfigured: () => boolean; @@ -88,12 +95,6 @@ export function createBuildTrayMenuTemplateHandler(deps: { openYomitanSettings: () => { deps.openYomitanSettings(); }, - openRuntimeOptions: () => { - if (!deps.isOverlayRuntimeInitialized()) { - deps.initializeOverlayRuntime(); - } - deps.openRuntimeOptionsPalette(); - }, openConfigSettings: () => { deps.openConfigSettingsWindow(); }, diff --git a/src/main/runtime/tray-main-deps.test.ts b/src/main/runtime/tray-main-deps.test.ts index 6eb92daa..8206c999 100644 --- a/src/main/runtime/tray-main-deps.test.ts +++ b/src/main/runtime/tray-main-deps.test.ts @@ -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, diff --git a/src/main/runtime/tray-main-deps.ts b/src/main/runtime/tray-main-deps.ts index 0279c5b6..bea49cd4 100644 --- a/src/main/runtime/tray-main-deps.ts +++ b/src/main/runtime/tray-main-deps.ts @@ -35,7 +35,6 @@ export function createBuildTrayMenuTemplateMainDepsHandler(deps: { openWindowsMpvLauncherSetup: () => void; showWindowsMpvLauncherSetup: boolean; openYomitanSettings: () => void; - openRuntimeOptions: () => void; openConfigSettings: () => void; openJellyfinSetup: () => void; showJellyfinDiscovery: boolean; @@ -54,7 +53,6 @@ export function createBuildTrayMenuTemplateMainDepsHandler(deps: { openFirstRunSetupWindow: () => void; showWindowsMpvLauncherSetup: () => boolean; openYomitanSettings: () => void; - openRuntimeOptionsPalette: () => void; openConfigSettingsWindow: () => void; openJellyfinSetupWindow: () => void; isJellyfinConfigured: () => boolean; @@ -75,7 +73,6 @@ export function createBuildTrayMenuTemplateMainDepsHandler(deps: { openFirstRunSetupWindow: deps.openFirstRunSetupWindow, showWindowsMpvLauncherSetup: deps.showWindowsMpvLauncherSetup, openYomitanSettings: deps.openYomitanSettings, - openRuntimeOptionsPalette: deps.openRuntimeOptionsPalette, openConfigSettingsWindow: deps.openConfigSettingsWindow, openJellyfinSetupWindow: deps.openJellyfinSetupWindow, isJellyfinConfigured: deps.isJellyfinConfigured, diff --git a/src/main/runtime/tray-runtime-handlers.test.ts b/src/main/runtime/tray-runtime-handlers.test.ts index f49bac67..db02249f 100644 --- a/src/main/runtime/tray-runtime-handlers.test.ts +++ b/src/main/runtime/tray-runtime-handlers.test.ts @@ -31,7 +31,6 @@ test('tray runtime handlers compose resolve/menu/ensure/destroy handlers', () => openFirstRunSetupWindow: () => {}, showWindowsMpvLauncherSetup: () => true, openYomitanSettings: () => {}, - openRuntimeOptionsPalette: () => {}, openConfigSettingsWindow: () => {}, openJellyfinSetupWindow: () => {}, isJellyfinConfigured: () => false, diff --git a/src/main/runtime/tray-runtime.test.ts b/src/main/runtime/tray-runtime.test.ts index 28f089ab..f79ae128 100644 --- a/src/main/runtime/tray-runtime.test.ts +++ b/src/main/runtime/tray-runtime.test.ts @@ -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, diff --git a/src/main/runtime/tray-runtime.ts b/src/main/runtime/tray-runtime.ts index 98c2443d..cbb40844 100644 --- a/src/main/runtime/tray-runtime.ts +++ b/src/main/runtime/tray-runtime.ts @@ -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, }, { diff --git a/src/main/startup-lifecycle.ts b/src/main/startup-lifecycle.ts index 44447266..df75eaa6 100644 --- a/src/main/startup-lifecycle.ts +++ b/src/main/startup-lifecycle.ts @@ -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; 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, diff --git a/src/settings/settings-model.test.ts b/src/settings/settings-model.test.ts index 2f55d464..de3a3063 100644 --- a/src/settings/settings-model.test.ts +++ b/src/settings/settings-model.test.ts @@ -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'); +}); diff --git a/src/settings/settings-model.ts b/src/settings/settings-model.ts index 69d58daf..f2876638 100644 --- a/src/settings/settings-model.ts +++ b/src/settings/settings-model.ts @@ -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); } diff --git a/src/shared/app-control-client.ts b/src/shared/app-control-client.ts new file mode 100644 index 00000000..c2e72fe2 --- /dev/null +++ b/src/shared/app-control-client.ts @@ -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 { + const socketPath = resolveSocketPath(options); + const timeoutMs = options.timeoutMs ?? 350; + + return new Promise((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 { + const socketPath = resolveSocketPath(options); + const timeoutMs = options.timeoutMs ?? 1000; + + return new Promise((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' }); + }); + }); +} diff --git a/src/shared/app-control.ts b/src/shared/app-control.ts new file mode 100644 index 00000000..33a880c9 --- /dev/null +++ b/src/shared/app-control.ts @@ -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', + }; +}