diff --git a/README.md b/README.md index c406c88c..71754a35 100644 --- a/README.md +++ b/README.md @@ -110,7 +110,7 @@ Browse sibling episode files and the active mpv queue in one overlay modal. Open ## Requirements -Only **mpv** and Anki+AnkiConnect is required. Everything else is optional but enhances the experience +Only **mpv** and Anki+AnkiConnect are required. Everything else is optional but enhances the experience. | Dependency | Status | What it does | | -------------------- | ----------- | ---------------------------------------- | diff --git a/launcher/commands/playback-command.test.ts b/launcher/commands/playback-command.test.ts index c3634464..f85f5cae 100644 --- a/launcher/commands/playback-command.test.ts +++ b/launcher/commands/playback-command.test.ts @@ -151,6 +151,7 @@ test('plugin auto-start playback leaves app lifetime to managed-playback owner', ...context.args, target: '/tmp/movie.mkv', targetKind: 'file', + useTexthooker: true, }; context.pluginRuntimeConfig = { socketPath: '/tmp/subminer.sock', @@ -213,6 +214,7 @@ test('plugin auto-start playback attaches a warm background app through the laun ...context.args, target: '/tmp/movie.mkv', targetKind: 'file', + useTexthooker: true, }; context.pluginRuntimeConfig = { socketPath: '/tmp/subminer.sock', @@ -268,3 +270,47 @@ test('plugin auto-start playback attaches a warm background app through the laun false, ); }); + +test('plugin auto-start attach mode omits texthooker flag when CLI texthooker is disabled', 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[] = []; + + await runPlaybackCommandWithDeps(context, { + ensurePlaybackSetupReady: async () => {}, + chooseTarget: async () => ({ target: context.args.target, kind: 'file' }), + checkDependencies: () => {}, + registerCleanup: () => {}, + startMpv: async () => { + calls.push('startMpv'); + }, + 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']); +}); diff --git a/launcher/commands/playback-command.ts b/launcher/commands/playback-command.ts index 86d8a9b3..5aeb62f0 100644 --- a/launcher/commands/playback-command.ts +++ b/launcher/commands/playback-command.ts @@ -282,7 +282,9 @@ export async function runPlaybackCommandWithDeps( pluginRuntimeConfig.autoStartVisibleOverlay ? '--show-visible-overlay' : '--hide-visible-overlay', - ...(pluginRuntimeConfig.texthookerEnabled ? ['--texthooker'] : []), + ...(args.useTexthooker && effectivePluginRuntimeConfig.texthookerEnabled + ? ['--texthooker'] + : []), ] : []; await deps.startOverlay(appPath, args, mpvSocketPath, extraAppArgs); diff --git a/launcher/mpv.test.ts b/launcher/mpv.test.ts index 0b2beda7..86d8920c 100644 --- a/launcher/mpv.test.ts +++ b/launcher/mpv.test.ts @@ -767,7 +767,10 @@ test('startOverlay attaches through the running app control socket without spawn let buffer = ''; socket.on('data', (chunk) => { buffer += chunk.toString('utf8'); - const line = buffer.split(/\r?\n/, 1)[0]; + const newlineMatch = buffer.match(/\r?\n/); + if (!newlineMatch || newlineMatch.index === undefined) return; + const line = buffer.slice(0, newlineMatch.index).trim(); + buffer = buffer.slice(newlineMatch.index + newlineMatch[0].length); if (!line) return; const payload = JSON.parse(line) as { argv?: unknown }; if (Array.isArray(payload.argv)) { @@ -823,6 +826,59 @@ test('startOverlay attaches through the running app control socket without spawn } }); +test('startOverlay falls back to legacy app startup when control command fails', 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 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 controlServer = net.createServer((socket) => { + socket.on('data', () => { + socket.end(JSON.stringify({ ok: false, error: 'boom' }) + '\n'); + }); + }); + + try { + process.env.SUBMINER_APP_CONTROL_SOCKET = controlSocketPath; + await new Promise((resolve, reject) => { + controlServer.once('error', reject); + controlServer.listen(controlSocketPath, resolve); + }); + + await startOverlay(appPath, makeArgs(), socketPath); + + const invocationText = fs.readFileSync(appInvocationsPath, 'utf8'); + assert.match(invocationText, /--app-ping/); + assert.match(invocationText, /--start/); + } finally { + if (originalControlSocket === undefined) { + delete process.env.SUBMINER_APP_CONTROL_SOCKET; + } else { + process.env.SUBMINER_APP_CONTROL_SOCKET = originalControlSocket; + } + 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 ba95ea9f..a3a584eb 100644 --- a/launcher/mpv.ts +++ b/launcher/mpv.ts @@ -1058,7 +1058,6 @@ export async function startOverlay( clearOverlayManagedByLauncher(); state.overlayProc = null; } - return; } const appAlreadyRunning = isAppAlreadyRunning(appPath, args.logLevel); diff --git a/src/main.ts b/src/main.ts index d77c3143..629600a8 100644 --- a/src/main.ts +++ b/src/main.ts @@ -3077,10 +3077,14 @@ const openFirstRunSetupWindowHandler = createOpenFirstRunSetupWindowHandler({ return; } if (submission.action === 'open-config-settings') { - firstRunSetupMessage = openConfigSettingsWindow() + const opened = openConfigSettingsWindow(); + firstRunSetupMessage = opened ? 'Opened SubMiner settings.' : 'SubMiner settings are unavailable.'; - return { skipRender: true }; + if (opened) { + return { skipRender: true }; + } + return; } if (submission.action === 'refresh') { const snapshot = await firstRunSetupService.refreshStatus('Status refreshed.'); diff --git a/src/main/runtime/app-control-server.test.ts b/src/main/runtime/app-control-server.test.ts index 41c899b8..cbe0581f 100644 --- a/src/main/runtime/app-control-server.test.ts +++ b/src/main/runtime/app-control-server.test.ts @@ -45,3 +45,31 @@ test('app control server dispatches argv requests and replies ok', async () => { fs.rmSync(dir, { recursive: true, force: true }); } }); + +test('app control server rejects requests larger than 64KB by UTF-8 byte length', 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(Array.from({ length: 4 }, () => 'あ'.repeat(6000)), { + socketPath, + }); + + assert.deepEqual(result, { ok: false, error: 'App control request too large' }); + assert.deepEqual(received, []); + } 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 index ea45f189..5b11a7b3 100644 --- a/src/main/runtime/app-control-server.ts +++ b/src/main/runtime/app-control-server.ts @@ -44,12 +44,14 @@ export function startAppControlServer(options: AppControlServerOptions): AppCont const server = net.createServer((socket) => { let buffer = ''; + let byteCount = 0; let handled = false; socket.on('data', (chunk) => { if (handled) return; + byteCount += chunk.length; buffer += chunk.toString('utf8'); - if (buffer.length > 65536) { + if (byteCount > 65536) { handled = true; writeResponse(socket, { ok: false, error: 'App control request too large' }); return;