From e6a004ab8b35ad27569a3eb0ea374f629927736a Mon Sep 17 00:00:00 2001 From: sudacode Date: Sun, 31 May 2026 21:46:00 -0700 Subject: [PATCH] Fix Windows mpv shortcut attachment to background app (#105) --- .../windows-launch-mpv-background-attach.md | 4 + src/main-entry.ts | 12 +- src/main/runtime/windows-mpv-launch.test.ts | 116 ++++++++++++++++++ src/main/runtime/windows-mpv-launch.ts | 113 ++++++++++++++++- 4 files changed, 239 insertions(+), 6 deletions(-) create mode 100644 changes/windows-launch-mpv-background-attach.md diff --git a/changes/windows-launch-mpv-background-attach.md b/changes/windows-launch-mpv-background-attach.md new file mode 100644 index 00000000..3efa4c5a --- /dev/null +++ b/changes/windows-launch-mpv-background-attach.md @@ -0,0 +1,4 @@ +type: fixed +area: launcher + +- Fixed the Windows `SubMiner mpv` shortcut so videos attach to an already-running background app instead of launching a second warmup/tokenizer path. diff --git a/src/main-entry.ts b/src/main-entry.ts index 2753f1a2..e4c84563 100644 --- a/src/main-entry.ts +++ b/src/main-entry.ts @@ -21,7 +21,7 @@ import { } from './main-entry-runtime'; import { requestSingleInstanceLockEarly } from './main/early-single-instance'; import { readConfiguredWindowsMpvLaunch } from './main-entry-launch-config'; -import { sendAppControlCommand } from './shared/app-control-client'; +import { isAppControlServerAvailable, sendAppControlCommand } from './shared/app-control-client'; import { detectInstalledFirstRunPluginCandidates, detectInstalledMpvPlugin, @@ -249,6 +249,16 @@ async function runEntryProcess(): Promise { normalizeLaunchMpvTargets(process.argv), createWindowsMpvLaunchDeps({ getEnv: (name) => process.env[name], + isAppControlServerAvailable: () => + isAppControlServerAvailable({ + configDir: userDataPath, + timeoutMs: 350, + }), + sendAppControlCommand: (argv) => + sendAppControlCommand(argv, { + configDir: userDataPath, + timeoutMs: 1000, + }), showError: (title, content) => { dialog.showErrorBox(title, content); }, diff --git a/src/main/runtime/windows-mpv-launch.test.ts b/src/main/runtime/windows-mpv-launch.test.ts index 75129b40..f1255667 100644 --- a/src/main/runtime/windows-mpv-launch.test.ts +++ b/src/main/runtime/windows-mpv-launch.test.ts @@ -253,6 +253,122 @@ test('buildWindowsMpvLaunchArgs keeps Windows ipc default unless explicitly over assert.match(scriptOpts ?? '', /subminer-socket_path=\\\\\.\\pipe\\subminer-socket/); }); +test('launchWindowsMpv attaches a launched video to a running app and disables plugin auto-start', async () => { + const spawnedArgs: string[][] = []; + const controlArgv: string[][] = []; + const waitedSockets: Array<{ socketPath: string; timeoutMs: number }> = []; + const logs: string[] = []; + const result = await launchWindowsMpv( + ['C:\\video.mkv'], + createDeps({ + getEnv: (name) => (name === 'SUBMINER_MPV_PATH' ? 'C:\\mpv\\mpv.exe' : undefined), + fileExists: (candidate) => candidate === 'C:\\mpv\\mpv.exe', + isAppControlServerAvailable: async () => true, + waitForSocketReady: async (socketPath, timeoutMs) => { + waitedSockets.push({ socketPath, timeoutMs }); + return true; + }, + sendAppControlCommand: async (argv) => { + controlArgv.push(argv); + return { ok: true }; + }, + logInfo: (message) => logs.push(message), + spawnDetached: async (_command, args) => { + spawnedArgs.push(args); + }, + }), + ['--input-ipc-server', '\\\\.\\pipe\\warm-subminer'], + 'C:\\SubMiner\\SubMiner.exe', + 'C:\\Program Files\\SubMiner\\resources\\plugin\\subminer\\main.lua', + '', + 'normal', + undefined, + { + socketPath: '\\\\.\\pipe\\ignored-config-socket', + binaryPath: '', + backend: 'windows', + logLevel: 'debug', + logRotation: 7, + autoStart: true, + autoStartVisibleOverlay: true, + autoStartPauseUntilReady: true, + texthookerEnabled: true, + aniskipEnabled: true, + aniskipButtonKey: 'TAB', + }, + ); + + assert.equal(result.ok, true); + const scriptOpts = spawnedArgs[0]?.find((arg) => arg.startsWith('--script-opts=')); + assert.match(scriptOpts ?? '', /subminer-auto_start=no/); + assert.match(scriptOpts ?? '', /subminer-socket_path=\\\\\.\\pipe\\warm-subminer/); + assert.deepEqual(waitedSockets, [{ socketPath: '\\\\.\\pipe\\warm-subminer', timeoutMs: 10000 }]); + assert.deepEqual(controlArgv, [ + [ + '--start', + '--managed-playback', + '--log-level', + 'debug', + '--backend', + 'windows', + '--socket', + '\\\\.\\pipe\\warm-subminer', + '--show-visible-overlay', + '--texthooker', + ], + ]); + assert.ok(logs.some((line) => line.includes('attachRunningApp=yes'))); + assert.ok(logs.some((line) => line.includes('Attached launched mpv session'))); +}); + +test('launchWindowsMpv leaves plugin auto-start enabled when no running app control socket exists', async () => { + const spawnedArgs: string[][] = []; + let controlCalls = 0; + let waitCalls = 0; + const result = await launchWindowsMpv( + ['C:\\video.mkv'], + createDeps({ + getEnv: (name) => (name === 'SUBMINER_MPV_PATH' ? 'C:\\mpv\\mpv.exe' : undefined), + fileExists: (candidate) => candidate === 'C:\\mpv\\mpv.exe', + isAppControlServerAvailable: async () => false, + waitForSocketReady: async () => { + waitCalls += 1; + return true; + }, + sendAppControlCommand: async () => { + controlCalls += 1; + return { ok: true }; + }, + spawnDetached: async (_command, args) => { + spawnedArgs.push(args); + }, + }), + [], + 'C:\\SubMiner\\SubMiner.exe', + 'C:\\Program Files\\SubMiner\\resources\\plugin\\subminer\\main.lua', + '', + 'normal', + undefined, + { + socketPath: '\\\\.\\pipe\\subminer-socket', + binaryPath: '', + backend: 'windows', + autoStart: true, + autoStartVisibleOverlay: true, + autoStartPauseUntilReady: true, + texthookerEnabled: false, + aniskipEnabled: true, + aniskipButtonKey: 'TAB', + }, + ); + + assert.equal(result.ok, true); + const scriptOpts = spawnedArgs[0]?.find((arg) => arg.startsWith('--script-opts=')); + assert.match(scriptOpts ?? '', /subminer-auto_start=yes/); + assert.equal(waitCalls, 0); + assert.equal(controlCalls, 0); +}); + test('launchWindowsMpv reports missing mpv path', async () => { const errors: string[] = []; const result = await launchWindowsMpv( diff --git a/src/main/runtime/windows-mpv-launch.ts b/src/main/runtime/windows-mpv-launch.ts index 59c8ead5..e2ef1fbb 100644 --- a/src/main/runtime/windows-mpv-launch.ts +++ b/src/main/runtime/windows-mpv-launch.ts @@ -1,4 +1,5 @@ import fs from 'node:fs'; +import net from 'node:net'; import { spawn, spawnSync } from 'node:child_process'; import { isLogFileEnabled } from '../../shared/log-files'; import { buildMpvLaunchModeArgs } from '../../shared/mpv-launch-mode'; @@ -13,6 +14,11 @@ export interface WindowsMpvLaunchDeps { runWhere: () => { status: number | null; stdout: string; error?: Error }; fileExists: (candidate: string) => boolean; spawnDetached: (command: string, args: string[], env?: NodeJS.ProcessEnv) => Promise; + isAppControlServerAvailable?: () => Promise; + sendAppControlCommand?: ( + argv: string[], + ) => Promise<{ ok: boolean; unavailable?: boolean; error?: string }>; + waitForSocketReady?: (socketPath: string, timeoutMs: number) => Promise; showError: (title: string, content: string) => void; logInfo?: (message: string) => void; } @@ -81,6 +87,44 @@ export function resolveWindowsMpvPath(deps: WindowsMpvLaunchDeps, configuredMpvP } const DEFAULT_WINDOWS_MPV_SOCKET = '\\\\.\\pipe\\subminer-socket'; +const RUNNING_APP_ATTACH_SOCKET_WAIT_MS = 10000; + +async function sleepMs(ms: number): Promise { + await new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function canConnectSocket(socketPath: string): Promise { + return await new Promise((resolve) => { + const socket = net.createConnection(socketPath); + let settled = false; + + const finish = (value: boolean): void => { + if (settled) return; + settled = true; + try { + socket.destroy(); + } catch { + // ignore + } + resolve(value); + }; + + socket.once('connect', () => finish(true)); + socket.once('error', () => finish(false)); + socket.setTimeout(400, () => finish(false)); + }); +} + +async function waitForSocketReady(socketPath: string, timeoutMs: number): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (await canConnectSocket(socketPath)) { + return true; + } + await sleepMs(150); + } + return false; +} function readExtraArgValue(extraArgs: string[], flag: string): string | undefined { let value: string | undefined; @@ -101,6 +145,31 @@ function readExtraArgValue(extraArgs: string[], flag: string): string | undefine return value; } +export function resolveWindowsMpvInputIpcServer(extraArgs: string[] = []): string { + return readExtraArgValue(extraArgs, '--input-ipc-server') ?? DEFAULT_WINDOWS_MPV_SOCKET; +} + +export function buildWindowsRunningAppAttachArgs( + socketPath: string, + runtimeConfig: SubminerPluginRuntimeScriptOptConfig, +): string[] { + const args = ['--start', '--managed-playback']; + if (runtimeConfig.logLevel && runtimeConfig.logLevel !== 'info') { + args.push('--log-level', runtimeConfig.logLevel); + } + if (runtimeConfig.backend) { + args.push('--backend', runtimeConfig.backend); + } + args.push('--socket', socketPath); + args.push( + runtimeConfig.autoStartVisibleOverlay ? '--show-visible-overlay' : '--hide-visible-overlay', + ); + if (runtimeConfig.texthookerEnabled) { + args.push('--texthooker'); + } + return args; +} + export function buildWindowsMpvLaunchArgs( targets: string[], extraArgs: string[] = [], @@ -110,8 +179,7 @@ export function buildWindowsMpvLaunchArgs( pluginRuntimeConfig?: SubminerPluginRuntimeScriptOptConfig, ): string[] { const launchIdle = targets.length === 0; - const inputIpcServer = - readExtraArgValue(extraArgs, '--input-ipc-server') ?? DEFAULT_WINDOWS_MPV_SOCKET; + const inputIpcServer = resolveWindowsMpvInputIpcServer(extraArgs); const scriptEntrypoint = typeof pluginEntrypointPath === 'string' && pluginEntrypointPath.trim().length > 0 ? `--script=${pluginEntrypointPath.trim()}` @@ -210,6 +278,16 @@ export async function launchWindowsMpv( } const hasLogLevel = pluginRuntimeConfig?.logLevel !== undefined; const hasLogRotation = pluginRuntimeConfig?.logRotation !== undefined; + const shouldAttachRunningApp = + targets.length > 0 && + pluginRuntimeConfig?.autoStart === true && + deps.isAppControlServerAvailable !== undefined && + deps.sendAppControlCommand !== undefined && + (await deps.isAppControlServerAvailable()); + const effectivePluginRuntimeConfig = + shouldAttachRunningApp && pluginRuntimeConfig + ? { ...pluginRuntimeConfig, autoStart: false } + : pluginRuntimeConfig; const launchEnv = hasLogLevel || hasLogRotation ? { @@ -219,16 +297,15 @@ export async function launchWindowsMpv( : {}), } : undefined; + const inputIpcServer = resolveWindowsMpvInputIpcServer(extraArgs); const launchArgs = buildWindowsMpvLaunchArgs( targets, extraArgs, binaryPath, runtimePluginEntrypointPath, launchMode, - pluginRuntimeConfig, + effectivePluginRuntimeConfig, ); - const inputIpcServer = - readExtraArgValue(launchArgs, '--input-ipc-server') ?? DEFAULT_WINDOWS_MPV_SOCKET; deps.logInfo?.( [ `Launching mpv: mpvPath=${mpvPath}`, @@ -236,9 +313,28 @@ export async function launchWindowsMpv( `bundledPlugin=${runtimePluginEntrypointPath ?? 'not injected'}`, `installedPlugin=${installedPlugin?.installed ? (installedPlugin.path ?? 'unknown') : 'none'}`, `targets=${targets.length}`, + `attachRunningApp=${shouldAttachRunningApp ? 'yes' : 'no'}`, ].join('; '), ); await deps.spawnDetached(mpvPath, launchArgs, launchEnv); + if (shouldAttachRunningApp && pluginRuntimeConfig) { + const socketReady = await (deps.waitForSocketReady ?? waitForSocketReady)( + inputIpcServer, + RUNNING_APP_ATTACH_SOCKET_WAIT_MS, + ); + if (!socketReady) { + deps.logInfo?.(`MPV IPC socket was not ready before running app attach: ${inputIpcServer}`); + } + const attachArgs = buildWindowsRunningAppAttachArgs(inputIpcServer, pluginRuntimeConfig); + const controlResult = await deps.sendAppControlCommand?.(attachArgs); + if (controlResult?.ok) { + deps.logInfo?.('Attached launched mpv session to running SubMiner app via control socket'); + } else { + deps.logInfo?.( + `Running SubMiner app attach failed: ${controlResult?.error ?? 'unknown error'}`, + ); + } + } return { ok: true, mpvPath }; } catch (error) { const message = error instanceof Error ? error.message : String(error); @@ -250,6 +346,10 @@ export async function launchWindowsMpv( export function createWindowsMpvLaunchDeps(options: { getEnv?: (name: string) => string | undefined; fileExists?: (candidate: string) => boolean; + isAppControlServerAvailable?: () => Promise; + sendAppControlCommand?: ( + argv: string[], + ) => Promise<{ ok: boolean; unavailable?: boolean; error?: string }>; showError: (title: string, content: string) => void; logInfo?: (message: string) => void; }): WindowsMpvLaunchDeps { @@ -267,6 +367,9 @@ export function createWindowsMpvLaunchDeps(options: { }; }, fileExists: options.fileExists ?? defaultWindowsMpvFileExists, + isAppControlServerAvailable: options.isAppControlServerAvailable, + sendAppControlCommand: options.sendAppControlCommand, + waitForSocketReady, logInfo: options.logInfo, spawnDetached: (command, args, env) => new Promise((resolve, reject) => {