Fix Windows mpv handoff and tray setup (#82)

This commit is contained in:
2026-05-25 01:34:01 -07:00
committed by GitHub
parent 17d97f0b7e
commit 920cbab1bc
31 changed files with 751 additions and 220 deletions
@@ -13,6 +13,7 @@ test('cli command context main deps builder maps state and callbacks', async ()
const build = createBuildCliCommandContextMainDepsHandler({
appState,
onMpvSocketPathChanged: (next, previous) => calls.push(`socket:${previous}->${next}`),
texthookerService: { isRunning: () => false, start: () => null },
getResolvedConfig: () => ({
texthooker: { openBrowser: true },
@@ -121,6 +122,10 @@ test('cli command context main deps builder maps state and callbacks', async ()
assert.equal(deps.getSocketPath(), '/tmp/mpv.sock');
deps.setSocketPath('/tmp/next.sock');
assert.equal(appState.mpvSocketPath, '/tmp/next.sock');
assert.deepEqual(calls, ['socket:/tmp/mpv.sock->/tmp/next.sock']);
deps.setSocketPath('/tmp/next.sock');
assert.deepEqual(calls, ['socket:/tmp/mpv.sock->/tmp/next.sock']);
calls.length = 0;
assert.equal(deps.getTexthookerPort(), 5174);
deps.setTexthookerPort(5175);
assert.equal(appState.texthookerPort, 5175);
@@ -12,6 +12,7 @@ type CliCommandContextMainState = {
export function createBuildCliCommandContextMainDepsHandler(deps: {
appState: CliCommandContextMainState;
setLogLevel?: (level: NonNullable<CliArgs['logLevel']>) => void;
onMpvSocketPathChanged?: (nextSocketPath: string, previousSocketPath: string) => void;
texthookerService: CliCommandContextFactoryDeps['texthookerService'];
getResolvedConfig: () => {
texthooker?: { openBrowser?: boolean };
@@ -74,7 +75,11 @@ export function createBuildCliCommandContextMainDepsHandler(deps: {
setLogLevel: deps.setLogLevel,
getSocketPath: () => deps.appState.mpvSocketPath,
setSocketPath: (socketPath: string) => {
const previousSocketPath = deps.appState.mpvSocketPath;
deps.appState.mpvSocketPath = socketPath;
if (socketPath !== previousSocketPath) {
deps.onMpvSocketPathChanged?.(socketPath, previousSocketPath);
}
},
getMpvClient: () => deps.appState.mpvClient,
showOsd: (text: string) => deps.showMpvOsd(text),
+29 -4
View File
@@ -139,13 +139,38 @@ export function failureMessage(result: RunCommandResult, fallback: string): stri
return detail ? `${fallback}: ${detail}` : fallback;
}
function needsWindowsShell(command: string): boolean {
return process.platform === 'win32' && /\.(cmd|bat)$/i.test(command);
}
function quoteForWindowsShell(value: string): string {
return `"${value.replace(/([&|<>^%!])/g, '^$1').replace(/"/g, '""')}"`;
}
function createDefaultRunCommand(): RunCommand {
return (command, args, options = {}) =>
new Promise((resolve) => {
const child = spawn(command, args, {
env: options.env ?? process.env,
windowsHide: false,
});
const useShell = needsWindowsShell(command);
let child: ReturnType<typeof spawn>;
try {
child = useShell
? spawn(quoteForWindowsShell(command), args.map(quoteForWindowsShell), {
env: options.env ?? process.env,
windowsHide: false,
shell: true,
})
: spawn(command, args, {
env: options.env ?? process.env,
windowsHide: false,
});
} catch (error) {
resolve({
exitCode: 1,
stdout: '',
stderr: error instanceof Error ? error.message : String(error),
});
return;
}
let stdout = '';
let stderr = '';
const timeout = setTimeout(() => {
@@ -1,5 +1,7 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import {
detectBun,
@@ -9,6 +11,7 @@ import {
resolveLauncherInstallTarget,
type BunSnapshot,
} from './command-line-launcher';
import { getRunCommand } from './command-line-launcher-deps';
function createBunSnapshot(status: BunSnapshot['status']): BunSnapshot {
return {
@@ -85,6 +88,48 @@ test('resolveBunInstallCommand prefers winget on Windows', () => {
);
});
test('default runCommand preserves Windows cmd metacharacter args', async (t) => {
if (process.platform !== 'win32') return;
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-cmd-args-'));
const scriptPath = path.join(tempDir, 'argv.cmd');
const outputPath = path.join(tempDir, 'argv.txt');
t.after(() => {
fs.rmSync(tempDir, { recursive: true, force: true });
});
fs.writeFileSync(
scriptPath,
[
'@echo off',
'setlocal DisableDelayedExpansion',
'> "%SUBMINER_ARGV_OUT%" (',
' echo 1=%~1',
' echo 2=%~2',
' echo 3=%~3',
' echo 4=%~4',
' echo 5=%~5',
' echo 6=%~6',
')',
'',
].join('\r\n'),
'utf8',
);
const result = await getRunCommand({})(
scriptPath,
['plain', 'has space', 'a&b', 'x|y', 'p%PATH%q', 'bang!z'],
{
env: { ...process.env, SUBMINER_ARGV_OUT: outputPath },
},
);
assert.equal(result.exitCode, 0, result.stderr);
assert.equal(
fs.readFileSync(outputPath, 'utf8'),
['1=plain', '2=has space', '3=a&b', '4=x|y', '5=p%PATH%q', '6=bang!z', ''].join('\r\n'),
);
});
test('resolveBunInstallCommand falls back to scoop on Windows before official installer', () => {
assert.deepEqual(
resolveBunInstallCommand({
+39 -2
View File
@@ -66,7 +66,8 @@ test('build tray template handler wires actions and init guards', () => {
openTexthookerInBrowser: () => calls.push('texthooker'),
showTexthookerPage: () => true,
showFirstRunSetup: () => true,
openFirstRunSetupWindow: () => calls.push('setup'),
openFirstRunSetupWindow: (force?: boolean) =>
calls.push(force ? 'setup-forced' : 'setup'),
showWindowsMpvLauncherSetup: () => true,
openYomitanSettings: () => calls.push('yomitan'),
openConfigSettingsWindow: () => calls.push('configuration'),
@@ -91,7 +92,7 @@ test('build tray template handler wires actions and init guards', () => {
'texthooker',
'show-texthooker:true',
'setup',
'setup',
'setup-forced',
'yomitan',
'configuration',
'jellyfin',
@@ -102,6 +103,42 @@ test('build tray template handler wires actions and init guards', () => {
]);
});
test('windows mpv launcher tray action force-opens completed setup', () => {
const calls: string[] = [];
const buildTemplate = createBuildTrayMenuTemplateHandler({
buildTrayMenuTemplateRuntime: (handlers) => {
assert.equal(handlers.showFirstRunSetup, false);
assert.equal(handlers.showWindowsMpvLauncherSetup, true);
handlers.openWindowsMpvLauncherSetup();
return [{ label: 'ok' }] as never;
},
initializeOverlayRuntime: () => calls.push('init'),
isOverlayRuntimeInitialized: () => true,
openSessionHelpModal: () => calls.push('help'),
openTexthookerInBrowser: () => calls.push('texthooker'),
showTexthookerPage: () => true,
showFirstRunSetup: () => false,
openFirstRunSetupWindow: (force?: boolean) =>
calls.push(force ? 'setup-forced' : 'setup'),
showWindowsMpvLauncherSetup: () => true,
openYomitanSettings: () => calls.push('yomitan'),
openConfigSettingsWindow: () => calls.push('configuration'),
openJellyfinSetupWindow: () => calls.push('jellyfin'),
isJellyfinConfigured: () => false,
isJellyfinDiscoveryActive: () => false,
toggleJellyfinDiscovery: () => {
calls.push('jellyfin-discovery');
},
platform: 'win32',
openAnilistSetupWindow: () => calls.push('anilist'),
checkForUpdates: () => calls.push('updates'),
quitApp: () => calls.push('quit'),
});
assert.deepEqual(buildTemplate(), [{ label: 'ok' }]);
assert.deepEqual(calls, ['setup-forced']);
});
test('texthooker tray visibility follows websocket server enabled state', () => {
assert.equal(
shouldShowTexthookerTrayEntry({
+2 -2
View File
@@ -61,7 +61,7 @@ export function createBuildTrayMenuTemplateHandler<TMenuItem>(deps: {
openTexthookerInBrowser: () => void;
showTexthookerPage: () => boolean;
showFirstRunSetup: () => boolean;
openFirstRunSetupWindow: () => void;
openFirstRunSetupWindow: (force?: boolean) => void;
showWindowsMpvLauncherSetup: () => boolean;
openYomitanSettings: () => void;
openConfigSettingsWindow: () => void;
@@ -92,7 +92,7 @@ export function createBuildTrayMenuTemplateHandler<TMenuItem>(deps: {
},
showFirstRunSetup: deps.showFirstRunSetup(),
openWindowsMpvLauncherSetup: () => {
deps.openFirstRunSetupWindow();
deps.openFirstRunSetupWindow(true);
},
showWindowsMpvLauncherSetup: deps.showWindowsMpvLauncherSetup(),
openYomitanSettings: () => {
+2 -1
View File
@@ -28,7 +28,8 @@ test('tray main deps builders return mapped handlers', () => {
openTexthookerInBrowser: () => calls.push('texthooker'),
showTexthookerPage: () => true,
showFirstRunSetup: () => true,
openFirstRunSetupWindow: () => calls.push('setup'),
openFirstRunSetupWindow: (force?: boolean) =>
calls.push(force ? 'setup-forced' : 'setup'),
showWindowsMpvLauncherSetup: () => true,
openYomitanSettings: () => calls.push('yomitan'),
openConfigSettingsWindow: () => calls.push('configuration'),
+1 -1
View File
@@ -51,7 +51,7 @@ export function createBuildTrayMenuTemplateMainDepsHandler<TMenuItem>(deps: {
openTexthookerInBrowser: () => void;
showTexthookerPage: () => boolean;
showFirstRunSetup: () => boolean;
openFirstRunSetupWindow: () => void;
openFirstRunSetupWindow: (force?: boolean) => void;
showWindowsMpvLauncherSetup: () => boolean;
openYomitanSettings: () => void;
openConfigSettingsWindow: () => void;
+2 -1
View File
@@ -57,6 +57,7 @@ test('tray menu template contains expected entries and handlers', () => {
false,
);
assert.equal(template[0]!.label, 'Open Help');
assert.equal(template[3]!.label, 'Open SubMiner Setup');
const discovery = template.find((entry) => entry.label === 'Jellyfin Discovery');
assert.equal(discovery?.type, 'checkbox');
assert.equal(discovery?.checked, false);
@@ -102,7 +103,7 @@ test('tray menu template omits first-run setup entry when setup is complete', ()
.filter(Boolean);
assert.equal(labels.includes('Complete Setup'), false);
assert.equal(labels.includes('Manage Windows mpv launcher'), false);
assert.equal(labels.includes('Open SubMiner Setup'), false);
assert.equal(labels.includes('Jellyfin Discovery'), false);
});
+1 -1
View File
@@ -89,7 +89,7 @@ export function buildTrayMenuTemplateRuntime(handlers: TrayMenuActionHandlers):
...(handlers.showWindowsMpvLauncherSetup
? [
{
label: 'Manage Windows mpv launcher',
label: 'Open SubMiner Setup',
click: handlers.openWindowsMpvLauncherSetup,
},
]
+31 -1
View File
@@ -72,6 +72,7 @@ test('buildWindowsMpvLaunchArgs uses explicit SubMiner defaults and targets', ()
'--sub-file-paths=subs;subtitles',
'--sid=auto',
'--secondary-sid=auto',
'--sub-visibility=no',
'--secondary-sub-visibility=no',
'--script-opts=subminer-binary_path=C:\\SubMiner\\SubMiner.exe,subminer-socket_path=\\\\.\\pipe\\subminer-socket',
'C:\\a.mkv',
@@ -100,6 +101,7 @@ test('buildWindowsMpvLaunchArgs inserts maximized launch mode before explicit ex
'--sub-file-paths=subs;subtitles',
'--sid=auto',
'--secondary-sid=auto',
'--sub-visibility=no',
'--secondary-sub-visibility=no',
'--script-opts=subminer-binary_path=C:\\SubMiner\\SubMiner.exe,subminer-socket_path=\\\\.\\pipe\\subminer-socket',
'--window-maximized=yes',
@@ -129,6 +131,7 @@ test('buildWindowsMpvLaunchArgs keeps shortcut-only launches in idle mode', () =
'--sub-file-paths=subs;subtitles',
'--sid=auto',
'--secondary-sid=auto',
'--sub-visibility=no',
'--secondary-sub-visibility=no',
'--script-opts=subminer-binary_path=C:\\SubMiner\\SubMiner.exe,subminer-socket_path=\\\\.\\pipe\\subminer-socket',
],
@@ -154,6 +157,7 @@ test('buildWindowsMpvLaunchArgs mirrors a custom input-ipc-server into script op
'--sub-file-paths=subs;subtitles',
'--sid=auto',
'--secondary-sid=auto',
'--sub-visibility=no',
'--secondary-sub-visibility=no',
'--script-opts=subminer-binary_path=C:\\SubMiner\\SubMiner.exe,subminer-socket_path=\\\\.\\pipe\\custom-subminer-socket',
'--input-ipc-server',
@@ -182,6 +186,7 @@ test('buildWindowsMpvLaunchArgs includes socket script opts when plugin entrypoi
'--sub-file-paths=subs;subtitles',
'--sid=auto',
'--secondary-sid=auto',
'--sub-visibility=no',
'--secondary-sub-visibility=no',
'--script-opts=subminer-socket_path=\\\\.\\pipe\\custom-subminer-socket',
'--input-ipc-server',
@@ -223,6 +228,31 @@ test('buildWindowsMpvLaunchArgs uses runtime plugin config script opts', () => {
assert.match(scriptOpts ?? '', /subminer-aniskip_button_key=F8/);
});
test('buildWindowsMpvLaunchArgs keeps Windows ipc default unless explicitly overridden', () => {
const args = buildWindowsMpvLaunchArgs(
['C:\\video.mkv'],
[],
'C:\\SubMiner\\SubMiner.exe',
'C:\\Program Files\\SubMiner\\resources\\plugin\\subminer\\main.lua',
'normal',
{
socketPath: 'C:\\Users\\tester\\AppData\\Local\\Temp\\subminer-smoke-sock\\subminer.sock',
binaryPath: '',
backend: 'windows',
autoStart: true,
autoStartVisibleOverlay: true,
autoStartPauseUntilReady: true,
texthookerEnabled: false,
aniskipEnabled: true,
aniskipButtonKey: 'F7',
},
);
assert.ok(args.includes('--input-ipc-server=\\\\.\\pipe\\subminer-socket'));
const scriptOpts = args.find((arg) => arg.startsWith('--script-opts='));
assert.match(scriptOpts ?? '', /subminer-socket_path=\\\\\.\\pipe\\subminer-socket/);
});
test('launchWindowsMpv reports missing mpv path', async () => {
const errors: string[] = [];
const result = await launchWindowsMpv(
@@ -258,7 +288,7 @@ test('launchWindowsMpv spawns detached mpv with targets', async () => {
assert.equal(result.mpvPath, 'C:\\mpv\\mpv.exe');
assert.deepEqual(calls, [
'C:\\mpv\\mpv.exe',
'--player-operation-mode=pseudo-gui|--force-window=immediate|--script=C:\\Program Files\\SubMiner\\resources\\plugin\\subminer\\main.lua|--input-ipc-server=\\\\.\\pipe\\subminer-socket|--alang=ja,jp,jpn,japanese,en,eng,english,enus,en-us|--slang=ja,jp,jpn,japanese,en,eng,english,enus,en-us|--sub-auto=fuzzy|--sub-file-paths=subs;subtitles|--sid=auto|--secondary-sid=auto|--secondary-sub-visibility=no|--script-opts=subminer-binary_path=C:\\SubMiner\\SubMiner.exe,subminer-socket_path=\\\\.\\pipe\\subminer-socket|C:\\video.mkv',
'--player-operation-mode=pseudo-gui|--force-window=immediate|--script=C:\\Program Files\\SubMiner\\resources\\plugin\\subminer\\main.lua|--input-ipc-server=\\\\.\\pipe\\subminer-socket|--alang=ja,jp,jpn,japanese,en,eng,english,enus,en-us|--slang=ja,jp,jpn,japanese,en,eng,english,enus,en-us|--sub-auto=fuzzy|--sub-file-paths=subs;subtitles|--sid=auto|--secondary-sid=auto|--sub-visibility=no|--secondary-sub-visibility=no|--script-opts=subminer-binary_path=C:\\SubMiner\\SubMiner.exe,subminer-socket_path=\\\\.\\pipe\\subminer-socket|C:\\video.mkv',
]);
});
+1
View File
@@ -143,6 +143,7 @@ export function buildWindowsMpvLaunchArgs(
'--sub-file-paths=subs;subtitles',
'--sid=auto',
'--secondary-sid=auto',
'--sub-visibility=no',
'--secondary-sub-visibility=no',
...(scriptOpts ? [scriptOpts] : []),
...buildMpvLaunchModeArgs(launchMode),