fix texthooker gate, overlay fallback, and control server byte limit

- gate --texthooker flag on both CLI useTexthooker arg and plugin texthookerEnabled
- remove erroneous return that blocked legacy app startup fallback after control command failure
- fix open-config-settings to only skipRender when window actually opened
- track raw byte count for accurate 64KB limit in app control server
This commit is contained in:
2026-05-21 03:11:23 -07:00
parent a53237f1ce
commit 661e54144d
8 changed files with 144 additions and 7 deletions
+1 -1
View File
@@ -110,7 +110,7 @@ Browse sibling episode files and the active mpv queue in one overlay modal. Open
## Requirements ## 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 | | Dependency | Status | What it does |
| -------------------- | ----------- | ---------------------------------------- | | -------------------- | ----------- | ---------------------------------------- |
@@ -151,6 +151,7 @@ test('plugin auto-start playback leaves app lifetime to managed-playback owner',
...context.args, ...context.args,
target: '/tmp/movie.mkv', target: '/tmp/movie.mkv',
targetKind: 'file', targetKind: 'file',
useTexthooker: true,
}; };
context.pluginRuntimeConfig = { context.pluginRuntimeConfig = {
socketPath: '/tmp/subminer.sock', socketPath: '/tmp/subminer.sock',
@@ -213,6 +214,7 @@ test('plugin auto-start playback attaches a warm background app through the laun
...context.args, ...context.args,
target: '/tmp/movie.mkv', target: '/tmp/movie.mkv',
targetKind: 'file', targetKind: 'file',
useTexthooker: true,
}; };
context.pluginRuntimeConfig = { context.pluginRuntimeConfig = {
socketPath: '/tmp/subminer.sock', socketPath: '/tmp/subminer.sock',
@@ -268,3 +270,47 @@ test('plugin auto-start playback attaches a warm background app through the laun
false, 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<typeof runPlaybackCommandWithDeps>[1] & {
isAppControlServerAvailable: () => Promise<boolean>;
});
assert.deepEqual(calls, ['startMpv', 'startOverlay:--show-visible-overlay']);
});
+3 -1
View File
@@ -282,7 +282,9 @@ export async function runPlaybackCommandWithDeps(
pluginRuntimeConfig.autoStartVisibleOverlay pluginRuntimeConfig.autoStartVisibleOverlay
? '--show-visible-overlay' ? '--show-visible-overlay'
: '--hide-visible-overlay', : '--hide-visible-overlay',
...(pluginRuntimeConfig.texthookerEnabled ? ['--texthooker'] : []), ...(args.useTexthooker && effectivePluginRuntimeConfig.texthookerEnabled
? ['--texthooker']
: []),
] ]
: []; : [];
await deps.startOverlay(appPath, args, mpvSocketPath, extraAppArgs); await deps.startOverlay(appPath, args, mpvSocketPath, extraAppArgs);
+57 -1
View File
@@ -767,7 +767,10 @@ test('startOverlay attaches through the running app control socket without spawn
let buffer = ''; let buffer = '';
socket.on('data', (chunk) => { socket.on('data', (chunk) => {
buffer += chunk.toString('utf8'); 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; if (!line) return;
const payload = JSON.parse(line) as { argv?: unknown }; const payload = JSON.parse(line) as { argv?: unknown };
if (Array.isArray(payload.argv)) { 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<void>((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<void>((resolve) => controlServer.close(() => resolve()));
state.overlayProc = null;
state.overlayManagedByLauncher = false;
state.appPath = '';
fs.rmSync(dir, { recursive: true, force: true });
}
});
test('startOverlay keeps lifecycle ownership for its already-managed app', async () => { test('startOverlay keeps lifecycle ownership for its already-managed app', async () => {
const { dir, socketPath } = createTempSocketPath(); const { dir, socketPath } = createTempSocketPath();
const appPath = path.join(dir, 'fake-subminer.sh'); const appPath = path.join(dir, 'fake-subminer.sh');
-1
View File
@@ -1058,7 +1058,6 @@ export async function startOverlay(
clearOverlayManagedByLauncher(); clearOverlayManagedByLauncher();
state.overlayProc = null; state.overlayProc = null;
} }
return;
} }
const appAlreadyRunning = isAppAlreadyRunning(appPath, args.logLevel); const appAlreadyRunning = isAppAlreadyRunning(appPath, args.logLevel);
+6 -2
View File
@@ -3077,10 +3077,14 @@ const openFirstRunSetupWindowHandler = createOpenFirstRunSetupWindowHandler({
return; return;
} }
if (submission.action === 'open-config-settings') { if (submission.action === 'open-config-settings') {
firstRunSetupMessage = openConfigSettingsWindow() const opened = openConfigSettingsWindow();
firstRunSetupMessage = opened
? 'Opened SubMiner settings.' ? 'Opened SubMiner settings.'
: 'SubMiner settings are unavailable.'; : 'SubMiner settings are unavailable.';
return { skipRender: true }; if (opened) {
return { skipRender: true };
}
return;
} }
if (submission.action === 'refresh') { if (submission.action === 'refresh') {
const snapshot = await firstRunSetupService.refreshStatus('Status refreshed.'); const snapshot = await firstRunSetupService.refreshStatus('Status refreshed.');
@@ -45,3 +45,31 @@ test('app control server dispatches argv requests and replies ok', async () => {
fs.rmSync(dir, { recursive: true, force: true }); 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 });
}
});
+3 -1
View File
@@ -44,12 +44,14 @@ export function startAppControlServer(options: AppControlServerOptions): AppCont
const server = net.createServer((socket) => { const server = net.createServer((socket) => {
let buffer = ''; let buffer = '';
let byteCount = 0;
let handled = false; let handled = false;
socket.on('data', (chunk) => { socket.on('data', (chunk) => {
if (handled) return; if (handled) return;
byteCount += chunk.length;
buffer += chunk.toString('utf8'); buffer += chunk.toString('utf8');
if (buffer.length > 65536) { if (byteCount > 65536) {
handled = true; handled = true;
writeResponse(socket, { ok: false, error: 'App control request too large' }); writeResponse(socket, { ok: false, error: 'App control request too large' });
return; return;