mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-06-09 03:13:32 -07:00
Fix Windows mpv shortcut attachment to background app (#105)
This commit is contained in:
@@ -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
@@ -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<void> {
|
||||
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);
|
||||
},
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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<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;
|
||||
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<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 {
|
||||
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<boolean>;
|
||||
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) => {
|
||||
|
||||
Reference in New Issue
Block a user