mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-03-20 12:11:28 -07:00
Prepare Windows release and signing process (#16)
This commit is contained in:
@@ -2,7 +2,11 @@ import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { parseLauncherYoutubeSubgenConfig } from './config/youtube-subgen-config.js';
|
||||
import { parseLauncherJellyfinConfig } from './config/jellyfin-config.js';
|
||||
import { parsePluginRuntimeConfigContent } from './config/plugin-runtime-config.js';
|
||||
import {
|
||||
getPluginConfigCandidates,
|
||||
parsePluginRuntimeConfigContent,
|
||||
} from './config/plugin-runtime-config.js';
|
||||
import { getDefaultSocketPath } from './types.js';
|
||||
|
||||
test('parseLauncherYoutubeSubgenConfig keeps only valid typed values', () => {
|
||||
const parsed = parseLauncherYoutubeSubgenConfig({
|
||||
@@ -97,3 +101,18 @@ auto_start_pause_until_ready = off
|
||||
assert.equal(parsed.autoStartVisibleOverlay, false);
|
||||
assert.equal(parsed.autoStartPauseUntilReady, false);
|
||||
});
|
||||
|
||||
test('getPluginConfigCandidates resolves Windows mpv script-opts path', () => {
|
||||
assert.deepEqual(
|
||||
getPluginConfigCandidates({
|
||||
platform: 'win32',
|
||||
homeDir: 'C:\\Users\\tester',
|
||||
appDataDir: 'C:\\Users\\tester\\AppData\\Roaming',
|
||||
}),
|
||||
['C:\\Users\\tester\\AppData\\Roaming\\mpv\\script-opts\\subminer.conf'],
|
||||
);
|
||||
});
|
||||
|
||||
test('getDefaultSocketPath returns Windows named pipe default', () => {
|
||||
assert.equal(getDefaultSocketPath('win32'), '\\\\.\\pipe\\subminer-socket');
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -5,12 +5,36 @@ import { log } from '../log.js';
|
||||
import type { LogLevel, PluginRuntimeConfig } from '../types.js';
|
||||
import { DEFAULT_SOCKET_PATH } from '../types.js';
|
||||
|
||||
export function getPluginConfigCandidates(): string[] {
|
||||
const xdgConfigHome = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config');
|
||||
function getPlatformPath(platform: NodeJS.Platform): typeof path.posix | typeof path.win32 {
|
||||
return platform === 'win32' ? path.win32 : path.posix;
|
||||
}
|
||||
|
||||
export function getPluginConfigCandidates(options?: {
|
||||
platform?: NodeJS.Platform;
|
||||
homeDir?: string;
|
||||
xdgConfigHome?: string;
|
||||
appDataDir?: string;
|
||||
}): string[] {
|
||||
const platform = options?.platform ?? process.platform;
|
||||
const homeDir = options?.homeDir ?? os.homedir();
|
||||
const platformPath = getPlatformPath(platform);
|
||||
|
||||
if (platform === 'win32') {
|
||||
const appDataDir =
|
||||
options?.appDataDir?.trim() ||
|
||||
process.env.APPDATA?.trim() ||
|
||||
platformPath.join(homeDir, 'AppData', 'Roaming');
|
||||
return [platformPath.join(appDataDir, 'mpv', 'script-opts', 'subminer.conf')];
|
||||
}
|
||||
|
||||
const xdgConfigHome =
|
||||
options?.xdgConfigHome?.trim() ||
|
||||
process.env.XDG_CONFIG_HOME ||
|
||||
platformPath.join(homeDir, '.config');
|
||||
return Array.from(
|
||||
new Set([
|
||||
path.join(xdgConfigHome, 'mpv', 'script-opts', 'subminer.conf'),
|
||||
path.join(os.homedir(), '.config', 'mpv', 'script-opts', 'subminer.conf'),
|
||||
platformPath.join(xdgConfigHome, 'mpv', 'script-opts', 'subminer.conf'),
|
||||
platformPath.join(homeDir, '.config', 'mpv', 'script-opts', 'subminer.conf'),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
24
launcher/log.test.ts
Normal file
24
launcher/log.test.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import path from 'node:path';
|
||||
import { getDefaultMpvLogFile } from './types.js';
|
||||
|
||||
test('getDefaultMpvLogFile uses APPDATA on windows', () => {
|
||||
const resolved = getDefaultMpvLogFile({
|
||||
platform: 'win32',
|
||||
homeDir: 'C:\\Users\\tester',
|
||||
appDataDir: 'C:\\Users\\tester\\AppData\\Roaming',
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
path.normalize(resolved),
|
||||
path.normalize(
|
||||
path.join(
|
||||
'C:\\Users\\tester\\AppData\\Roaming',
|
||||
'SubMiner',
|
||||
'logs',
|
||||
`SubMiner-${new Date().toISOString().slice(0, 10)}.log`,
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
@@ -51,10 +51,16 @@ function runLauncher(argv: string[], env: NodeJS.ProcessEnv): RunResult {
|
||||
}
|
||||
|
||||
function makeTestEnv(homeDir: string, xdgConfigHome: string): NodeJS.ProcessEnv {
|
||||
const pathValue = process.env.Path || process.env.PATH || '';
|
||||
return {
|
||||
...process.env,
|
||||
HOME: homeDir,
|
||||
USERPROFILE: homeDir,
|
||||
APPDATA: xdgConfigHome,
|
||||
LOCALAPPDATA: path.join(homeDir, 'AppData', 'Local'),
|
||||
XDG_CONFIG_HOME: xdgConfigHome,
|
||||
PATH: pathValue,
|
||||
Path: pathValue,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -75,13 +81,14 @@ test('config path uses XDG_CONFIG_HOME override', () => {
|
||||
test('config discovery ignores lowercase subminer candidate', () => {
|
||||
const homeDir = '/home/tester';
|
||||
const xdgConfigHome = '/tmp/xdg-config';
|
||||
const expected = path.join(xdgConfigHome, 'SubMiner', 'config.jsonc');
|
||||
const foundPaths = new Set([path.join(xdgConfigHome, 'subminer', 'config.json')]);
|
||||
const expected = path.posix.join(xdgConfigHome, 'SubMiner', 'config.jsonc');
|
||||
const foundPaths = new Set([path.posix.join(xdgConfigHome, 'subminer', 'config.json')]);
|
||||
|
||||
const resolved = resolveConfigFilePath({
|
||||
xdgConfigHome,
|
||||
homeDir,
|
||||
existsSync: (candidate) => foundPaths.has(path.normalize(candidate)),
|
||||
platform: 'linux',
|
||||
existsSync: (candidate) => foundPaths.has(path.posix.normalize(candidate)),
|
||||
});
|
||||
|
||||
assert.equal(resolved, expected);
|
||||
@@ -138,6 +145,12 @@ test('mpv status exits non-zero when socket is not ready', () => {
|
||||
withTempDir((root) => {
|
||||
const homeDir = path.join(root, 'home');
|
||||
const xdgConfigHome = path.join(root, 'xdg');
|
||||
const socketPath = path.join(root, 'missing.sock');
|
||||
fs.mkdirSync(path.join(xdgConfigHome, 'mpv', 'script-opts'), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(xdgConfigHome, 'mpv', 'script-opts', 'subminer.conf'),
|
||||
`socket_path=${socketPath}\n`,
|
||||
);
|
||||
const result = runLauncher(['mpv', 'status'], makeTestEnv(homeDir, xdgConfigHome));
|
||||
|
||||
assert.equal(result.status, 1);
|
||||
@@ -152,6 +165,7 @@ test('doctor reports checks and exits non-zero without hard dependencies', () =>
|
||||
const env = {
|
||||
...makeTestEnv(homeDir, xdgConfigHome),
|
||||
PATH: '',
|
||||
Path: '',
|
||||
};
|
||||
const result = runLauncher(['doctor'], env);
|
||||
|
||||
@@ -184,7 +198,7 @@ test('youtube command rejects removed --mode option', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('youtube playback generates subtitles before mpv launch', () => {
|
||||
test('youtube playback generates subtitles before mpv launch', { timeout: 15000 }, () => {
|
||||
withTempDir((root) => {
|
||||
const homeDir = path.join(root, 'home');
|
||||
const xdgConfigHome = path.join(root, 'xdg');
|
||||
@@ -194,6 +208,7 @@ test('youtube playback generates subtitles before mpv launch', () => {
|
||||
const mpvCapturePath = path.join(root, 'mpv-order.txt');
|
||||
const mpvArgsPath = path.join(root, 'mpv-args.txt');
|
||||
const socketPath = path.join(root, 'mpv.sock');
|
||||
const bunBinary = JSON.stringify(process.execPath.replace(/\\/g, '/'));
|
||||
|
||||
fs.mkdirSync(binDir, { recursive: true });
|
||||
fs.mkdirSync(path.join(xdgConfigHome, 'SubMiner'), { recursive: true });
|
||||
@@ -264,7 +279,7 @@ for arg in "$@"; do
|
||||
;;
|
||||
esac
|
||||
done
|
||||
bun -e "const net=require('node:net'); const fs=require('node:fs'); const socket=process.argv[1]; try { fs.rmSync(socket,{force:true}); } catch {} const server=net.createServer((conn)=>conn.end()); server.listen(socket,()=>setTimeout(()=>server.close(()=>process.exit(0)),250));" "$socket_path"
|
||||
${bunBinary} -e "const net=require('node:net'); const fs=require('node:fs'); const socket=process.argv[1]; try { fs.rmSync(socket,{force:true}); } catch {} const server=net.createServer((conn)=>conn.end()); server.listen(socket,()=>setTimeout(()=>server.close(()=>process.exit(0)),250));" "$socket_path"
|
||||
`,
|
||||
'utf8',
|
||||
);
|
||||
@@ -272,7 +287,8 @@ bun -e "const net=require('node:net'); const fs=require('node:fs'); const socket
|
||||
|
||||
const env = {
|
||||
...makeTestEnv(homeDir, xdgConfigHome),
|
||||
PATH: `${binDir}${path.delimiter}${process.env.PATH || ''}`,
|
||||
PATH: `${binDir}${path.delimiter}${process.env.Path || process.env.PATH || ''}`,
|
||||
Path: `${binDir}${path.delimiter}${process.env.Path || process.env.PATH || ''}`,
|
||||
SUBMINER_APPIMAGE_PATH: appPath,
|
||||
SUBMINER_TEST_YTDLP_LOG: ytdlpLogPath,
|
||||
SUBMINER_TEST_MPV_ORDER: mpvCapturePath,
|
||||
@@ -280,7 +296,7 @@ bun -e "const net=require('node:net'); const fs=require('node:fs'); const socket
|
||||
};
|
||||
const result = runLauncher(['youtube', 'https://www.youtube.com/watch?v=test123'], env);
|
||||
|
||||
assert.equal(result.status, 0);
|
||||
assert.equal(result.status, 0, `stdout:\n${result.stdout}\nstderr:\n${result.stderr}`);
|
||||
assert.equal(fs.readFileSync(mpvCapturePath, 'utf8').trim(), 'generated-before-mpv');
|
||||
assert.match(
|
||||
fs.readFileSync(mpvArgsPath, 'utf8'),
|
||||
@@ -528,15 +544,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', () => {
|
||||
|
||||
@@ -9,8 +9,10 @@ import { log, fail, getMpvLogPath } from './log.js';
|
||||
import { buildSubminerScriptOpts, resolveAniSkipMetadataForFile } from './aniskip-metadata.js';
|
||||
import {
|
||||
commandExists,
|
||||
getPathEnv,
|
||||
isExecutable,
|
||||
resolveBinaryPathCandidate,
|
||||
resolveCommandInvocation,
|
||||
realpathMaybe,
|
||||
isYoutubeTarget,
|
||||
uniqueNormalizedLangCodes,
|
||||
@@ -27,6 +29,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;
|
||||
@@ -199,7 +206,8 @@ export function findAppBinary(selfPath: string): string | null {
|
||||
if (isExecutable(candidate)) return candidate;
|
||||
}
|
||||
|
||||
const fromPath = process.env.PATH?.split(path.delimiter)
|
||||
const fromPath = getPathEnv()
|
||||
.split(path.delimiter)
|
||||
.map((dir) => path.join(dir, 'subminer'))
|
||||
.find((candidate) => isExecutable(candidate));
|
||||
|
||||
@@ -512,7 +520,8 @@ export async function startMpv(
|
||||
mpvArgs.push(`--input-ipc-server=${socketPath}`);
|
||||
mpvArgs.push(target);
|
||||
|
||||
state.mpvProc = spawn('mpv', mpvArgs, { stdio: 'inherit' });
|
||||
const mpvTarget = resolveCommandInvocation('mpv', mpvArgs);
|
||||
state.mpvProc = spawn(mpvTarget.command, mpvTarget.args, { stdio: 'inherit' });
|
||||
}
|
||||
|
||||
async function waitForOverlayStartCommandSettled(
|
||||
@@ -563,7 +572,8 @@ export async function startOverlay(appPath: string, args: Args, socketPath: stri
|
||||
if (args.logLevel !== 'info') overlayArgs.push('--log-level', args.logLevel);
|
||||
if (args.useTexthooker) overlayArgs.push('--texthooker');
|
||||
|
||||
state.overlayProc = spawn(appPath, overlayArgs, {
|
||||
const target = resolveAppSpawnTarget(appPath, overlayArgs);
|
||||
state.overlayProc = spawn(target.command, target.args, {
|
||||
stdio: 'inherit',
|
||||
env: { ...process.env, SUBMINER_MPV_LOG: getMpvLogPath() },
|
||||
});
|
||||
@@ -682,8 +692,30 @@ 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 };
|
||||
}
|
||||
return resolveCommandInvocation(appPath, 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 +734,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 +762,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 +786,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(),
|
||||
@@ -766,7 +820,8 @@ export function launchMpvIdleDetached(
|
||||
);
|
||||
mpvArgs.push(`--log-file=${getMpvLogPath()}`);
|
||||
mpvArgs.push(`--input-ipc-server=${socketPath}`);
|
||||
const proc = spawn('mpv', mpvArgs, {
|
||||
const mpvTarget = resolveCommandInvocation('mpv', mpvArgs);
|
||||
const proc = spawn(mpvTarget.command, mpvTarget.args, {
|
||||
stdio: 'ignore',
|
||||
detached: true,
|
||||
});
|
||||
|
||||
@@ -7,22 +7,26 @@ test('waitForSetupCompletion resolves completed and cancelled states', async ()
|
||||
const sequence: Array<SetupState | null> = [
|
||||
null,
|
||||
{
|
||||
version: 1,
|
||||
version: 2,
|
||||
status: 'in_progress',
|
||||
completedAt: null,
|
||||
completionSource: null,
|
||||
lastSeenYomitanDictionaryCount: 0,
|
||||
pluginInstallStatus: 'unknown',
|
||||
pluginInstallPathSummary: null,
|
||||
windowsMpvShortcutPreferences: { startMenuEnabled: true, desktopEnabled: true },
|
||||
windowsMpvShortcutLastStatus: 'unknown',
|
||||
},
|
||||
{
|
||||
version: 1,
|
||||
version: 2,
|
||||
status: 'completed',
|
||||
completedAt: '2026-03-07T00:00:00.000Z',
|
||||
completionSource: 'user',
|
||||
lastSeenYomitanDictionaryCount: 1,
|
||||
pluginInstallStatus: 'skipped',
|
||||
pluginInstallPathSummary: null,
|
||||
windowsMpvShortcutPreferences: { startMenuEnabled: true, desktopEnabled: true },
|
||||
windowsMpvShortcutLastStatus: 'skipped',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -50,23 +54,27 @@ test('ensureLauncherSetupReady launches setup app and resumes only after complet
|
||||
if (reads === 1) return null;
|
||||
if (reads === 2) {
|
||||
return {
|
||||
version: 1,
|
||||
version: 2,
|
||||
status: 'in_progress',
|
||||
completedAt: null,
|
||||
completionSource: null,
|
||||
lastSeenYomitanDictionaryCount: 0,
|
||||
pluginInstallStatus: 'unknown',
|
||||
pluginInstallPathSummary: null,
|
||||
windowsMpvShortcutPreferences: { startMenuEnabled: true, desktopEnabled: true },
|
||||
windowsMpvShortcutLastStatus: 'unknown',
|
||||
};
|
||||
}
|
||||
return {
|
||||
version: 1,
|
||||
version: 2,
|
||||
status: 'completed',
|
||||
completedAt: '2026-03-07T00:00:00.000Z',
|
||||
completionSource: 'user',
|
||||
lastSeenYomitanDictionaryCount: 1,
|
||||
pluginInstallStatus: 'installed',
|
||||
pluginInstallPathSummary: '/tmp/mpv',
|
||||
windowsMpvShortcutPreferences: { startMenuEnabled: true, desktopEnabled: true },
|
||||
windowsMpvShortcutLastStatus: 'installed',
|
||||
};
|
||||
},
|
||||
launchSetupApp: () => {
|
||||
@@ -88,13 +96,15 @@ test('ensureLauncherSetupReady launches setup app and resumes only after complet
|
||||
test('ensureLauncherSetupReady fails on timeout/cancelled state', async () => {
|
||||
const result = await ensureLauncherSetupReady({
|
||||
readSetupState: () => ({
|
||||
version: 1,
|
||||
version: 2,
|
||||
status: 'cancelled',
|
||||
completedAt: null,
|
||||
completionSource: null,
|
||||
lastSeenYomitanDictionaryCount: 0,
|
||||
pluginInstallStatus: 'unknown',
|
||||
pluginInstallPathSummary: null,
|
||||
windowsMpvShortcutPreferences: { startMenuEnabled: true, desktopEnabled: true },
|
||||
windowsMpvShortcutLastStatus: 'unknown',
|
||||
}),
|
||||
launchSetupApp: () => undefined,
|
||||
sleep: async () => undefined,
|
||||
|
||||
@@ -3,7 +3,14 @@ import os from 'node:os';
|
||||
export { VIDEO_EXTENSIONS } from '../src/shared/video-extensions.js';
|
||||
|
||||
export const ROFI_THEME_FILE = 'subminer.rasi';
|
||||
export const DEFAULT_SOCKET_PATH = '/tmp/subminer-socket';
|
||||
export function getDefaultSocketPath(platform: NodeJS.Platform = process.platform): string {
|
||||
if (platform === 'win32') {
|
||||
return '\\\\.\\pipe\\subminer-socket';
|
||||
}
|
||||
return '/tmp/subminer-socket';
|
||||
}
|
||||
|
||||
export const DEFAULT_SOCKET_PATH = getDefaultSocketPath();
|
||||
export const DEFAULT_YOUTUBE_PRIMARY_SUB_LANGS = ['ja', 'jpn'];
|
||||
export const DEFAULT_YOUTUBE_SECONDARY_SUB_LANGS = ['en', 'eng'];
|
||||
export const YOUTUBE_SUB_EXTENSIONS = new Set(['.srt', '.vtt', '.ass']);
|
||||
@@ -22,13 +29,21 @@ export const DEFAULT_YOUTUBE_SUBGEN_OUT_DIR = path.join(
|
||||
'subminer',
|
||||
'youtube-subs',
|
||||
);
|
||||
export const DEFAULT_MPV_LOG_FILE = path.join(
|
||||
os.homedir(),
|
||||
'.config',
|
||||
'SubMiner',
|
||||
'logs',
|
||||
`SubMiner-${new Date().toISOString().slice(0, 10)}.log`,
|
||||
);
|
||||
export function getDefaultMpvLogFile(options?: {
|
||||
platform?: NodeJS.Platform;
|
||||
homeDir?: string;
|
||||
appDataDir?: string;
|
||||
}): string {
|
||||
const platform = options?.platform ?? process.platform;
|
||||
const homeDir = options?.homeDir ?? os.homedir();
|
||||
const baseDir =
|
||||
platform === 'win32'
|
||||
? path.join(options?.appDataDir?.trim() || path.join(homeDir, 'AppData', 'Roaming'), 'SubMiner')
|
||||
: path.join(homeDir, '.config', 'SubMiner');
|
||||
return path.join(baseDir, 'logs', `SubMiner-${new Date().toISOString().slice(0, 10)}.log`);
|
||||
}
|
||||
|
||||
export const DEFAULT_MPV_LOG_FILE = getDefaultMpvLogFile();
|
||||
export const DEFAULT_YOUTUBE_YTDL_FORMAT = 'bestvideo*+bestaudio/best';
|
||||
export const DEFAULT_JIMAKU_API_BASE_URL = 'https://jimaku.cc';
|
||||
export const DEFAULT_MPV_SUBMINER_ARGS = [
|
||||
|
||||
191
launcher/util.ts
191
launcher/util.ts
@@ -18,14 +18,139 @@ export function isExecutable(filePath: string): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
export function commandExists(command: string): boolean {
|
||||
const pathEnv = process.env.PATH ?? '';
|
||||
function isRunnableFile(filePath: string): boolean {
|
||||
try {
|
||||
if (!fs.statSync(filePath).isFile()) return false;
|
||||
return process.platform === 'win32' ? true : isExecutable(filePath);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function isPathLikeCommand(command: string): boolean {
|
||||
return (
|
||||
command.includes('/') ||
|
||||
command.includes('\\') ||
|
||||
/^[A-Za-z]:[\\/]/.test(command) ||
|
||||
command.startsWith('.')
|
||||
);
|
||||
}
|
||||
|
||||
function getWindowsPathExts(): string[] {
|
||||
const raw = process.env.PATHEXT ?? '.COM;.EXE;.BAT;.CMD';
|
||||
return raw
|
||||
.split(';')
|
||||
.map((entry) => entry.trim())
|
||||
.filter((entry) => entry.length > 0);
|
||||
}
|
||||
|
||||
export function getPathEnv(): string {
|
||||
const pathKey = Object.keys(process.env).find((key) => key.toLowerCase() === 'path');
|
||||
return pathKey ? (process.env[pathKey] ?? '') : '';
|
||||
}
|
||||
|
||||
function resolveExecutablePath(command: string): string | null {
|
||||
const tryCandidate = (candidate: string): string | null =>
|
||||
isRunnableFile(candidate) ? candidate : null;
|
||||
|
||||
const resolveWindowsCandidate = (candidate: string): string | null => {
|
||||
const direct = tryCandidate(candidate);
|
||||
if (direct) return direct;
|
||||
if (path.extname(candidate)) return null;
|
||||
for (const ext of getWindowsPathExts()) {
|
||||
const withExt = tryCandidate(`${candidate}${ext}`);
|
||||
if (withExt) return withExt;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
if (isPathLikeCommand(command)) {
|
||||
const resolved = path.resolve(resolvePathMaybe(command));
|
||||
return process.platform === 'win32' ? resolveWindowsCandidate(resolved) : tryCandidate(resolved);
|
||||
}
|
||||
|
||||
const pathEnv = getPathEnv();
|
||||
for (const dir of pathEnv.split(path.delimiter)) {
|
||||
if (!dir) continue;
|
||||
const full = path.join(dir, command);
|
||||
if (isExecutable(full)) return true;
|
||||
const candidate = path.join(dir, command);
|
||||
const resolved =
|
||||
process.platform === 'win32' ? resolveWindowsCandidate(candidate) : tryCandidate(candidate);
|
||||
if (resolved) return resolved;
|
||||
}
|
||||
return false;
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalizeWindowsBashArg(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}`;
|
||||
}
|
||||
|
||||
function resolveGitBashExecutable(): string | null {
|
||||
const directCandidates = [
|
||||
'C:\\Program Files\\Git\\bin\\bash.exe',
|
||||
'C:\\Program Files\\Git\\usr\\bin\\bash.exe',
|
||||
];
|
||||
for (const candidate of directCandidates) {
|
||||
if (isRunnableFile(candidate)) return candidate;
|
||||
}
|
||||
|
||||
const gitExecutable = resolveExecutablePath('git');
|
||||
if (!gitExecutable) return null;
|
||||
const gitDir = path.dirname(gitExecutable);
|
||||
const inferredCandidates = [
|
||||
path.resolve(gitDir, '..', 'bin', 'bash.exe'),
|
||||
path.resolve(gitDir, '..', 'usr', 'bin', 'bash.exe'),
|
||||
];
|
||||
for (const candidate of inferredCandidates) {
|
||||
if (isRunnableFile(candidate)) return candidate;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveWindowsBashTarget(): {
|
||||
command: string;
|
||||
flavor: 'git' | 'wsl';
|
||||
} {
|
||||
const gitBash = resolveGitBashExecutable();
|
||||
if (gitBash) {
|
||||
return { command: gitBash, flavor: 'git' };
|
||||
}
|
||||
return {
|
||||
command: resolveExecutablePath('bash') ?? 'bash',
|
||||
flavor: 'wsl',
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeWindowsShellArg(value: string, flavor: 'git' | 'wsl'): string {
|
||||
if (!isPathLikeCommand(value)) {
|
||||
return value;
|
||||
}
|
||||
return flavor === 'git' ? value.replace(/\\/g, '/') : normalizeWindowsBashArg(value);
|
||||
}
|
||||
|
||||
function readShebang(filePath: string): string {
|
||||
try {
|
||||
const fd = fs.openSync(filePath, 'r');
|
||||
try {
|
||||
const buffer = Buffer.alloc(160);
|
||||
const bytesRead = fs.readSync(fd, buffer, 0, buffer.length, 0);
|
||||
return buffer.toString('utf8', 0, bytesRead).split(/\r?\n/, 1)[0] ?? '';
|
||||
} finally {
|
||||
fs.closeSync(fd);
|
||||
}
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
export function commandExists(command: string): boolean {
|
||||
return resolveExecutablePath(command) !== null;
|
||||
}
|
||||
|
||||
export function resolvePathMaybe(input: string): string {
|
||||
@@ -116,6 +241,51 @@ export function inferWhisperLanguage(langCodes: string[], fallback: string): str
|
||||
return fallback;
|
||||
}
|
||||
|
||||
export function resolveCommandInvocation(
|
||||
executable: string,
|
||||
args: string[],
|
||||
): { command: string; args: string[] } {
|
||||
if (process.platform !== 'win32') {
|
||||
return { command: executable, args };
|
||||
}
|
||||
|
||||
const resolvedExecutable = resolveExecutablePath(executable) ?? executable;
|
||||
const extension = path.extname(resolvedExecutable).toLowerCase();
|
||||
if (extension === '.ps1') {
|
||||
return {
|
||||
command: 'powershell.exe',
|
||||
args: ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', resolvedExecutable, ...args],
|
||||
};
|
||||
}
|
||||
|
||||
if (extension === '.sh') {
|
||||
const bashTarget = resolveWindowsBashTarget();
|
||||
return {
|
||||
command: bashTarget.command,
|
||||
args: [
|
||||
normalizeWindowsShellArg(resolvedExecutable, bashTarget.flavor),
|
||||
...args.map((arg) => normalizeWindowsShellArg(arg, bashTarget.flavor)),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
if (!extension) {
|
||||
const shebang = readShebang(resolvedExecutable);
|
||||
if (/^#!.*\b(?:sh|bash)\b/i.test(shebang)) {
|
||||
const bashTarget = resolveWindowsBashTarget();
|
||||
return {
|
||||
command: bashTarget.command,
|
||||
args: [
|
||||
normalizeWindowsShellArg(resolvedExecutable, bashTarget.flavor),
|
||||
...args.map((arg) => normalizeWindowsShellArg(arg, bashTarget.flavor)),
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return { command: resolvedExecutable, args };
|
||||
}
|
||||
|
||||
export function runExternalCommand(
|
||||
executable: string,
|
||||
args: string[],
|
||||
@@ -129,8 +299,13 @@ export function runExternalCommand(
|
||||
const streamOutput = opts.streamOutput === true;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
log('debug', configuredLogLevel, `[${commandLabel}] spawn: ${executable} ${args.join(' ')}`);
|
||||
const child = spawn(executable, args, {
|
||||
const target = resolveCommandInvocation(executable, args);
|
||||
log(
|
||||
'debug',
|
||||
configuredLogLevel,
|
||||
`[${commandLabel}] spawn: ${target.command} ${target.args.join(' ')}`,
|
||||
);
|
||||
const child = spawn(target.command, target.args, {
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
env: { ...process.env, ...opts.env },
|
||||
});
|
||||
@@ -201,7 +376,7 @@ export function runExternalCommand(
|
||||
`[${commandLabel}] exit code ${code ?? 1}`,
|
||||
);
|
||||
if (code !== 0 && !allowFailure) {
|
||||
const commandString = `${executable} ${args.join(' ')}`;
|
||||
const commandString = `${target.command} ${target.args.join(' ')}`;
|
||||
reject(
|
||||
new Error(`Command failed (${commandString}): ${stderr.trim() || `exit code ${code}`}`),
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user