Fix Windows mpv shortcut attachment to background app (#105)

This commit is contained in:
2026-05-31 21:46:00 -07:00
committed by GitHub
parent b510c54875
commit e6a004ab8b
4 changed files with 239 additions and 6 deletions
@@ -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.
+11 -1
View File
@@ -21,7 +21,7 @@ import {
} from './main-entry-runtime'; } from './main-entry-runtime';
import { requestSingleInstanceLockEarly } from './main/early-single-instance'; import { requestSingleInstanceLockEarly } from './main/early-single-instance';
import { readConfiguredWindowsMpvLaunch } from './main-entry-launch-config'; import { readConfiguredWindowsMpvLaunch } from './main-entry-launch-config';
import { sendAppControlCommand } from './shared/app-control-client'; import { isAppControlServerAvailable, sendAppControlCommand } from './shared/app-control-client';
import { import {
detectInstalledFirstRunPluginCandidates, detectInstalledFirstRunPluginCandidates,
detectInstalledMpvPlugin, detectInstalledMpvPlugin,
@@ -249,6 +249,16 @@ async function runEntryProcess(): Promise<void> {
normalizeLaunchMpvTargets(process.argv), normalizeLaunchMpvTargets(process.argv),
createWindowsMpvLaunchDeps({ createWindowsMpvLaunchDeps({
getEnv: (name) => process.env[name], getEnv: (name) => process.env[name],
isAppControlServerAvailable: () =>
isAppControlServerAvailable({
configDir: userDataPath,
timeoutMs: 350,
}),
sendAppControlCommand: (argv) =>
sendAppControlCommand(argv, {
configDir: userDataPath,
timeoutMs: 1000,
}),
showError: (title, content) => { showError: (title, content) => {
dialog.showErrorBox(title, content); dialog.showErrorBox(title, content);
}, },
+116
View File
@@ -253,6 +253,122 @@ test('buildWindowsMpvLaunchArgs keeps Windows ipc default unless explicitly over
assert.match(scriptOpts ?? '', /subminer-socket_path=\\\\\.\\pipe\\subminer-socket/); 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 () => { test('launchWindowsMpv reports missing mpv path', async () => {
const errors: string[] = []; const errors: string[] = [];
const result = await launchWindowsMpv( const result = await launchWindowsMpv(
+108 -5
View File
@@ -1,4 +1,5 @@
import fs from 'node:fs'; import fs from 'node:fs';
import net from 'node:net';
import { spawn, spawnSync } from 'node:child_process'; import { spawn, spawnSync } from 'node:child_process';
import { isLogFileEnabled } from '../../shared/log-files'; import { isLogFileEnabled } from '../../shared/log-files';
import { buildMpvLaunchModeArgs } from '../../shared/mpv-launch-mode'; import { buildMpvLaunchModeArgs } from '../../shared/mpv-launch-mode';
@@ -13,6 +14,11 @@ export interface WindowsMpvLaunchDeps {
runWhere: () => { status: number | null; stdout: string; error?: Error }; runWhere: () => { status: number | null; stdout: string; error?: Error };
fileExists: (candidate: string) => boolean; fileExists: (candidate: string) => boolean;
spawnDetached: (command: string, args: string[], env?: NodeJS.ProcessEnv) => Promise<void>; spawnDetached: (command: string, args: string[], env?: NodeJS.ProcessEnv) => Promise<void>;
isAppControlServerAvailable?: () => Promise<boolean>;
sendAppControlCommand?: (
argv: string[],
) => Promise<{ ok: boolean; unavailable?: boolean; error?: string }>;
waitForSocketReady?: (socketPath: string, timeoutMs: number) => Promise<boolean>;
showError: (title: string, content: string) => void; showError: (title: string, content: string) => void;
logInfo?: (message: 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 DEFAULT_WINDOWS_MPV_SOCKET = '\\\\.\\pipe\\subminer-socket';
const RUNNING_APP_ATTACH_SOCKET_WAIT_MS = 10000;
async function sleepMs(ms: number): Promise<void> {
await new Promise<void>((resolve) => setTimeout(resolve, ms));
}
async function canConnectSocket(socketPath: string): Promise<boolean> {
return await new Promise<boolean>((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<boolean> {
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 { function readExtraArgValue(extraArgs: string[], flag: string): string | undefined {
let value: string | undefined; let value: string | undefined;
@@ -101,6 +145,31 @@ function readExtraArgValue(extraArgs: string[], flag: string): string | undefine
return value; 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( export function buildWindowsMpvLaunchArgs(
targets: string[], targets: string[],
extraArgs: string[] = [], extraArgs: string[] = [],
@@ -110,8 +179,7 @@ export function buildWindowsMpvLaunchArgs(
pluginRuntimeConfig?: SubminerPluginRuntimeScriptOptConfig, pluginRuntimeConfig?: SubminerPluginRuntimeScriptOptConfig,
): string[] { ): string[] {
const launchIdle = targets.length === 0; const launchIdle = targets.length === 0;
const inputIpcServer = const inputIpcServer = resolveWindowsMpvInputIpcServer(extraArgs);
readExtraArgValue(extraArgs, '--input-ipc-server') ?? DEFAULT_WINDOWS_MPV_SOCKET;
const scriptEntrypoint = const scriptEntrypoint =
typeof pluginEntrypointPath === 'string' && pluginEntrypointPath.trim().length > 0 typeof pluginEntrypointPath === 'string' && pluginEntrypointPath.trim().length > 0
? `--script=${pluginEntrypointPath.trim()}` ? `--script=${pluginEntrypointPath.trim()}`
@@ -210,6 +278,16 @@ export async function launchWindowsMpv(
} }
const hasLogLevel = pluginRuntimeConfig?.logLevel !== undefined; const hasLogLevel = pluginRuntimeConfig?.logLevel !== undefined;
const hasLogRotation = pluginRuntimeConfig?.logRotation !== 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 = const launchEnv =
hasLogLevel || hasLogRotation hasLogLevel || hasLogRotation
? { ? {
@@ -219,16 +297,15 @@ export async function launchWindowsMpv(
: {}), : {}),
} }
: undefined; : undefined;
const inputIpcServer = resolveWindowsMpvInputIpcServer(extraArgs);
const launchArgs = buildWindowsMpvLaunchArgs( const launchArgs = buildWindowsMpvLaunchArgs(
targets, targets,
extraArgs, extraArgs,
binaryPath, binaryPath,
runtimePluginEntrypointPath, runtimePluginEntrypointPath,
launchMode, launchMode,
pluginRuntimeConfig, effectivePluginRuntimeConfig,
); );
const inputIpcServer =
readExtraArgValue(launchArgs, '--input-ipc-server') ?? DEFAULT_WINDOWS_MPV_SOCKET;
deps.logInfo?.( deps.logInfo?.(
[ [
`Launching mpv: mpvPath=${mpvPath}`, `Launching mpv: mpvPath=${mpvPath}`,
@@ -236,9 +313,28 @@ export async function launchWindowsMpv(
`bundledPlugin=${runtimePluginEntrypointPath ?? 'not injected'}`, `bundledPlugin=${runtimePluginEntrypointPath ?? 'not injected'}`,
`installedPlugin=${installedPlugin?.installed ? (installedPlugin.path ?? 'unknown') : 'none'}`, `installedPlugin=${installedPlugin?.installed ? (installedPlugin.path ?? 'unknown') : 'none'}`,
`targets=${targets.length}`, `targets=${targets.length}`,
`attachRunningApp=${shouldAttachRunningApp ? 'yes' : 'no'}`,
].join('; '), ].join('; '),
); );
await deps.spawnDetached(mpvPath, launchArgs, launchEnv); 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 }; return { ok: true, mpvPath };
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : String(error); const message = error instanceof Error ? error.message : String(error);
@@ -250,6 +346,10 @@ export async function launchWindowsMpv(
export function createWindowsMpvLaunchDeps(options: { export function createWindowsMpvLaunchDeps(options: {
getEnv?: (name: string) => string | undefined; getEnv?: (name: string) => string | undefined;
fileExists?: (candidate: string) => boolean; fileExists?: (candidate: string) => boolean;
isAppControlServerAvailable?: () => Promise<boolean>;
sendAppControlCommand?: (
argv: string[],
) => Promise<{ ok: boolean; unavailable?: boolean; error?: string }>;
showError: (title: string, content: string) => void; showError: (title: string, content: string) => void;
logInfo?: (message: string) => void; logInfo?: (message: string) => void;
}): WindowsMpvLaunchDeps { }): WindowsMpvLaunchDeps {
@@ -267,6 +367,9 @@ export function createWindowsMpvLaunchDeps(options: {
}; };
}, },
fileExists: options.fileExists ?? defaultWindowsMpvFileExists, fileExists: options.fileExists ?? defaultWindowsMpvFileExists,
isAppControlServerAvailable: options.isAppControlServerAvailable,
sendAppControlCommand: options.sendAppControlCommand,
waitForSocketReady,
logInfo: options.logInfo, logInfo: options.logInfo,
spawnDetached: (command, args, env) => spawnDetached: (command, args, env) =>
new Promise((resolve, reject) => { new Promise((resolve, reject) => {