From 78d0da03dd47d90489dda6c8148dc8a794c05a39 Mon Sep 17 00:00:00 2001 From: Kyle Date: Fri, 3 Apr 2026 00:04:04 -0700 Subject: [PATCH] Fix launcher binary discovery and defaults --- launcher/config/args-normalizer.test.ts | 1 + launcher/config/args-normalizer.ts | 2 +- launcher/mpv.test.ts | 72 ++++++++++++++++++- launcher/mpv.ts | 92 +++++++++++++++++++++---- package.json | 1 + 5 files changed, 149 insertions(+), 19 deletions(-) diff --git a/launcher/config/args-normalizer.test.ts b/launcher/config/args-normalizer.test.ts index 579e2734..71cbddc9 100644 --- a/launcher/config/args-normalizer.test.ts +++ b/launcher/config/args-normalizer.test.ts @@ -62,6 +62,7 @@ test('createDefaultArgs normalizes configured language codes and env thread over assert.deepEqual(parsed.youtubeAudioLangs, ['ja', 'jpn', 'en', 'eng']); assert.equal(parsed.whisperThreads, 7); assert.equal(parsed.youtubeWhisperSourceLanguage, 'ja'); + assert.equal(parsed.profile, ''); } finally { if (originalThreads === undefined) { delete process.env.SUBMINER_WHISPER_THREADS; diff --git a/launcher/config/args-normalizer.ts b/launcher/config/args-normalizer.ts index 91aadd76..feb8b0e9 100644 --- a/launcher/config/args-normalizer.ts +++ b/launcher/config/args-normalizer.ts @@ -97,7 +97,7 @@ export function createDefaultArgs(launcherConfig: LauncherYoutubeSubgenConfig): backend: 'auto', directory: '.', recursive: false, - profile: 'subminer', + profile: '', startOverlay: false, whisperBin: process.env.SUBMINER_WHISPER_BIN || launcherConfig.whisperBin || '', whisperModel: process.env.SUBMINER_WHISPER_MODEL || launcherConfig.whisperModel || '', diff --git a/launcher/mpv.test.ts b/launcher/mpv.test.ts index 0f894163..3e8a2df2 100644 --- a/launcher/mpv.test.ts +++ b/launcher/mpv.test.ts @@ -427,6 +427,16 @@ function withFindAppBinaryEnvSandbox(run: () => void): void { } } +function withFindAppBinaryPlatformSandbox(platform: NodeJS.Platform, run: () => void): void { + const originalPlatform = process.platform; + try { + Object.defineProperty(process, 'platform', { value: platform, configurable: true }); + withFindAppBinaryEnvSandbox(run); + } finally { + Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true }); + } +} + function withAccessSyncStub( isExecutablePath: (filePath: string) => boolean, run: () => void, @@ -455,7 +465,7 @@ test('findAppBinary resolves ~/.local/bin/SubMiner.AppImage when it exists', () const appImage = path.join(baseDir, '.local/bin/SubMiner.AppImage'); makeExecutable(appImage); - withFindAppBinaryEnvSandbox(() => { + withFindAppBinaryPlatformSandbox('linux', () => { const result = findAppBinary('/some/other/path/subminer'); assert.equal(result, appImage); }); @@ -470,7 +480,7 @@ test('findAppBinary resolves /opt/SubMiner/SubMiner.AppImage when ~/.local/bin c const originalHomedir = os.homedir; try { os.homedir = () => baseDir; - withFindAppBinaryEnvSandbox(() => { + withFindAppBinaryPlatformSandbox('linux', () => { withAccessSyncStub( (filePath) => filePath === '/opt/SubMiner/SubMiner.AppImage', () => { @@ -497,7 +507,7 @@ test('findAppBinary finds subminer on PATH when AppImage candidates do not exist makeExecutable(wrapperPath); process.env.PATH = `${binDir}${path.delimiter}${originalPath ?? ''}`; - withFindAppBinaryEnvSandbox(() => { + withFindAppBinaryPlatformSandbox('linux', () => { withAccessSyncStub( (filePath) => filePath === wrapperPath, () => { @@ -513,3 +523,59 @@ test('findAppBinary finds subminer on PATH when AppImage candidates do not exist fs.rmSync(baseDir, { recursive: true, force: true }); } }); + +test('findAppBinary resolves Windows install paths when present', () => { + const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-test-win-')); + const originalHomedir = os.homedir; + const originalLocalAppData = process.env.LOCALAPPDATA; + try { + os.homedir = () => baseDir; + process.env.LOCALAPPDATA = path.join(baseDir, 'AppData', 'Local'); + const appExe = path.join(baseDir, 'AppData', 'Local', 'Programs', 'SubMiner', 'SubMiner.exe'); + + withFindAppBinaryPlatformSandbox('win32', () => { + withAccessSyncStub( + (filePath) => filePath === appExe, + () => { + const result = findAppBinary(path.join(baseDir, 'launcher', 'SubMiner.exe')); + assert.equal(result, appExe); + }, + ); + }); + } finally { + os.homedir = originalHomedir; + if (originalLocalAppData === undefined) { + delete process.env.LOCALAPPDATA; + } else { + process.env.LOCALAPPDATA = originalLocalAppData; + } + fs.rmSync(baseDir, { recursive: true, force: true }); + } +}); + +test('findAppBinary resolves SubMiner.exe on PATH on Windows', () => { + const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-test-win-path-')); + const originalHomedir = os.homedir; + const originalPath = process.env.PATH; + try { + os.homedir = () => baseDir; + const binDir = path.join(baseDir, 'bin'); + const wrapperPath = path.join(binDir, 'SubMiner.exe'); + makeExecutable(wrapperPath); + process.env.PATH = `${binDir}${path.delimiter}${originalPath ?? ''}`; + + withFindAppBinaryPlatformSandbox('win32', () => { + withAccessSyncStub( + (filePath) => filePath === wrapperPath, + () => { + const result = findAppBinary(path.join(baseDir, 'launcher', 'SubMiner.exe')); + assert.equal(result, wrapperPath); + }, + ); + }); + } finally { + os.homedir = originalHomedir; + process.env.PATH = originalPath; + fs.rmSync(baseDir, { recursive: true, force: true }); + } +}); diff --git a/launcher/mpv.ts b/launcher/mpv.ts index bf7c7cd3..fde57c74 100644 --- a/launcher/mpv.ts +++ b/launcher/mpv.ts @@ -243,18 +243,44 @@ export function detectBackend(backend: Backend): Exclude { fail('Could not detect display backend'); } -function resolveMacAppBinaryCandidate(candidate: string): string { +function resolveAppBinaryCandidate(candidate: string): string { const direct = resolveBinaryPathCandidate(candidate); if (!direct) return ''; - if (process.platform !== 'darwin') { - return isExecutable(direct) ? direct : ''; - } - if (isExecutable(direct)) { return direct; } + if (process.platform === 'win32') { + try { + if (fs.existsSync(direct) && fs.statSync(direct).isDirectory()) { + for (const candidateBinary of ['SubMiner.exe', 'subminer.exe']) { + const nestedCandidate = path.join(direct, candidateBinary); + if (isExecutable(nestedCandidate)) { + return nestedCandidate; + } + } + } + } catch { + // ignore + } + + if (!path.extname(direct)) { + for (const extension of ['.exe', '.cmd', '.bat']) { + const withExtension = `${direct}${extension}`; + if (isExecutable(withExtension)) { + return withExtension; + } + } + } + + return ''; + } + + if (process.platform !== 'darwin') { + return ''; + } + const appIndex = direct.indexOf('.app/'); const appPath = direct.endsWith('.app') && direct.includes('.app') @@ -278,37 +304,73 @@ function resolveMacAppBinaryCandidate(candidate: string): string { return ''; } +function findCommandOnPath(candidates: string[]): string { + const pathDirs = getPathEnv().split(path.delimiter); + for (const candidateName of candidates) { + for (const dir of pathDirs) { + if (!dir) continue; + + const directCandidate = path.join(dir, candidateName); + if (isExecutable(directCandidate)) { + return directCandidate; + } + + if (process.platform === 'win32' && !path.extname(candidateName)) { + for (const extension of ['.exe', '.cmd', '.bat']) { + const extendedCandidate = path.join(dir, `${candidateName}${extension}`); + if (isExecutable(extendedCandidate)) { + return extendedCandidate; + } + } + } + } + } + + return ''; +} + export function findAppBinary(selfPath: string): string | null { const envPaths = [process.env.SUBMINER_APPIMAGE_PATH, process.env.SUBMINER_BINARY_PATH].filter( (candidate): candidate is string => Boolean(candidate), ); for (const envPath of envPaths) { - const resolved = resolveMacAppBinaryCandidate(envPath); + const resolved = resolveAppBinaryCandidate(envPath); if (resolved) { return resolved; } } const candidates: string[] = []; - if (process.platform === 'darwin') { + if (process.platform === 'win32') { + const localAppData = + process.env.LOCALAPPDATA?.trim() || + (process.env.APPDATA?.trim() || '').replace(/[\\/]Roaming$/i, `${path.sep}Local`) || + path.join(os.homedir(), 'AppData', 'Local'); + const programFiles = process.env.ProgramFiles?.trim() || 'C:\\Program Files'; + const programFilesX86 = process.env['ProgramFiles(x86)']?.trim() || 'C:\\Program Files (x86)'; + candidates.push(path.join(localAppData, 'Programs', 'SubMiner', 'SubMiner.exe')); + candidates.push(path.join(programFiles, 'SubMiner', 'SubMiner.exe')); + candidates.push(path.join(programFilesX86, 'SubMiner', 'SubMiner.exe')); + candidates.push('C:\\SubMiner\\SubMiner.exe'); + } else if (process.platform === 'darwin') { candidates.push('/Applications/SubMiner.app/Contents/MacOS/SubMiner'); candidates.push('/Applications/SubMiner.app/Contents/MacOS/subminer'); candidates.push(path.join(os.homedir(), 'Applications/SubMiner.app/Contents/MacOS/SubMiner')); candidates.push(path.join(os.homedir(), 'Applications/SubMiner.app/Contents/MacOS/subminer')); + } else { + candidates.push(path.join(os.homedir(), '.local/bin/SubMiner.AppImage')); + candidates.push('/opt/SubMiner/SubMiner.AppImage'); } - candidates.push(path.join(os.homedir(), '.local/bin/SubMiner.AppImage')); - candidates.push('/opt/SubMiner/SubMiner.AppImage'); - for (const candidate of candidates) { - if (isExecutable(candidate)) return candidate; + const resolved = resolveAppBinaryCandidate(candidate); + if (resolved) return resolved; } - const fromPath = getPathEnv() - .split(path.delimiter) - .map((dir) => path.join(dir, 'subminer')) - .find((candidate) => isExecutable(candidate)); + const fromPath = findCommandOnPath( + process.platform === 'win32' ? ['SubMiner', 'subminer'] : ['subminer'], + ); if (fromPath) { const resolvedSelf = realpathMaybe(selfPath); diff --git a/package.json b/package.json index 36244581..686a61c5 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "test-yomitan-parser:electron": "bun run build:yomitan && bun build scripts/test-yomitan-parser.ts --format=cjs --target=node --outfile dist/scripts/test-yomitan-parser.js --external electron && env -u ELECTRON_RUN_AS_NODE electron dist/scripts/test-yomitan-parser.js", "build:yomitan": "bun scripts/build-yomitan.mjs", "build:assets": "bun scripts/prepare-build-assets.mjs", + "build:launcher": "bun build ./launcher/main.ts --target=bun --packages=bundle --outfile=dist/launcher/subminer", "build:stats": "cd stats && bun run build", "dev:stats": "cd stats && bun run dev", "build": "bun run build:yomitan && bun run build:stats && tsc -p tsconfig.json && bun run build:renderer && bun run build:assets",