diff --git a/src/main-entry.ts b/src/main-entry.ts index 730f151b..98a279da 100644 --- a/src/main-entry.ts +++ b/src/main-entry.ts @@ -91,8 +91,8 @@ if (shouldHandleHelpOnlyAtEntry(process.argv, process.env)) { if (shouldHandleLaunchMpvAtEntry(process.argv, process.env)) { const sanitizedEnv = sanitizeLaunchMpvEnv(process.env); applySanitizedEnv(sanitizedEnv); - void app.whenReady().then(() => { - const result = launchWindowsMpv( + void app.whenReady().then(async () => { + const result = await launchWindowsMpv( normalizeLaunchMpvTargets(process.argv), createWindowsMpvLaunchDeps({ getEnv: (name) => process.env[name], diff --git a/src/main.ts b/src/main.ts index d60fc1f3..b5a94301 100644 --- a/src/main.ts +++ b/src/main.ts @@ -367,7 +367,11 @@ import { detectWindowsMpvShortcuts, resolveWindowsMpvShortcutPaths, } from './main/runtime/windows-mpv-shortcuts'; -import { createWindowsMpvLaunchDeps, launchWindowsMpv } from './main/runtime/windows-mpv-launch'; +import { + createWindowsMpvLaunchDeps, + getConfiguredWindowsMpvPathStatus, + launchWindowsMpv, +} from './main/runtime/windows-mpv-launch'; import { createWaitForMpvConnectedHandler } from './main/runtime/jellyfin-remote-connection'; import { createPrepareYoutubePlaybackInMpvHandler } from './main/runtime/youtube-playback-launch'; import { shouldEnsureTrayOnStartupForInitialArgs } from './main/runtime/startup-tray-policy'; @@ -2216,6 +2220,7 @@ const openFirstRunSetupWindowHandler = createOpenFirstRunSetupWindowHandler({ }), getSetupSnapshot: async () => { const snapshot = await firstRunSetupService.getSetupStatus(); + const mpvExecutablePath = getResolvedConfig().mpv.executablePath; return { configReady: snapshot.configReady, dictionaryCount: snapshot.dictionaryCount, @@ -2223,7 +2228,8 @@ const openFirstRunSetupWindowHandler = createOpenFirstRunSetupWindowHandler({ externalYomitanConfigured: snapshot.externalYomitanConfigured, pluginStatus: snapshot.pluginStatus, pluginInstallPathSummary: snapshot.pluginInstallPathSummary, - mpvExecutablePath: getResolvedConfig().mpv.executablePath, + mpvExecutablePath, + mpvExecutablePathStatus: getConfiguredWindowsMpvPathStatus(mpvExecutablePath), windowsMpvShortcuts: snapshot.windowsMpvShortcuts, message: firstRunSetupMessage, }; diff --git a/src/main/runtime/first-run-setup-window.test.ts b/src/main/runtime/first-run-setup-window.test.ts index 27e2b208..897b66fa 100644 --- a/src/main/runtime/first-run-setup-window.test.ts +++ b/src/main/runtime/first-run-setup-window.test.ts @@ -17,6 +17,7 @@ test('buildFirstRunSetupHtml renders macchiato setup actions and disabled finish pluginStatus: 'required', pluginInstallPathSummary: null, mpvExecutablePath: '', + mpvExecutablePathStatus: 'blank', windowsMpvShortcuts: { supported: false, startMenuEnabled: true, @@ -45,6 +46,7 @@ test('buildFirstRunSetupHtml switches plugin action to reinstall when already in pluginStatus: 'installed', pluginInstallPathSummary: '/tmp/mpv', mpvExecutablePath: 'C:\\Program Files\\mpv\\mpv.exe', + mpvExecutablePathStatus: 'configured', windowsMpvShortcuts: { supported: true, startMenuEnabled: true, @@ -65,6 +67,31 @@ test('buildFirstRunSetupHtml switches plugin action to reinstall when already in ); }); +test('buildFirstRunSetupHtml marks an invalid configured mpv path as invalid', () => { + const html = buildFirstRunSetupHtml({ + configReady: true, + dictionaryCount: 1, + canFinish: true, + externalYomitanConfigured: false, + pluginStatus: 'installed', + pluginInstallPathSummary: '/tmp/mpv', + mpvExecutablePath: 'C:\\Broken\\mpv.exe', + mpvExecutablePathStatus: 'invalid', + windowsMpvShortcuts: { + supported: true, + startMenuEnabled: true, + desktopEnabled: true, + startMenuInstalled: false, + desktopInstalled: false, + status: 'optional', + }, + message: null, + }); + + assert.match(html, />Invalid { const html = buildFirstRunSetupHtml({ configReady: false, @@ -74,6 +101,7 @@ test('buildFirstRunSetupHtml explains the config blocker when setup is missing c pluginStatus: 'required', pluginInstallPathSummary: null, mpvExecutablePath: '', + mpvExecutablePathStatus: 'blank', windowsMpvShortcuts: { supported: false, startMenuEnabled: true, @@ -97,6 +125,7 @@ test('buildFirstRunSetupHtml explains external yomitan mode and keeps finish ena pluginStatus: 'installed', pluginInstallPathSummary: null, mpvExecutablePath: '', + mpvExecutablePathStatus: 'blank', windowsMpvShortcuts: { supported: false, startMenuEnabled: true, @@ -208,6 +237,7 @@ test('closing incomplete first-run setup quits app outside background mode', asy pluginStatus: 'required', pluginInstallPathSummary: null, mpvExecutablePath: '', + mpvExecutablePathStatus: 'blank', windowsMpvShortcuts: { supported: false, startMenuEnabled: true, diff --git a/src/main/runtime/first-run-setup-window.ts b/src/main/runtime/first-run-setup-window.ts index c52199cb..08daf671 100644 --- a/src/main/runtime/first-run-setup-window.ts +++ b/src/main/runtime/first-run-setup-window.ts @@ -39,6 +39,7 @@ export interface FirstRunSetupHtmlModel { pluginStatus: 'installed' | 'required' | 'failed'; pluginInstallPathSummary: string | null; mpvExecutablePath: string; + mpvExecutablePathStatus: 'blank' | 'configured' | 'invalid'; windowsMpvShortcuts: { supported: boolean; startMenuEnabled: boolean; @@ -94,8 +95,23 @@ export function buildFirstRunSetupHtml(model: FirstRunSetupHtmlModel): string { ? 'muted' : 'warn'; const mpvExecutablePathLabel = - model.mpvExecutablePath.trim().length > 0 ? 'Configured' : 'Blank'; - const mpvExecutablePathTone = model.mpvExecutablePath.trim().length > 0 ? 'ready' : 'muted'; + model.mpvExecutablePathStatus === 'configured' + ? 'Configured' + : model.mpvExecutablePathStatus === 'invalid' + ? 'Invalid' + : 'Blank'; + const mpvExecutablePathTone = + model.mpvExecutablePathStatus === 'configured' + ? 'ready' + : model.mpvExecutablePathStatus === 'invalid' + ? 'danger' + : 'muted'; + const mpvExecutablePathCurrent = + model.mpvExecutablePathStatus === 'blank' + ? 'blank (PATH discovery)' + : model.mpvExecutablePathStatus === 'invalid' + ? `${model.mpvExecutablePath} (invalid; file not found)` + : model.mpvExecutablePath; const mpvExecutablePathCard = model.windowsMpvShortcuts.supported ? `
@@ -103,7 +119,7 @@ export function buildFirstRunSetupHtml(model: FirstRunSetupHtmlModel): string {
mpv executable path
Leave blank to auto-discover mpv.exe from PATH.
-
Current: ${escapeHtml(model.mpvExecutablePath.trim().length > 0 ? model.mpvExecutablePath : 'blank (PATH discovery)')}
+
Current: ${escapeHtml(mpvExecutablePathCurrent)}
${renderStatusBadge(mpvExecutablePathLabel, mpvExecutablePathTone)}
diff --git a/src/main/runtime/windows-mpv-launch.test.ts b/src/main/runtime/windows-mpv-launch.test.ts index 54d2e5f6..f6d18cdf 100644 --- a/src/main/runtime/windows-mpv-launch.test.ts +++ b/src/main/runtime/windows-mpv-launch.test.ts @@ -12,7 +12,7 @@ function createDeps(overrides: Partial = {}): WindowsMpvLa getEnv: () => undefined, runWhere: () => ({ status: 1, stdout: '' }), fileExists: () => false, - spawnDetached: () => undefined, + spawnDetached: async () => undefined, showError: () => undefined, ...overrides, }; @@ -134,9 +134,9 @@ test('buildWindowsMpvLaunchArgs mirrors a custom input-ipc-server into script op ); }); -test('launchWindowsMpv reports missing mpv path', () => { +test('launchWindowsMpv reports missing mpv path', async () => { const errors: string[] = []; - const result = launchWindowsMpv( + const result = await launchWindowsMpv( [], createDeps({ showError: (_title, content) => errors.push(content), @@ -148,14 +148,14 @@ test('launchWindowsMpv reports missing mpv path', () => { assert.match(errors[0] ?? '', /mpv\.executablePath/i); }); -test('launchWindowsMpv spawns detached mpv with targets', () => { +test('launchWindowsMpv spawns detached mpv with targets', async () => { const calls: string[] = []; - const result = launchWindowsMpv( + 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', - spawnDetached: (command, args) => { + spawnDetached: async (command, args) => { calls.push(command); calls.push(args.join('|')); }, @@ -173,14 +173,14 @@ test('launchWindowsMpv spawns detached mpv with targets', () => { ]); }); -test('launchWindowsMpv reports spawn failures with path context', () => { +test('launchWindowsMpv reports spawn failures with path context', async () => { const errors: string[] = []; - const result = launchWindowsMpv( + const result = await launchWindowsMpv( [], createDeps({ getEnv: (name) => (name === 'SUBMINER_MPV_PATH' ? 'C:\\mpv\\mpv.exe' : undefined), fileExists: (candidate) => candidate === 'C:\\mpv\\mpv.exe', - spawnDetached: () => { + spawnDetached: async () => { throw new Error('spawn failed'); }, showError: (_title, content) => errors.push(content), @@ -192,3 +192,21 @@ test('launchWindowsMpv reports spawn failures with path context', () => { assert.match(errors[0] ?? '', /Failed to launch mpv/i); assert.match(errors[0] ?? '', /C:\\mpv\\mpv\.exe/i); }); + +test('launchWindowsMpv reports async spawn failures with path context', async () => { + const errors: string[] = []; + const result = await launchWindowsMpv( + [], + createDeps({ + getEnv: (name) => (name === 'SUBMINER_MPV_PATH' ? 'C:\\mpv\\mpv.exe' : undefined), + fileExists: (candidate) => candidate === 'C:\\mpv\\mpv.exe', + spawnDetached: () => Promise.reject(new Error('async spawn failed')), + showError: (_title, content) => errors.push(content), + }), + ); + + assert.equal(result.ok, false); + assert.equal(result.mpvPath, 'C:\\mpv\\mpv.exe'); + assert.match(errors[0] ?? '', /Failed to launch mpv/i); + assert.match(errors[0] ?? '', /async spawn failed/i); +}); diff --git a/src/main/runtime/windows-mpv-launch.ts b/src/main/runtime/windows-mpv-launch.ts index 681edc4b..8cdca7ca 100644 --- a/src/main/runtime/windows-mpv-launch.ts +++ b/src/main/runtime/windows-mpv-launch.ts @@ -5,23 +5,45 @@ export interface WindowsMpvLaunchDeps { getEnv: (name: string) => string | undefined; runWhere: () => { status: number | null; stdout: string; error?: Error }; fileExists: (candidate: string) => boolean; - spawnDetached: (command: string, args: string[]) => void; + spawnDetached: (command: string, args: string[]) => Promise; showError: (title: string, content: string) => void; } +export type ConfiguredWindowsMpvPathStatus = 'blank' | 'configured' | 'invalid'; + function normalizeCandidate(candidate: string | undefined): string { return typeof candidate === 'string' ? candidate.trim() : ''; } +function defaultWindowsMpvFileExists(candidate: string): boolean { + try { + return fs.statSync(candidate).isFile(); + } catch { + return false; + } +} + +export function getConfiguredWindowsMpvPathStatus( + configuredMpvPath = '', + fileExists: (candidate: string) => boolean = defaultWindowsMpvFileExists, +): ConfiguredWindowsMpvPathStatus { + const configPath = normalizeCandidate(configuredMpvPath); + if (!configPath) { + return 'blank'; + } + return fileExists(configPath) ? 'configured' : 'invalid'; +} + export function resolveWindowsMpvPath( deps: WindowsMpvLaunchDeps, configuredMpvPath = '', ): string { const configPath = normalizeCandidate(configuredMpvPath); - if (configPath) { - if (deps.fileExists(configPath)) { - return configPath; - } + const configuredPathStatus = getConfiguredWindowsMpvPathStatus(configPath, deps.fileExists); + if (configuredPathStatus === 'configured') { + return configPath; + } + if (configuredPathStatus === 'invalid') { return ''; } @@ -102,14 +124,14 @@ export function buildWindowsMpvLaunchArgs( ]; } -export function launchWindowsMpv( +export async function launchWindowsMpv( targets: string[], deps: WindowsMpvLaunchDeps, extraArgs: string[] = [], binaryPath?: string, pluginEntrypointPath?: string, configuredMpvPath?: string, -): { ok: boolean; mpvPath: string } { +): Promise<{ ok: boolean; mpvPath: string }> { const normalizedConfiguredPath = normalizeCandidate(configuredMpvPath); const mpvPath = resolveWindowsMpvPath(deps, normalizedConfiguredPath); if (!mpvPath) { @@ -123,7 +145,7 @@ export function launchWindowsMpv( } try { - deps.spawnDetached( + await deps.spawnDetached( mpvPath, buildWindowsMpvLaunchArgs(targets, extraArgs, binaryPath, pluginEntrypointPath), ); @@ -155,21 +177,31 @@ export function createWindowsMpvLaunchDeps(options: { }, fileExists: options.fileExists ?? - ((candidate) => { + defaultWindowsMpvFileExists, + spawnDetached: (command, args) => + new Promise((resolve, reject) => { try { - return fs.statSync(candidate).isFile(); - } catch { - return false; + const child = spawn(command, args, { + detached: true, + stdio: 'ignore', + windowsHide: true, + }); + let settled = false; + child.once('error', (error) => { + if (settled) return; + settled = true; + reject(error); + }); + child.once('spawn', () => { + if (settled) return; + settled = true; + child.unref(); + resolve(); + }); + } catch (error) { + reject(error); } }), - spawnDetached: (command, args) => { - const child = spawn(command, args, { - detached: true, - stdio: 'ignore', - windowsHide: true, - }); - child.unref(); - }, showError: options.showError, }; } diff --git a/src/main/runtime/youtube-playback-runtime.test.ts b/src/main/runtime/youtube-playback-runtime.test.ts index 5208bfcb..8bcf4178 100644 --- a/src/main/runtime/youtube-playback-runtime.test.ts +++ b/src/main/runtime/youtube-playback-runtime.test.ts @@ -29,7 +29,7 @@ test('youtube playback runtime resets flow ownership after a successful run', as resolveYoutubePlaybackUrl: async () => { throw new Error('linux path should not resolve direct playback url'); }, - launchWindowsMpv: () => ({ ok: false }), + launchWindowsMpv: async () => ({ ok: false }), waitForYoutubeMpvConnected: async (timeoutMs) => { calls.push(`wait-connected:${timeoutMs}`); return true; @@ -105,7 +105,7 @@ test('youtube playback runtime resolves the socket path lazily for windows start calls.push(`resolve:${url}:${format}`); return 'https://example.com/direct'; }, - launchWindowsMpv: (_playbackUrl, args) => { + launchWindowsMpv: async (_playbackUrl, args) => { calls.push(`launch:${args.join(' ')}`); return { ok: true, mpvPath: '/usr/bin/mpv' }; }, diff --git a/src/main/runtime/youtube-playback-runtime.ts b/src/main/runtime/youtube-playback-runtime.ts index 0bf08153..f3000742 100644 --- a/src/main/runtime/youtube-playback-runtime.ts +++ b/src/main/runtime/youtube-playback-runtime.ts @@ -17,7 +17,7 @@ export type YoutubePlaybackRuntimeDeps = { setAppOwnedFlowInFlight: (next: boolean) => void; ensureYoutubePlaybackRuntimeReady: () => Promise; resolveYoutubePlaybackUrl: (url: string, format: string) => Promise; - launchWindowsMpv: (playbackUrl: string, args: string[]) => LaunchResult; + launchWindowsMpv: (playbackUrl: string, args: string[]) => Promise; waitForYoutubeMpvConnected: (timeoutMs: number) => Promise; prepareYoutubePlaybackInMpv: (request: { url: string }) => Promise; runYoutubePlaybackFlow: (request: { @@ -77,7 +77,7 @@ export function createYoutubePlaybackRuntime(deps: YoutubePlaybackRuntimeDeps) { if (deps.platform === 'win32' && !deps.getMpvConnected()) { const socketPath = deps.getSocketPath(); - const launchResult = deps.launchWindowsMpv(playbackUrl, [ + const launchResult = await deps.launchWindowsMpv(playbackUrl, [ '--pause=yes', '--ytdl=yes', `--ytdl-format=${deps.mpvYtdlFormat}`,