mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-06-09 15: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';
|
} 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);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user