add app control server for launcher-to-app attachment

- Launcher detects a running app via control socket and attaches without spawning a new process
- Own-lifecycle app launches now pass --background --managed-playback; borrowed apps skip --background
- Separate plain subtitle websocket (tokens: []) from annotation websocket
- Default pauseVideoOnHover to true; update docs and config.example.jsonc
- Setup: remove plugin readiness card, add Open SubMiner Settings button
This commit is contained in:
2026-05-21 01:32:58 -07:00
parent 47f92129af
commit 355d7d95b2
58 changed files with 1618 additions and 205 deletions
@@ -206,3 +206,65 @@ test('plugin auto-start playback leaves app lifetime to managed-playback owner',
state.overlayManagedByLauncher = false;
}
});
test('plugin auto-start playback attaches a warm background app through the launcher', async () => {
const context = createContext();
context.args = {
...context.args,
target: '/tmp/movie.mkv',
targetKind: 'file',
};
context.pluginRuntimeConfig = {
socketPath: '/tmp/subminer.sock',
binaryPath: '',
backend: 'auto',
autoStart: true,
autoStartVisibleOverlay: true,
autoStartPauseUntilReady: true,
texthookerEnabled: true,
aniskipEnabled: true,
aniskipButtonKey: 'TAB',
};
const calls: string[] = [];
const receivedStartMpvOptions: Record<string, unknown>[] = [];
await runPlaybackCommandWithDeps(context, {
ensurePlaybackSetupReady: async () => {},
chooseTarget: async () => ({ target: context.args.target, kind: 'file' }),
checkDependencies: () => {},
registerCleanup: () => {},
startMpv: async (
_target,
_targetKind,
_args,
_socketPath,
_appPath,
_preloadedSubtitles,
options,
) => {
calls.push('startMpv');
if (options) {
receivedStartMpvOptions.push(options as Record<string, unknown>);
}
},
waitForUnixSocketReady: async () => true,
startOverlay: async (_appPath, _args, _socketPath, extraAppArgs = []) => {
calls.push(`startOverlay:${extraAppArgs.join(' ')}`);
},
launchAppCommandDetached: () => {},
log: () => {},
cleanupPlaybackSession: async () => {},
getMpvProc: () => null,
isAppControlServerAvailable: async () => true,
} as Parameters<typeof runPlaybackCommandWithDeps>[1] & {
isAppControlServerAvailable: () => Promise<boolean>;
});
assert.deepEqual(calls, ['startMpv', 'startOverlay:--show-visible-overlay --texthooker']);
assert.equal(receivedStartMpvOptions[0]?.startPaused, false);
assert.equal(
(receivedStartMpvOptions[0]?.runtimePluginConfig as { autoStart?: boolean } | undefined)
?.autoStart,
false,
);
});
+33 -13
View File
@@ -8,6 +8,7 @@ import {
cleanupPlaybackSession,
launchAppCommandDetached,
resolveLauncherRuntimePluginPath,
isRunningAppControlServerAvailable,
startMpv,
startOverlay,
state,
@@ -146,6 +147,7 @@ export async function runPlaybackCommand(context: LauncherCommandContext): Promi
waitForUnixSocketReady,
startOverlay,
launchAppCommandDetached,
isAppControlServerAvailable: isRunningAppControlServerAvailable,
log,
cleanupPlaybackSession,
getMpvProc: () => state.mpvProc,
@@ -164,6 +166,7 @@ type PlaybackCommandDeps = {
waitForUnixSocketReady: typeof waitForUnixSocketReady;
startOverlay: typeof startOverlay;
launchAppCommandDetached: typeof launchAppCommandDetached;
isAppControlServerAvailable?: (logLevel: Args['logLevel']) => Promise<boolean>;
log: typeof log;
cleanupPlaybackSession: typeof cleanupPlaybackSession;
getMpvProc: () => typeof state.mpvProc;
@@ -213,8 +216,19 @@ export async function runPlaybackCommandWithDeps(
deps.log('info', args.logLevel, 'YouTube subtitle flow: app-owned picker after mpv bootstrap');
}
const pluginAutoStartEnabled = pluginRuntimeConfig.autoStart;
const shouldLauncherAttachRunningApp =
pluginAutoStartEnabled &&
!args.startOverlay &&
!args.autoStartOverlay &&
!isAppOwnedYoutubeFlow &&
((await deps.isAppControlServerAvailable?.(args.logLevel)) ?? false);
const effectivePluginRuntimeConfig = shouldLauncherAttachRunningApp
? { ...pluginRuntimeConfig, autoStart: false }
: pluginRuntimeConfig;
const shouldPauseUntilOverlayReady =
pluginRuntimeConfig.autoStart &&
effectivePluginRuntimeConfig.autoStart &&
pluginRuntimeConfig.autoStartVisibleOverlay &&
pluginRuntimeConfig.autoStartPauseUntilReady;
@@ -238,16 +252,19 @@ export async function runPlaybackCommandWithDeps(
disableYoutubeSubtitleAutoLoad: isAppOwnedYoutubeFlow,
runtimePluginPath: resolveLauncherRuntimePluginPath({ appPath, scriptPath }),
runtimePluginConfig: {
...pluginRuntimeConfig,
...effectivePluginRuntimeConfig,
backend: args.backend,
texthookerEnabled: args.useTexthooker && pluginRuntimeConfig.texthookerEnabled,
texthookerEnabled: args.useTexthooker && effectivePluginRuntimeConfig.texthookerEnabled,
},
},
);
const ready = await deps.waitForUnixSocketReady(mpvSocketPath, 10000);
const pluginAutoStartEnabled = pluginRuntimeConfig.autoStart;
const shouldStartOverlay = args.startOverlay || args.autoStartOverlay || isAppOwnedYoutubeFlow;
const shouldStartOverlay =
args.startOverlay ||
args.autoStartOverlay ||
isAppOwnedYoutubeFlow ||
shouldLauncherAttachRunningApp;
if (shouldStartOverlay) {
if (ready) {
deps.log('info', args.logLevel, 'MPV IPC socket ready, starting SubMiner overlay');
@@ -258,14 +275,17 @@ export async function runPlaybackCommandWithDeps(
'MPV IPC socket not ready after timeout, starting SubMiner overlay anyway',
);
}
await deps.startOverlay(
appPath,
args,
mpvSocketPath,
isAppOwnedYoutubeFlow
? ['--youtube-play', selectedTarget.target, '--youtube-mode', youtubeMode]
: [],
);
const extraAppArgs = isAppOwnedYoutubeFlow
? ['--youtube-play', selectedTarget.target, '--youtube-mode', youtubeMode]
: shouldLauncherAttachRunningApp
? [
pluginRuntimeConfig.autoStartVisibleOverlay
? '--show-visible-overlay'
: '--hide-visible-overlay',
...(pluginRuntimeConfig.texthookerEnabled ? ['--texthooker'] : []),
]
: [];
await deps.startOverlay(appPath, args, mpvSocketPath, extraAppArgs);
} else if (pluginAutoStartEnabled) {
if (ready) {
deps.log('info', args.logLevel, 'MPV IPC socket ready, relying on mpv plugin auto-start');
+126
View File
@@ -655,6 +655,48 @@ test('startOverlay captures app stdout and stderr into app log', async () => {
}
});
test('startOverlay starts launcher-owned playback in background managed mode', async () => {
const { dir, socketPath } = createTempSocketPath();
const appPath = path.join(dir, 'fake-subminer.sh');
const appInvocationsPath = path.join(dir, 'app-invocations.log');
fs.writeFileSync(
appPath,
[
'#!/bin/sh',
`printf '%s\\n' "$@" >> ${JSON.stringify(appInvocationsPath)}`,
'if [ "$1" = "--app-ping" ]; then exit 1; fi',
'exit 0',
'',
].join('\n'),
);
fs.chmodSync(appPath, 0o755);
fs.writeFileSync(socketPath, '');
const originalCreateConnection = net.createConnection;
try {
net.createConnection = (() => {
const socket = new EventEmitter() as net.Socket;
socket.destroy = (() => socket) as net.Socket['destroy'];
socket.setTimeout = (() => socket) as net.Socket['setTimeout'];
setTimeout(() => socket.emit('connect'), 10);
return socket;
}) as typeof net.createConnection;
await startOverlay(appPath, makeArgs(), socketPath);
const invocationText = fs.readFileSync(appInvocationsPath, 'utf8');
assert.match(invocationText, /--background/);
assert.match(invocationText, /--managed-playback/);
assert.equal(state.overlayManagedByLauncher, true);
assert.equal(state.appPath, appPath);
} finally {
net.createConnection = originalCreateConnection;
state.overlayProc = null;
state.overlayManagedByLauncher = false;
state.appPath = '';
fs.rmSync(dir, { recursive: true, force: true });
}
});
test('startOverlay borrows an already-running background app instead of owning its lifecycle', async () => {
const { dir, socketPath } = createTempSocketPath();
const appPath = path.join(dir, 'fake-subminer.sh');
@@ -686,6 +728,7 @@ test('startOverlay borrows an already-running background app instead of owning i
const invocationText = fs.readFileSync(appInvocationsPath, 'utf8');
assert.match(invocationText, /--app-ping/);
assert.match(invocationText, /--start/);
assert.doesNotMatch(invocationText, /--background/);
assert.equal(state.overlayManagedByLauncher, false);
assert.equal(state.appPath, '');
} finally {
@@ -697,6 +740,89 @@ test('startOverlay borrows an already-running background app instead of owning i
}
});
test('startOverlay attaches through the running app control socket without spawning another app command', async () => {
if (process.platform === 'win32') return;
const { dir, socketPath } = createTempSocketPath();
const controlSocketPath = path.join(dir, 'control.sock');
const appPath = path.join(dir, 'fake-subminer.sh');
const appInvocationsPath = path.join(dir, 'app-invocations.log');
const receivedControlArgv: string[][] = [];
const originalControlSocket = process.env.SUBMINER_APP_CONTROL_SOCKET;
fs.writeFileSync(
appPath,
[
'#!/bin/sh',
`printf '%s\\n' "$@" >> ${JSON.stringify(appInvocationsPath)}`,
'if [ "$1" = "--app-ping" ]; then exit 0; fi',
'exit 0',
'',
].join('\n'),
);
fs.chmodSync(appPath, 0o755);
const mpvServer = net.createServer((socket) => socket.end());
const controlServer = net.createServer((socket) => {
let buffer = '';
socket.on('data', (chunk) => {
buffer += chunk.toString('utf8');
const line = buffer.split(/\r?\n/, 1)[0];
if (!line) return;
const payload = JSON.parse(line) as { argv?: unknown };
if (Array.isArray(payload.argv)) {
receivedControlArgv.push(
payload.argv.filter((value): value is string => typeof value === 'string'),
);
}
socket.end(JSON.stringify({ ok: true }) + '\n');
});
});
try {
process.env.SUBMINER_APP_CONTROL_SOCKET = controlSocketPath;
await new Promise<void>((resolve, reject) => {
mpvServer.once('error', reject);
mpvServer.listen(socketPath, resolve);
});
await new Promise<void>((resolve, reject) => {
controlServer.once('error', reject);
controlServer.listen(controlSocketPath, resolve);
});
await startOverlay(appPath, makeArgs(), socketPath);
const invocationText = fs.existsSync(appInvocationsPath)
? fs.readFileSync(appInvocationsPath, 'utf8')
: '';
assert.equal(invocationText, '');
assert.equal(receivedControlArgv.length, 1);
assert.deepEqual(receivedControlArgv[0]?.slice(0, 7), [
'--start',
'--managed-playback',
'--backend',
'x11',
'--socket',
socketPath,
'--log-level',
]);
assert.equal(state.overlayManagedByLauncher, false);
assert.equal(state.appPath, '');
} finally {
if (originalControlSocket === undefined) {
delete process.env.SUBMINER_APP_CONTROL_SOCKET;
} else {
process.env.SUBMINER_APP_CONTROL_SOCKET = originalControlSocket;
}
await new Promise<void>((resolve) => mpvServer.close(() => resolve()));
await new Promise<void>((resolve) => controlServer.close(() => resolve()));
state.overlayProc = null;
state.overlayManagedByLauncher = false;
state.appPath = '';
fs.rmSync(dir, { recursive: true, force: true });
}
});
test('startOverlay keeps lifecycle ownership for its already-managed app', async () => {
const { dir, socketPath } = createTempSocketPath();
const appPath = path.join(dir, 'fake-subminer.sh');
+77 -4
View File
@@ -4,6 +4,11 @@ import os from 'node:os';
import net from 'node:net';
import { spawn, spawnSync } from 'node:child_process';
import { buildMpvLaunchModeArgs } from '../src/shared/mpv-launch-mode.js';
import {
isAppControlServerAvailable as checkAppControlServerAvailable,
sendAppControlCommand,
} from '../src/shared/app-control-client.js';
import { getDefaultConfigDir } from '../src/shared/setup-state.js';
import {
detectInstalledMpvPlugin,
type InstalledMpvPluginDetection,
@@ -1004,19 +1009,70 @@ export async function startOverlay(
): Promise<void> {
const backend = detectBackend(args.backend);
log('info', args.logLevel, `Starting SubMiner overlay (backend: ${backend})...`);
const appAlreadyRunning = isAppAlreadyRunning(appPath, args.logLevel);
const alreadyManagedByLauncher = state.overlayManagedByLauncher && state.appPath === appPath;
const overlayArgs = ['--start', '--backend', backend, '--socket', socketPath, ...extraAppArgs];
const overlayArgs = [
'--start',
'--managed-playback',
'--backend',
backend,
'--socket',
socketPath,
...extraAppArgs,
];
if (args.logLevel !== 'info') overlayArgs.push('--log-level', args.logLevel);
if (args.useTexthooker) overlayArgs.push('--texthooker');
const target = resolveAppSpawnTarget(appPath, overlayArgs);
const controlResult = await sendAppControlCommand(overlayArgs, {
configDir: getLauncherConfigDir(),
});
if (controlResult.ok) {
log('debug', args.logLevel, 'Attached to running SubMiner app via control socket');
if (alreadyManagedByLauncher) {
markOverlayManagedByLauncher(appPath);
} else {
clearOverlayManagedByLauncher();
state.overlayProc = null;
}
const socketReady = await waitForUnixSocketReady(
socketPath,
OVERLAY_START_SOCKET_READY_TIMEOUT_MS,
);
if (!socketReady) {
log(
'debug',
args.logLevel,
'Overlay start continuing before mpv socket readiness was confirmed',
);
}
return;
}
if (controlResult.unavailable !== true) {
log(
'warn',
args.logLevel,
`Running SubMiner app control command failed: ${controlResult.error ?? 'unknown error'}`,
);
if (!alreadyManagedByLauncher) {
clearOverlayManagedByLauncher();
state.overlayProc = null;
}
return;
}
const appAlreadyRunning = isAppAlreadyRunning(appPath, args.logLevel);
const borrowingExistingApp = appAlreadyRunning && !alreadyManagedByLauncher;
const spawnOverlayArgs = [...overlayArgs];
if (!borrowingExistingApp) spawnOverlayArgs.unshift('--background');
const target = resolveAppSpawnTarget(appPath, spawnOverlayArgs);
state.overlayProc = spawn(target.command, target.args, {
stdio: ['ignore', 'pipe', 'pipe'],
env: buildAppEnv(process.env, target.env),
});
attachAppProcessLogging(state.overlayProc);
if (appAlreadyRunning && !(state.overlayManagedByLauncher && state.appPath === appPath)) {
if (borrowingExistingApp) {
log(
'debug',
args.logLevel,
@@ -1045,6 +1101,23 @@ export async function startOverlay(
}
}
function getLauncherConfigDir(): string {
return getDefaultConfigDir({
xdgConfigHome: process.env.XDG_CONFIG_HOME,
homeDir: os.homedir(),
});
}
export async function isRunningAppControlServerAvailable(logLevel: LogLevel): Promise<boolean> {
const available = await checkAppControlServerAvailable({
configDir: getLauncherConfigDir(),
});
if (available) {
log('debug', logLevel, 'Running SubMiner app control socket detected');
}
return available;
}
export function markOverlayManagedByLauncher(appPath?: string): void {
if (appPath) {
state.appPath = appPath;
+118 -29
View File
@@ -238,6 +238,84 @@ async function waitForJsonLines(
}
}
async function waitForFile(filePath: string, timeoutMs = 1500): Promise<void> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (fs.existsSync(filePath)) return;
await new Promise<void>((resolve) => setTimeout(resolve, 50));
}
}
async function startFakeControlServer(
smokeCase: SmokeCase,
): Promise<{ socketPath: string; logPath: string; stop: () => Promise<void> }> {
const socketPath = path.join(smokeCase.socketDir, 'app-control.sock');
const logPath = path.join(smokeCase.artifactsDir, 'fake-control.log');
const readyPath = path.join(smokeCase.artifactsDir, 'fake-control.ready');
const scriptPath = path.join(smokeCase.artifactsDir, 'fake-control-server.js');
fs.writeFileSync(
scriptPath,
`const fs = require('node:fs');
const net = require('node:net');
const path = require('node:path');
const socketPath = ${JSON.stringify(socketPath)};
const logPath = ${JSON.stringify(logPath)};
const readyPath = ${JSON.stringify(readyPath)};
try { fs.rmSync(socketPath, { force: true }); } catch {}
fs.mkdirSync(path.dirname(socketPath), { recursive: true });
const server = net.createServer((socket) => {
let buffer = '';
socket.on('data', (chunk) => {
buffer += chunk.toString('utf8');
const line = buffer.split(/\\r?\\n/, 1)[0];
if (!line) return;
fs.appendFileSync(logPath, line + '\\n');
socket.end(JSON.stringify({ ok: true }) + '\\n');
});
});
server.listen(socketPath, () => {
fs.writeFileSync(readyPath, 'ready');
});
const shutdown = () => {
server.close(() => {
try { fs.rmSync(socketPath, { force: true }); } catch {}
process.exit(0);
});
};
process.on('SIGTERM', shutdown);
process.on('SIGINT', shutdown);
setInterval(() => {}, 1000);
`,
);
const proc = spawn(process.execPath, [scriptPath], { stdio: 'ignore' });
await waitForFile(readyPath);
return {
socketPath,
logPath,
stop: async () => {
if (proc.exitCode !== null || proc.signalCode !== null) return;
proc.kill('SIGTERM');
await new Promise<void>((resolve) => {
const timer = setTimeout(() => {
proc.kill('SIGKILL');
resolve();
}, 1000);
proc.once('close', () => {
clearTimeout(timer);
resolve();
});
});
},
};
}
test('launcher smoke fixture seeds completed setup state', () => {
const smokeCase = createSmokeCase('setup-state');
try {
@@ -295,7 +373,7 @@ test('launcher mpv status returns ready when socket is connectable', async () =>
});
test(
'launcher start-overlay run forwards socket/backend and keeps background app alive after mpv exits',
'launcher start-overlay run forwards socket/backend and stops owned background app after mpv exits',
{ timeout: LONG_SMOKE_TEST_TIMEOUT_MS },
async () => {
await withSmokeCase('overlay-start-stop', async (smokeCase) => {
@@ -330,7 +408,9 @@ test(
const appStartArgs = appStartEntries[0]?.argv;
assert.equal(Array.isArray(appStartArgs), true);
assert.equal((appStartArgs as string[]).includes('--background'), true);
assert.equal((appStartArgs as string[]).includes('--start'), true);
assert.equal((appStartArgs as string[]).includes('--managed-playback'), true);
assert.equal((appStartArgs as string[]).includes('--backend'), true);
assert.equal((appStartArgs as string[]).includes('x11'), true);
assert.equal((appStartArgs as string[]).includes('--socket'), true);
@@ -351,44 +431,53 @@ test(
);
test(
'launcher start-overlay borrows a running background app and does not stop it after mpv exits',
'launcher start-overlay attaches to a running background app without spawning another app command',
{ timeout: LONG_SMOKE_TEST_TIMEOUT_MS },
async () => {
await withSmokeCase('overlay-borrow-background', async (smokeCase) => {
const controlServer = await startFakeControlServer(smokeCase);
const env = {
...makeTestEnv(smokeCase),
SUBMINER_FAKE_APP_RUNNING: '1',
SUBMINER_APP_CONTROL_SOCKET: controlServer.socketPath,
};
const result = runLauncher(
smokeCase,
['--backend', 'x11', '--start-overlay', smokeCase.videoPath],
env,
'overlay-borrow-background',
);
try {
const result = runLauncher(
smokeCase,
['--backend', 'x11', '--start-overlay', smokeCase.videoPath],
env,
'overlay-borrow-background',
);
const appLogPath = path.join(smokeCase.artifactsDir, 'fake-app.log');
const appStartPath = path.join(smokeCase.artifactsDir, 'fake-app-start.log');
const appStopPath = path.join(smokeCase.artifactsDir, 'fake-app-stop.log');
await waitForJsonLines(appStartPath, 1);
const appLogPath = path.join(smokeCase.artifactsDir, 'fake-app.log');
const appStartPath = path.join(smokeCase.artifactsDir, 'fake-app-start.log');
const appStopPath = path.join(smokeCase.artifactsDir, 'fake-app-stop.log');
await waitForJsonLines(controlServer.logPath, 1);
const appEntries = readJsonLines(appLogPath);
const appStartEntries = readJsonLines(appStartPath);
const appStopEntries = readJsonLines(appStopPath);
const mpvEntries = readJsonLines(path.join(smokeCase.artifactsDir, 'fake-mpv.log'));
const mpvError = mpvEntries.find(
(entry): entry is { error: string } => typeof entry.error === 'string',
)?.error;
const unixSocketDenied =
typeof mpvError === 'string' && /eperm|operation not permitted/i.test(mpvError);
const appEntries = readJsonLines(appLogPath);
const appStartEntries = readJsonLines(appStartPath);
const appStopEntries = readJsonLines(appStopPath);
const controlEntries = readJsonLines(controlServer.logPath);
const mpvEntries = readJsonLines(path.join(smokeCase.artifactsDir, 'fake-mpv.log'));
const mpvError = mpvEntries.find(
(entry): entry is { error: string } => typeof entry.error === 'string',
)?.error;
const unixSocketDenied =
typeof mpvError === 'string' && /eperm|operation not permitted/i.test(mpvError);
assert.equal(result.status, unixSocketDenied ? 3 : 0);
assert.ok(
appEntries.some(
(entry) => Array.isArray(entry.argv) && (entry.argv as string[]).includes('--app-ping'),
),
);
assert.equal(appStartEntries.length, 1);
assert.equal(appStopEntries.length, 0);
assert.equal(result.status, unixSocketDenied ? 3 : 0);
assert.equal(appEntries.length, 0);
assert.equal(appStartEntries.length, 0);
assert.equal(appStopEntries.length, 0);
assert.equal(controlEntries.length, 1);
const controlArgs = controlEntries[0]?.argv;
assert.equal(Array.isArray(controlArgs), true);
assert.equal((controlArgs as string[]).includes('--background'), false);
assert.equal((controlArgs as string[]).includes('--start'), true);
assert.equal((controlArgs as string[]).includes('--managed-playback'), true);
} finally {
await controlServer.stop();
}
});
},
);