diff --git a/launcher/commands/playback-command.test.ts b/launcher/commands/playback-command.test.ts index f85f5cae..c6102186 100644 --- a/launcher/commands/playback-command.test.ts +++ b/launcher/commands/playback-command.test.ts @@ -1,6 +1,9 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { EventEmitter } from 'node:events'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; import type { LauncherCommandContext } from './context.js'; import { runPlaybackCommandWithDeps } from './playback-command.js'; import { state } from '../mpv.js'; @@ -271,6 +274,68 @@ test('plugin auto-start playback attaches a warm background app through the laun ); }); +test('plugin auto-start attach mode reuses launcher-resolved config dir for app control', async () => { + const context = createContext(); + const originalXdgConfigHome = process.env.XDG_CONFIG_HOME; + const xdgConfigHome = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-test-xdg-')); + const expectedConfigDir = path.join(xdgConfigHome, 'SubMiner'); + fs.mkdirSync(expectedConfigDir, { recursive: true }); + fs.writeFileSync(path.join(expectedConfigDir, 'config.jsonc'), '{}'); + context.args = { + ...context.args, + target: '/tmp/movie.mkv', + targetKind: 'file', + useTexthooker: true, + }; + context.pluginRuntimeConfig = { + socketPath: '/tmp/subminer.sock', + binaryPath: '', + backend: 'auto', + autoStart: true, + autoStartVisibleOverlay: true, + autoStartPauseUntilReady: true, + texthookerEnabled: true, + aniskipEnabled: true, + aniskipButtonKey: 'TAB', + }; + let availabilityConfigDir: string | undefined; + let overlayConfigDir: string | undefined; + + try { + process.env.XDG_CONFIG_HOME = xdgConfigHome; + + await runPlaybackCommandWithDeps(context, { + ensurePlaybackSetupReady: async () => {}, + chooseTarget: async () => ({ target: context.args.target, kind: 'file' }), + checkDependencies: () => {}, + registerCleanup: () => {}, + startMpv: async () => {}, + waitForUnixSocketReady: async () => true, + startOverlay: async (_appPath, _args, _socketPath, _extraAppArgs = [], configDir) => { + overlayConfigDir = configDir; + }, + launchAppCommandDetached: () => {}, + log: () => {}, + cleanupPlaybackSession: async () => {}, + getMpvProc: () => null, + isAppControlServerAvailable: async (_logLevel, configDir) => { + availabilityConfigDir = configDir; + return true; + }, + }); + + assert.equal(availabilityConfigDir, expectedConfigDir); + assert.equal(overlayConfigDir, expectedConfigDir); + } finally { + if (originalXdgConfigHome === undefined) { + delete process.env.XDG_CONFIG_HOME; + } else { + process.env.XDG_CONFIG_HOME = originalXdgConfigHome; + } + fs.rmSync(xdgConfigHome, { recursive: true, force: true }); + } +}); + test('plugin auto-start attach mode omits texthooker flag when CLI texthooker is disabled', async () => { const context = createContext(); context.args = { diff --git a/launcher/commands/playback-command.ts b/launcher/commands/playback-command.ts index 5aeb62f0..db216989 100644 --- a/launcher/commands/playback-command.ts +++ b/launcher/commands/playback-command.ts @@ -30,6 +30,13 @@ import { hasLauncherExternalYomitanProfileConfig } from '../config.js'; const SETUP_WAIT_TIMEOUT_MS = 10 * 60 * 1000; const SETUP_POLL_INTERVAL_MS = 500; +function getLauncherConfigDir(): string { + return getDefaultConfigDir({ + xdgConfigHome: process.env.XDG_CONFIG_HOME, + homeDir: os.homedir(), + }); +} + function checkDependencies(args: Args): void { const missing: string[] = []; @@ -100,10 +107,7 @@ async function ensurePlaybackSetupReady(context: LauncherCommandContext): Promis const { args, appPath } = context; if (!appPath) return; - const configDir = getDefaultConfigDir({ - xdgConfigHome: process.env.XDG_CONFIG_HOME, - homeDir: os.homedir(), - }); + const configDir = getLauncherConfigDir(); const statePath = getSetupStatePath(configDir); const ready = await ensureLauncherSetupReady({ readSetupState: () => readSetupState(statePath), @@ -166,7 +170,7 @@ type PlaybackCommandDeps = { waitForUnixSocketReady: typeof waitForUnixSocketReady; startOverlay: typeof startOverlay; launchAppCommandDetached: typeof launchAppCommandDetached; - isAppControlServerAvailable?: (logLevel: Args['logLevel']) => Promise; + isAppControlServerAvailable?: (logLevel: Args['logLevel'], configDir: string) => Promise; log: typeof log; cleanupPlaybackSession: typeof cleanupPlaybackSession; getMpvProc: () => typeof state.mpvProc; @@ -211,6 +215,7 @@ export async function runPlaybackCommandWithDeps( const isYoutubeUrl = selectedTarget.kind === 'url' && isYoutubeTarget(selectedTarget.target); const isAppOwnedYoutubeFlow = isYoutubeUrl; const youtubeMode = args.youtubeMode ?? 'download'; + const configDir = getLauncherConfigDir(); if (isYoutubeUrl) { deps.log('info', args.logLevel, 'YouTube subtitle flow: app-owned picker after mpv bootstrap'); @@ -222,7 +227,7 @@ export async function runPlaybackCommandWithDeps( !args.startOverlay && !args.autoStartOverlay && !isAppOwnedYoutubeFlow && - ((await deps.isAppControlServerAvailable?.(args.logLevel)) ?? false); + ((await deps.isAppControlServerAvailable?.(args.logLevel, configDir)) ?? false); const effectivePluginRuntimeConfig = shouldLauncherAttachRunningApp ? { ...pluginRuntimeConfig, autoStart: false } : pluginRuntimeConfig; @@ -287,7 +292,7 @@ export async function runPlaybackCommandWithDeps( : []), ] : []; - await deps.startOverlay(appPath, args, mpvSocketPath, extraAppArgs); + await deps.startOverlay(appPath, args, mpvSocketPath, extraAppArgs, configDir); } else if (pluginAutoStartEnabled) { if (ready) { deps.log('info', args.logLevel, 'MPV IPC socket ready, relying on mpv plugin auto-start'); diff --git a/launcher/config/args-normalizer.ts b/launcher/config/args-normalizer.ts index e828cba7..e7f87b26 100644 --- a/launcher/config/args-normalizer.ts +++ b/launcher/config/args-normalizer.ts @@ -313,7 +313,10 @@ export function applyInvocationsToArgs(parsed: Args, invocations: CliInvocations const action = (invocations.configInvocation.action || '').toLowerCase(); if (action === 'path') parsed.configPath = true; else if (action === 'show') parsed.configShow = true; - else fail(`Unknown config action: ${invocations.configInvocation.action || '(none)'}. Expected path or show.`); + else + fail( + `Unknown config action: ${invocations.configInvocation.action || '(none)'}. Expected path or show.`, + ); } if (invocations.settingsInvocation) { diff --git a/launcher/mpv.test.ts b/launcher/mpv.test.ts index 86d8920c..18ca0ecc 100644 --- a/launcher/mpv.test.ts +++ b/launcher/mpv.test.ts @@ -6,6 +6,7 @@ import os from 'node:os'; import net from 'node:net'; import { EventEmitter } from 'node:events'; import type { Args } from './types'; +import { getAppControlSocketPath } from '../src/shared/app-control'; import { buildConfiguredMpvDefaultArgs, buildMpvBackendArgs, @@ -826,6 +827,87 @@ test('startOverlay attaches through the running app control socket without spawn } }); +test('startOverlay uses caller config dir for app control socket discovery', async () => { + if (process.platform === 'win32') return; + + const { dir, socketPath } = createTempSocketPath(); + const configDir = path.join(dir, 'launcher-config'); + const controlSocketPath = getAppControlSocketPath({ configDir, platform: 'linux' }); + 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 newlineIndex = buffer.indexOf('\n'); + if (newlineIndex < 0) return; + const payload = JSON.parse(buffer.slice(0, newlineIndex)) 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 { + delete process.env.SUBMINER_APP_CONTROL_SOCKET; + 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, [], configDir); + + const invocationText = fs.existsSync(appInvocationsPath) + ? fs.readFileSync(appInvocationsPath, 'utf8') + : ''; + assert.equal(invocationText, ''); + assert.equal(receivedControlArgv.length, 1); + assert.deepEqual(receivedControlArgv[0]?.slice(0, 6), [ + '--start', + '--managed-playback', + '--backend', + 'x11', + '--socket', + socketPath, + ]); + } 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 falls back to legacy app startup when control command fails', async () => { if (process.platform === 'win32') return; diff --git a/launcher/mpv.ts b/launcher/mpv.ts index a3a584eb..643b9019 100644 --- a/launcher/mpv.ts +++ b/launcher/mpv.ts @@ -1006,6 +1006,7 @@ export async function startOverlay( args: Args, socketPath: string, extraAppArgs: string[] = [], + configDir: string = getLauncherConfigDir(), ): Promise { const backend = detectBackend(args.backend); log('info', args.logLevel, `Starting SubMiner overlay (backend: ${backend})...`); @@ -1024,7 +1025,7 @@ export async function startOverlay( if (args.useTexthooker) overlayArgs.push('--texthooker'); const controlResult = await sendAppControlCommand(overlayArgs, { - configDir: getLauncherConfigDir(), + configDir, }); if (controlResult.ok) { log('debug', args.logLevel, 'Attached to running SubMiner app via control socket'); @@ -1107,9 +1108,12 @@ function getLauncherConfigDir(): string { }); } -export async function isRunningAppControlServerAvailable(logLevel: LogLevel): Promise { +export async function isRunningAppControlServerAvailable( + logLevel: LogLevel, + configDir: string = getLauncherConfigDir(), +): Promise { const available = await checkAppControlServerAvailable({ - configDir: getLauncherConfigDir(), + configDir, }); if (available) { log('debug', logLevel, 'Running SubMiner app control socket detected'); diff --git a/src/core/services/mpv.test.ts b/src/core/services/mpv.test.ts index 1ca76ffd..184193b1 100644 --- a/src/core/services/mpv.test.ts +++ b/src/core/services/mpv.test.ts @@ -171,7 +171,11 @@ test('MpvIpcClient connect logs connect-request at debug level', () => { 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 connectionChanges: boolean[] = []; const resolved: unknown[] = []; + client.on('connection-change', ({ connected }) => { + connectionChanges.push(connected); + }); (client as any).connected = true; (client as any).connecting = false; (client as any).socket = {}; @@ -191,6 +195,7 @@ test('MpvIpcClient reconnect clears stale connected state and starts a fresh tra assert.equal(client.connected, false); assert.equal((client as any).connecting, true); assert.equal((client as any).socket, null); + assert.deepEqual(connectionChanges, [false]); assert.deepEqual(resolved, [{ request_id: 10, error: 'disconnected' }]); }); diff --git a/src/core/services/mpv.ts b/src/core/services/mpv.ts index 46f81475..bbabf357 100644 --- a/src/core/services/mpv.ts +++ b/src/core/services/mpv.ts @@ -277,11 +277,15 @@ export class MpvIpcClient implements MpvClient { reconnect(): void { logger.debug('MPV IPC reconnect requested.'); + const wasConnected = this.connected; this.transport.shutdown(); this.connected = false; this.connecting = false; this.socket = null; this.playbackPaused = null; + if (wasConnected) { + this.emit('connection-change', { connected: false }); + } this.failPendingRequests(); this.connect(); } diff --git a/src/main/runtime/app-control-server.test.ts b/src/main/runtime/app-control-server.test.ts index cbe0581f..ac371294 100644 --- a/src/main/runtime/app-control-server.test.ts +++ b/src/main/runtime/app-control-server.test.ts @@ -1,5 +1,7 @@ import assert from 'node:assert/strict'; +import { EventEmitter } from 'node:events'; import fs from 'node:fs'; +import net from 'node:net'; import os from 'node:os'; import path from 'node:path'; import test from 'node:test'; @@ -13,9 +15,7 @@ async function waitForSocketPath(socketPath: string): Promise { if (fs.existsSync(socketPath)) return; await new Promise((resolve) => setTimeout(resolve, 10)); } - throw new Error( - `Timed out waiting for control socket ${socketPath} after ${timeoutMs}ms`, - ); + throw new Error(`Timed out waiting for control socket ${socketPath} after ${timeoutMs}ms`); } test('app control server dispatches argv requests and replies ok', async () => { @@ -62,9 +62,12 @@ test('app control server rejects requests larger than 64KB by UTF-8 byte length' try { await waitForSocketPath(socketPath); - const result = await sendAppControlCommand(Array.from({ length: 4 }, () => 'あ'.repeat(6000)), { - socketPath, - }); + const result = await sendAppControlCommand( + Array.from({ length: 4 }, () => 'あ'.repeat(6000)), + { + socketPath, + }, + ); assert.deepEqual(result, { ok: false, error: 'App control request too large' }); assert.deepEqual(received, []); @@ -73,3 +76,56 @@ test('app control server rejects requests larger than 64KB by UTF-8 byte length' fs.rmSync(dir, { recursive: true, force: true }); } }); + +test('app control server logs and closes errored client sockets', () => { + const originalCreateServer = net.createServer; + let socketHandler: ((socket: net.Socket) => void) | null = null; + const fakeServer = new EventEmitter() as net.Server; + fakeServer.listen = (() => fakeServer) as net.Server['listen']; + fakeServer.close = ((callback?: (err?: Error) => void) => { + callback?.(); + return fakeServer; + }) as net.Server['close']; + const received: string[][] = []; + const warnings: Array<{ message: string; error?: unknown }> = []; + + try { + net.createServer = ((handler?: (socket: net.Socket) => void) => { + socketHandler = handler ?? null; + return fakeServer; + }) as typeof net.createServer; + + const server = startAppControlServer({ + socketPath: '\\\\.\\pipe\\subminer-test-control', + platform: 'win32', + handleArgv: (argv) => { + received.push(argv); + }, + logWarn: (message, error) => { + warnings.push({ message, error }); + }, + }); + + const error = new Error('client reset'); + let destroyed = false; + const socket = new EventEmitter() as net.Socket; + socket.destroy = (() => { + destroyed = true; + return socket; + }) as net.Socket['destroy']; + + const handler = socketHandler as ((socket: net.Socket) => void) | null; + assert.ok(handler); + handler(socket); + socket.emit('error', error); + socket.emit('data', Buffer.from('{"argv":["--start"]}\n')); + + assert.equal(destroyed, true); + assert.deepEqual(received, []); + assert.deepEqual(warnings, [{ message: 'App control client socket error.', error }]); + + server.close(); + } finally { + net.createServer = originalCreateServer; + } +}); diff --git a/src/main/runtime/app-control-server.ts b/src/main/runtime/app-control-server.ts index 5b11a7b3..a15e1496 100644 --- a/src/main/runtime/app-control-server.ts +++ b/src/main/runtime/app-control-server.ts @@ -47,6 +47,13 @@ export function startAppControlServer(options: AppControlServerOptions): AppCont let byteCount = 0; let handled = false; + socket.on('error', (error) => { + if (handled) return; + handled = true; + options.logWarn?.('App control client socket error.', error); + socket.destroy(); + }); + socket.on('data', (chunk) => { if (handled) return; byteCount += chunk.length;