diff --git a/launcher/config-path.ts b/launcher/config-path.ts index 33786af6..a900e28d 100644 --- a/launcher/config-path.ts +++ b/launcher/config-path.ts @@ -4,6 +4,7 @@ import { resolveConfigFilePath } from '../src/config/path-resolution.js'; export function resolveMainConfigPath(): string { return resolveConfigFilePath({ + appDataDir: process.env.APPDATA, xdgConfigHome: process.env.XDG_CONFIG_HOME, homeDir: os.homedir(), existsSync: fs.existsSync, diff --git a/launcher/config/shared-config-reader.ts b/launcher/config/shared-config-reader.ts index 3d9228bd..adf53643 100644 --- a/launcher/config/shared-config-reader.ts +++ b/launcher/config/shared-config-reader.ts @@ -5,6 +5,7 @@ import { resolveConfigFilePath } from '../../src/config/path-resolution.js'; export function resolveLauncherMainConfigPath(): string { return resolveConfigFilePath({ + appDataDir: process.env.APPDATA, xdgConfigHome: process.env.XDG_CONFIG_HOME, homeDir: os.homedir(), existsSync: fs.existsSync, diff --git a/launcher/main.test.ts b/launcher/main.test.ts index 62af91e6..52b7065f 100644 --- a/launcher/main.test.ts +++ b/launcher/main.test.ts @@ -54,6 +54,9 @@ function makeTestEnv(homeDir: string, xdgConfigHome: string): NodeJS.ProcessEnv return { ...process.env, HOME: homeDir, + USERPROFILE: homeDir, + APPDATA: xdgConfigHome, + LOCALAPPDATA: path.join(homeDir, 'AppData', 'Local'), XDG_CONFIG_HOME: xdgConfigHome, }; } @@ -81,6 +84,7 @@ test('config discovery ignores lowercase subminer candidate', () => { const resolved = resolveConfigFilePath({ xdgConfigHome, homeDir, + platform: 'linux', existsSync: (candidate) => foundPaths.has(path.normalize(candidate)), }); @@ -528,15 +532,20 @@ test('parseJellyfinPreviewAuthResponse returns null for invalid payloads', () => }); test('deriveJellyfinTokenStorePath resolves alongside config path', () => { - const tokenPath = deriveJellyfinTokenStorePath('/home/test/.config/SubMiner/config.jsonc'); - assert.equal(tokenPath, '/home/test/.config/SubMiner/jellyfin-token-store.json'); + const configPath = path.join('/home/test', '.config', 'SubMiner', 'config.jsonc'); + const tokenPath = deriveJellyfinTokenStorePath(configPath); + assert.equal(tokenPath, path.join(path.dirname(configPath), 'jellyfin-token-store.json')); }); test('hasStoredJellyfinSession checks token-store existence', () => { - const exists = (candidate: string): boolean => - candidate === '/home/test/.config/SubMiner/jellyfin-token-store.json'; - assert.equal(hasStoredJellyfinSession('/home/test/.config/SubMiner/config.jsonc', exists), true); - assert.equal(hasStoredJellyfinSession('/home/test/.config/Other/alt.jsonc', exists), false); + const configPath = path.join('/home/test', '.config', 'SubMiner', 'config.jsonc'); + const tokenPath = deriveJellyfinTokenStorePath(configPath); + const exists = (candidate: string): boolean => candidate === tokenPath; + assert.equal(hasStoredJellyfinSession(configPath, exists), true); + assert.equal( + hasStoredJellyfinSession(path.join('/home/test', '.config', 'Other', 'alt.jsonc'), exists), + false, + ); }); test('shouldRetryWithStartForNoRunningInstance matches expected app lifecycle error', () => { diff --git a/launcher/mpv.ts b/launcher/mpv.ts index 5c0651db..e3383e51 100644 --- a/launcher/mpv.ts +++ b/launcher/mpv.ts @@ -27,6 +27,11 @@ export const state = { stopRequested: false, }; +type SpawnTarget = { + command: string; + args: string[]; +}; + const DETACHED_IDLE_MPV_PID_FILE = path.join(os.tmpdir(), 'subminer-idle-mpv.pid'); const OVERLAY_START_SOCKET_READY_TIMEOUT_MS = 900; const OVERLAY_START_COMMAND_SETTLE_TIMEOUT_MS = 700; @@ -682,8 +687,56 @@ function buildAppEnv(): NodeJS.ProcessEnv { return env; } +function maybeCaptureAppArgs(appArgs: string[]): boolean { + const capturePath = process.env.SUBMINER_TEST_CAPTURE?.trim(); + if (!capturePath) { + return false; + } + + fs.writeFileSync(capturePath, `${appArgs.join('\n')}${appArgs.length > 0 ? '\n' : ''}`, 'utf8'); + return true; +} + +function resolveAppSpawnTarget(appPath: string, appArgs: string[]): SpawnTarget { + if (process.platform !== 'win32') { + return { command: appPath, args: appArgs }; + } + + const normalizeBashArg = (value: string): string => { + const normalized = value.replace(/\\/g, '/'); + const driveMatch = normalized.match(/^([A-Za-z]):\/(.*)$/); + if (!driveMatch) { + return normalized; + } + + const [, driveLetter, remainder] = driveMatch; + return `/mnt/${driveLetter!.toLowerCase()}/${remainder}`; + }; + const extension = path.extname(appPath).toLowerCase(); + if (extension === '.ps1') { + return { + command: 'powershell.exe', + args: ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', appPath, ...appArgs], + }; + } + + if (extension === '.sh') { + return { + command: 'bash', + args: [normalizeBashArg(appPath), ...appArgs.map(normalizeBashArg)], + }; + } + + return { command: appPath, args: appArgs }; +} + export function runAppCommandWithInherit(appPath: string, appArgs: string[]): never { - const result = spawnSync(appPath, appArgs, { + if (maybeCaptureAppArgs(appArgs)) { + process.exit(0); + } + + const target = resolveAppSpawnTarget(appPath, appArgs); + const result = spawnSync(target.command, target.args, { stdio: 'inherit', env: buildAppEnv(), }); @@ -702,7 +755,16 @@ export function runAppCommandCaptureOutput( stderr: string; error?: Error; } { - const result = spawnSync(appPath, appArgs, { + if (maybeCaptureAppArgs(appArgs)) { + return { + status: 0, + stdout: '', + stderr: '', + }; + } + + const target = resolveAppSpawnTarget(appPath, appArgs); + const result = spawnSync(target.command, target.args, { env: buildAppEnv(), encoding: 'utf8', }); @@ -721,8 +783,17 @@ export function runAppCommandWithInheritLogged( logLevel: LogLevel, label: string, ): never { - log('debug', logLevel, `${label}: launching app with args: ${appArgs.join(' ')}`); - const result = spawnSync(appPath, appArgs, { + if (maybeCaptureAppArgs(appArgs)) { + process.exit(0); + } + + const target = resolveAppSpawnTarget(appPath, appArgs); + log( + 'debug', + logLevel, + `${label}: launching app with args: ${[target.command, ...target.args].join(' ')}`, + ); + const result = spawnSync(target.command, target.args, { stdio: 'inherit', env: buildAppEnv(), }); @@ -736,7 +807,11 @@ export function runAppCommandWithInheritLogged( export function launchAppStartDetached(appPath: string, logLevel: LogLevel): void { const startArgs = ['--start']; if (logLevel !== 'info') startArgs.push('--log-level', logLevel); - const proc = spawn(appPath, startArgs, { + if (maybeCaptureAppArgs(startArgs)) { + return; + } + const target = resolveAppSpawnTarget(appPath, startArgs); + const proc = spawn(target.command, target.args, { stdio: 'ignore', detached: true, env: buildAppEnv(), diff --git a/scripts/prettier-scope.sh b/scripts/prettier-scope.sh index fda0913d..023af77a 100644 --- a/scripts/prettier-scope.sh +++ b/scripts/prettier-scope.sh @@ -17,4 +17,5 @@ paths=( "src" ) -exec bunx prettier "$@" "${paths[@]}" +BUN_BIN="$(command -v bun.exe || command -v bun)" +exec "$BUN_BIN" x prettier "$@" "${paths[@]}" diff --git a/src/core/services/subsync.test.ts b/src/core/services/subsync.test.ts index 28cb7c6e..3eaa41db 100644 --- a/src/core/services/subsync.test.ts +++ b/src/core/services/subsync.test.ts @@ -147,6 +147,28 @@ function writeExecutableScript(filePath: string, content: string): void { fs.chmodSync(filePath, 0o755); } +function toShellPath(filePath: string): string { + if (process.platform !== 'win32') { + return filePath; + } + + return filePath.replace(/\\/g, '/').replace(/^([A-Za-z]):\//, (_, driveLetter: string) => { + return `/mnt/${driveLetter.toLowerCase()}/`; + }); +} + +function fromShellPath(filePath: string): string { + if (process.platform !== 'win32') { + return filePath; + } + + return filePath + .replace(/^\/mnt\/([a-z])\//, (_, driveLetter: string) => { + return `${driveLetter.toUpperCase()}:/`; + }) + .replace(/\//g, '\\'); +} + test('runSubsyncManual constructs ffsubsync command and returns success', async () => { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subsync-ffsubsync-')); const ffsubsyncLogPath = path.join(tmpDir, 'ffsubsync-args.log'); @@ -162,7 +184,7 @@ test('runSubsyncManual constructs ffsubsync command and returns success', async writeExecutableScript(alassPath, '#!/bin/sh\nexit 0\n'); writeExecutableScript( ffsubsyncPath, - `#!/bin/sh\n: > "${ffsubsyncLogPath}"\nfor arg in "$@"; do printf '%s\\n' "$arg" >> "${ffsubsyncLogPath}"; done\nout=\"\"\nprev=\"\"\nfor arg in \"$@\"; do\n if [ \"$prev\" = \"-o\" ]; then out=\"$arg\"; fi\n prev=\"$arg\"\ndone\nif [ -n \"$out\" ]; then : > \"$out\"; fi\nexit 0\n`, + `#!/bin/sh\n: > "${toShellPath(ffsubsyncLogPath)}"\nfor arg in "$@"; do printf '%s\\n' "$arg" >> "${toShellPath(ffsubsyncLogPath)}"; done\nout=\"\"\nprev=\"\"\nfor arg in \"$@\"; do\n if [ \"$prev\" = \"-o\" ]; then out=\"$arg\"; fi\n prev=\"$arg\"\ndone\nif [ -n \"$out\" ]; then : > \"$out\"; fi\nexit 0\n`, ); const sentCommands: Array> = []; @@ -204,14 +226,14 @@ test('runSubsyncManual constructs ffsubsync command and returns success', async assert.equal(result.ok, true); assert.equal(result.message, 'Subtitle synchronized with ffsubsync'); const ffArgs = fs.readFileSync(ffsubsyncLogPath, 'utf8').trim().split('\n'); - assert.equal(ffArgs[0], videoPath); + assert.equal(ffArgs[0], toShellPath(videoPath)); assert.ok(ffArgs.includes('-i')); - assert.ok(ffArgs.includes(primaryPath)); + assert.ok(ffArgs.includes(toShellPath(primaryPath))); assert.ok(ffArgs.includes('--reference-stream')); assert.ok(ffArgs.includes('0:2')); const ffOutputFlagIndex = ffArgs.indexOf('-o'); assert.equal(ffOutputFlagIndex >= 0, true); - assert.equal(ffArgs[ffOutputFlagIndex + 1], primaryPath); + assert.equal(ffArgs[ffOutputFlagIndex + 1], toShellPath(primaryPath)); assert.equal(sentCommands[0]?.[0], 'sub_add'); assert.deepEqual(sentCommands[1], ['set_property', 'sub-delay', 0]); }); @@ -231,7 +253,7 @@ test('runSubsyncManual writes deterministic _retimed filename when replace is fa writeExecutableScript(alassPath, '#!/bin/sh\nexit 0\n'); writeExecutableScript( ffsubsyncPath, - `#!/bin/sh\n: > "${ffsubsyncLogPath}"\nfor arg in "$@"; do printf '%s\\n' "$arg" >> "${ffsubsyncLogPath}"; done\nout=\"\"\nprev=\"\"\nfor arg in \"$@\"; do\n if [ \"$prev\" = \"-o\" ]; then out=\"$arg\"; fi\n prev=\"$arg\"\ndone\nif [ -n \"$out\" ]; then : > \"$out\"; fi\nexit 0\n`, + `#!/bin/sh\n: > "${toShellPath(ffsubsyncLogPath)}"\nfor arg in "$@"; do printf '%s\\n' "$arg" >> "${toShellPath(ffsubsyncLogPath)}"; done\nout=\"\"\nprev=\"\"\nfor arg in \"$@\"; do\n if [ \"$prev\" = \"-o\" ]; then out=\"$arg\"; fi\n prev=\"$arg\"\ndone\nif [ -n \"$out\" ]; then : > \"$out\"; fi\nexit 0\n`, ); const deps = makeDeps({ @@ -273,7 +295,7 @@ test('runSubsyncManual writes deterministic _retimed filename when replace is fa const ffOutputFlagIndex = ffArgs.indexOf('-o'); assert.equal(ffOutputFlagIndex >= 0, true); const outputPath = ffArgs[ffOutputFlagIndex + 1]; - assert.equal(outputPath, path.join(tmpDir, 'episode.ja_retimed.srt')); + assert.equal(outputPath, toShellPath(path.join(tmpDir, 'episode.ja_retimed.srt'))); }); test('runSubsyncManual reports ffsubsync command failures with details', async () => { @@ -346,7 +368,7 @@ test('runSubsyncManual constructs alass command and returns failure on non-zero writeExecutableScript(ffsubsyncPath, '#!/bin/sh\nexit 0\n'); writeExecutableScript( alassPath, - `#!/bin/sh\n: > "${alassLogPath}"\nfor arg in "$@"; do printf '%s\\n' "$arg" >> "${alassLogPath}"; done\nexit 1\n`, + `#!/bin/sh\n: > "${toShellPath(alassLogPath)}"\nfor arg in "$@"; do printf '%s\\n' "$arg" >> "${toShellPath(alassLogPath)}"; done\nexit 1\n`, ); const deps = makeDeps({ @@ -393,8 +415,8 @@ test('runSubsyncManual constructs alass command and returns failure on non-zero assert.equal(typeof result.message, 'string'); assert.equal(result.message.startsWith('alass synchronization failed'), true); const alassArgs = fs.readFileSync(alassLogPath, 'utf8').trim().split('\n'); - assert.equal(alassArgs[0], sourcePath); - assert.equal(alassArgs[1], primaryPath); + assert.equal(alassArgs[0], toShellPath(sourcePath)); + assert.equal(alassArgs[1], toShellPath(primaryPath)); }); test('runSubsyncManual keeps internal alass source file alive until sync finishes', async () => { @@ -482,7 +504,7 @@ test('runSubsyncManual resolves string sid values from mpv stream properties', a writeExecutableScript(alassPath, '#!/bin/sh\nexit 0\n'); writeExecutableScript( ffsubsyncPath, - `#!/bin/sh\nmkdir -p "${tmpDir}"\n: > "${ffsubsyncLogPath}"\nfor arg in "$@"; do printf '%s\\n' "$arg" >> "${ffsubsyncLogPath}"; done\nprev=""\nout=""\nfor arg in "$@"; do\n if [ "$prev" = "--reference-stream" ]; then :; fi\n if [ "$prev" = "-o" ]; then out="$arg"; fi\n prev="$arg"\ndone\nif [ -n "$out" ]; then : > "$out"; fi`, + `#!/bin/sh\nmkdir -p "${toShellPath(tmpDir)}"\n: > "${toShellPath(ffsubsyncLogPath)}"\nfor arg in "$@"; do printf '%s\\n' "$arg" >> "${toShellPath(ffsubsyncLogPath)}"; done\nprev=""\nout=""\nfor arg in "$@"; do\n if [ "$prev" = "--reference-stream" ]; then :; fi\n if [ "$prev" = "-o" ]; then out="$arg"; fi\n prev="$arg"\ndone\nif [ -n "$out" ]; then : > "$out"; fi`, ); const deps = makeDeps({ @@ -526,5 +548,5 @@ test('runSubsyncManual resolves string sid values from mpv stream properties', a const outputPath = ffArgs[syncOutputIndex + 1]; assert.equal(typeof outputPath, 'string'); assert.ok(outputPath!.length > 0); - assert.equal(fs.readFileSync(outputPath!, 'utf8'), ''); + assert.equal(fs.readFileSync(fromShellPath(outputPath!), 'utf8'), ''); }); diff --git a/src/core/services/yomitan-extension-paths.test.ts b/src/core/services/yomitan-extension-paths.test.ts index f198d14f..a1058615 100644 --- a/src/core/services/yomitan-extension-paths.test.ts +++ b/src/core/services/yomitan-extension-paths.test.ts @@ -8,18 +8,21 @@ import { } from './yomitan-extension-paths'; test('getYomitanExtensionSearchPaths prioritizes generated build output before packaged fallbacks', () => { + const repoRoot = path.resolve('repo'); + const resourcesPath = path.join(path.sep, 'opt', 'SubMiner', 'resources'); + const userDataPath = path.join(path.sep, 'Users', 'kyle', '.config', 'SubMiner'); const searchPaths = getYomitanExtensionSearchPaths({ - cwd: '/repo', - moduleDir: '/repo/dist/core/services', - resourcesPath: '/opt/SubMiner/resources', - userDataPath: '/Users/kyle/.config/SubMiner', + cwd: repoRoot, + moduleDir: path.join(repoRoot, 'dist', 'core', 'services'), + resourcesPath, + userDataPath, }); assert.deepEqual(searchPaths, [ - path.join('/repo', 'build', 'yomitan'), - path.join('/opt/SubMiner/resources', 'yomitan'), + path.join(repoRoot, 'build', 'yomitan'), + path.join(resourcesPath, 'yomitan'), '/usr/share/SubMiner/yomitan', - path.join('/Users/kyle/.config/SubMiner', 'yomitan'), + path.join(userDataPath, 'yomitan'), ]); }); diff --git a/src/subsync/utils.ts b/src/subsync/utils.ts index 74635cac..5dc4ba5a 100644 --- a/src/subsync/utils.ts +++ b/src/subsync/utils.ts @@ -1,5 +1,6 @@ import * as fs from 'fs'; import * as childProcess from 'child_process'; +import * as path from 'path'; import { DEFAULT_CONFIG } from '../config'; import { SubsyncConfig, SubsyncMode } from '../types'; @@ -45,6 +46,42 @@ export interface CommandResult { error?: string; } +function resolveCommandInvocation( + executable: string, + args: string[], +): { command: string; args: string[] } { + if (process.platform !== 'win32') { + return { command: executable, args }; + } + + const normalizeBashArg = (value: string): string => { + const normalized = value.replace(/\\/g, '/'); + const driveMatch = normalized.match(/^([A-Za-z]):\/(.*)$/); + if (!driveMatch) { + return normalized; + } + + const [, driveLetter, remainder] = driveMatch; + return `/mnt/${driveLetter!.toLowerCase()}/${remainder}`; + }; + const extension = path.extname(executable).toLowerCase(); + if (extension === '.ps1') { + return { + command: 'powershell.exe', + args: ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', executable, ...args], + }; + } + + if (extension === '.sh') { + return { + command: 'bash', + args: [normalizeBashArg(executable), ...args.map(normalizeBashArg)], + }; + } + + return { command: executable, args }; +} + export function getSubsyncConfig(config: SubsyncConfig | undefined): SubsyncResolvedConfig { const resolvePath = (value: string | undefined, fallback: string): string => { const trimmed = value?.trim(); @@ -108,7 +145,8 @@ export function runCommand( timeoutMs = 120000, ): Promise { return new Promise((resolve) => { - const child = childProcess.spawn(executable, args, { + const invocation = resolveCommandInvocation(executable, args); + const child = childProcess.spawn(invocation.command, invocation.args, { stdio: ['ignore', 'pipe', 'pipe'], }); let stdout = '';