mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-04-05 00:12:06 -07:00
[codex] Make Windows mpv shortcut self-contained (#40)
This commit is contained in:
@@ -21,7 +21,9 @@ import {
|
||||
getDefaultConfigDir,
|
||||
getSetupStatePath,
|
||||
readSetupState,
|
||||
resolveDefaultMpvInstallPaths,
|
||||
} from '../../src/shared/setup-state.js';
|
||||
import { detectInstalledFirstRunPlugin } from '../../src/main/runtime/first-run-setup-plugin.js';
|
||||
import { hasLauncherExternalYomitanProfileConfig } from '../config.js';
|
||||
|
||||
const SETUP_WAIT_TIMEOUT_MS = 10 * 60 * 1000;
|
||||
@@ -105,6 +107,14 @@ async function ensurePlaybackSetupReady(context: LauncherCommandContext): Promis
|
||||
const ready = await ensureLauncherSetupReady({
|
||||
readSetupState: () => readSetupState(statePath),
|
||||
isExternalYomitanConfigured: () => hasLauncherExternalYomitanProfileConfig(),
|
||||
isPluginInstalled: () => {
|
||||
const installPaths = resolveDefaultMpvInstallPaths(
|
||||
process.platform,
|
||||
os.homedir(),
|
||||
process.env.XDG_CONFIG_HOME,
|
||||
);
|
||||
return detectInstalledFirstRunPlugin(installPaths);
|
||||
},
|
||||
launchSetupApp: () => {
|
||||
const setupArgs = ['--background', '--setup'];
|
||||
if (args.logLevel) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -49,10 +49,17 @@ function parseLogLevel(value: string): LogLevel {
|
||||
}
|
||||
|
||||
function parseBackend(value: string): Backend {
|
||||
if (value === 'auto' || value === 'hyprland' || value === 'x11' || value === 'macos') {
|
||||
if (
|
||||
value === 'auto' ||
|
||||
value === 'hyprland' ||
|
||||
value === 'sway' ||
|
||||
value === 'x11' ||
|
||||
value === 'macos' ||
|
||||
value === 'windows'
|
||||
) {
|
||||
return value as Backend;
|
||||
}
|
||||
fail(`Invalid backend: ${value} (must be auto, hyprland, x11, or macos)`);
|
||||
fail(`Invalid backend: ${value} (must be auto, hyprland, sway, x11, macos, or windows)`);
|
||||
}
|
||||
|
||||
function parseDictionaryTarget(value: string): string {
|
||||
@@ -97,7 +104,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 || '',
|
||||
|
||||
@@ -17,20 +17,20 @@ test('resolveTopLevelCommand respects the app alias after root options', () => {
|
||||
});
|
||||
|
||||
test('parseCliPrograms keeps root options and target when no command is present', () => {
|
||||
const result = parseCliPrograms(['--backend', 'x11', '/tmp/movie.mkv'], 'subminer');
|
||||
const result = parseCliPrograms(['--backend', 'windows', '/tmp/movie.mkv'], 'subminer');
|
||||
|
||||
assert.equal(result.options.backend, 'x11');
|
||||
assert.equal(result.options.backend, 'windows');
|
||||
assert.equal(result.rootTarget, '/tmp/movie.mkv');
|
||||
assert.equal(result.invocations.appInvocation, null);
|
||||
});
|
||||
|
||||
test('parseCliPrograms routes app alias arguments through passthrough mode', () => {
|
||||
const result = parseCliPrograms(
|
||||
['--backend', 'macos', 'bin', '--anilist', '--log-level', 'debug'],
|
||||
['--backend', 'windows', 'bin', '--anilist', '--log-level', 'debug'],
|
||||
'subminer',
|
||||
);
|
||||
|
||||
assert.equal(result.options.backend, 'macos');
|
||||
assert.equal(result.options.backend, 'windows');
|
||||
assert.deepEqual(result.invocations.appInvocation, {
|
||||
appArgs: ['--anilist', '--log-level', 'debug'],
|
||||
});
|
||||
|
||||
@@ -43,7 +43,10 @@ export interface CliInvocations {
|
||||
|
||||
function applyRootOptions(program: Command): void {
|
||||
program
|
||||
.option('-b, --backend <backend>', 'Display backend')
|
||||
.option(
|
||||
'-b, --backend <backend>',
|
||||
'Display backend (auto, hyprland, sway, x11, macos, windows)',
|
||||
)
|
||||
.option('-d, --directory <dir>', 'Directory to browse')
|
||||
.option('-a, --args <args>', 'Pass arguments to MPV')
|
||||
.option('-r, --recursive', 'Search directories recursively')
|
||||
|
||||
@@ -8,6 +8,7 @@ import { EventEmitter } from 'node:events';
|
||||
import type { Args } from './types';
|
||||
import {
|
||||
cleanupPlaybackSession,
|
||||
detectBackend,
|
||||
findAppBinary,
|
||||
launchAppCommandDetached,
|
||||
launchTexthookerOnly,
|
||||
@@ -56,6 +57,22 @@ function createTempSocketPath(): { dir: string; socketPath: string } {
|
||||
return { dir, socketPath: path.join(dir, 'mpv.sock') };
|
||||
}
|
||||
|
||||
function withPlatform<T>(platform: NodeJS.Platform, callback: () => T): T {
|
||||
const originalDescriptor = Object.getOwnPropertyDescriptor(process, 'platform');
|
||||
Object.defineProperty(process, 'platform', {
|
||||
configurable: true,
|
||||
value: platform,
|
||||
});
|
||||
|
||||
try {
|
||||
return callback();
|
||||
} finally {
|
||||
if (originalDescriptor) {
|
||||
Object.defineProperty(process, 'platform', originalDescriptor);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
test('mpv module exposes only canonical socket readiness helper', () => {
|
||||
assert.equal('waitForSocket' in mpvModule, false);
|
||||
});
|
||||
@@ -102,6 +119,12 @@ test('parseMpvArgString preserves empty quoted tokens', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
test('detectBackend resolves windows on win32 auto mode', () => {
|
||||
withPlatform('win32', () => {
|
||||
assert.equal(detectBackend('auto'), 'windows');
|
||||
});
|
||||
});
|
||||
|
||||
test('launchTexthookerOnly exits non-zero when app binary cannot be spawned', () => {
|
||||
const error = withProcessExitIntercept(() => {
|
||||
launchTexthookerOnly('/definitely-missing-subminer-binary', makeArgs());
|
||||
@@ -427,6 +450,21 @@ function withFindAppBinaryEnvSandbox(run: () => void): void {
|
||||
}
|
||||
}
|
||||
|
||||
function withFindAppBinaryPlatformSandbox(
|
||||
platform: NodeJS.Platform,
|
||||
run: (pathModule: typeof path) => void,
|
||||
): void {
|
||||
const originalPlatform = process.platform;
|
||||
try {
|
||||
Object.defineProperty(process, 'platform', { value: platform, configurable: true });
|
||||
withFindAppBinaryEnvSandbox(() =>
|
||||
run(platform === 'win32' ? (path.win32 as typeof path) : path),
|
||||
);
|
||||
} finally {
|
||||
Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true });
|
||||
}
|
||||
}
|
||||
|
||||
function withAccessSyncStub(
|
||||
isExecutablePath: (filePath: string) => boolean,
|
||||
run: () => void,
|
||||
@@ -447,62 +485,197 @@ function withAccessSyncStub(
|
||||
}
|
||||
}
|
||||
|
||||
test('findAppBinary resolves ~/.local/bin/SubMiner.AppImage when it exists', () => {
|
||||
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-test-home-'));
|
||||
const originalHomedir = os.homedir;
|
||||
function withRealpathSyncStub(resolvePath: (filePath: string) => string, run: () => void): void {
|
||||
const originalRealpathSync = fs.realpathSync;
|
||||
try {
|
||||
os.homedir = () => baseDir;
|
||||
const appImage = path.join(baseDir, '.local/bin/SubMiner.AppImage');
|
||||
makeExecutable(appImage);
|
||||
|
||||
withFindAppBinaryEnvSandbox(() => {
|
||||
const result = findAppBinary('/some/other/path/subminer');
|
||||
assert.equal(result, appImage);
|
||||
});
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(fs as any).realpathSync = (filePath: string): string => resolvePath(filePath);
|
||||
run();
|
||||
} finally {
|
||||
os.homedir = originalHomedir;
|
||||
fs.rmSync(baseDir, { recursive: true, force: true });
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(fs as any).realpathSync = originalRealpathSync;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test('findAppBinary resolves /opt/SubMiner/SubMiner.AppImage when ~/.local/bin candidate does not exist', () => {
|
||||
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-test-home-'));
|
||||
test(
|
||||
'findAppBinary resolves ~/.local/bin/SubMiner.AppImage when it exists',
|
||||
{ concurrency: false },
|
||||
() => {
|
||||
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-test-home-'));
|
||||
const originalHomedir = os.homedir;
|
||||
try {
|
||||
os.homedir = () => baseDir;
|
||||
const appImage = path.join(baseDir, '.local/bin/SubMiner.AppImage');
|
||||
makeExecutable(appImage);
|
||||
|
||||
withFindAppBinaryPlatformSandbox('linux', (pathModule) => {
|
||||
const result = findAppBinary('/some/other/path/subminer', pathModule);
|
||||
assert.equal(result, appImage);
|
||||
});
|
||||
} finally {
|
||||
os.homedir = originalHomedir;
|
||||
fs.rmSync(baseDir, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'findAppBinary resolves /opt/SubMiner/SubMiner.AppImage when ~/.local/bin candidate does not exist',
|
||||
{ concurrency: false },
|
||||
() => {
|
||||
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-test-home-'));
|
||||
const originalHomedir = os.homedir;
|
||||
try {
|
||||
os.homedir = () => baseDir;
|
||||
withFindAppBinaryPlatformSandbox('linux', (pathModule) => {
|
||||
withAccessSyncStub(
|
||||
(filePath) => filePath === '/opt/SubMiner/SubMiner.AppImage',
|
||||
() => {
|
||||
const result = findAppBinary('/some/other/path/subminer', pathModule);
|
||||
assert.equal(result, '/opt/SubMiner/SubMiner.AppImage');
|
||||
},
|
||||
);
|
||||
});
|
||||
} finally {
|
||||
os.homedir = originalHomedir;
|
||||
fs.rmSync(baseDir, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'findAppBinary finds subminer on PATH when AppImage candidates do not exist',
|
||||
{ concurrency: false },
|
||||
() => {
|
||||
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-test-path-'));
|
||||
const originalHomedir = os.homedir;
|
||||
const originalPath = process.env.PATH;
|
||||
try {
|
||||
os.homedir = () => baseDir;
|
||||
// No AppImage candidates in empty home dir; place subminer wrapper on PATH
|
||||
const binDir = path.join(baseDir, 'bin');
|
||||
const wrapperPath = path.join(binDir, 'subminer');
|
||||
makeExecutable(wrapperPath);
|
||||
process.env.PATH = `${binDir}${path.delimiter}${originalPath ?? ''}`;
|
||||
|
||||
withFindAppBinaryPlatformSandbox('linux', (pathModule) => {
|
||||
withAccessSyncStub(
|
||||
(filePath) => filePath === wrapperPath,
|
||||
() => {
|
||||
// selfPath must differ from wrapperPath so the self-check does not exclude it
|
||||
const result = findAppBinary(path.join(baseDir, 'launcher', 'subminer'), pathModule);
|
||||
assert.equal(result, wrapperPath);
|
||||
},
|
||||
);
|
||||
});
|
||||
} finally {
|
||||
os.homedir = originalHomedir;
|
||||
process.env.PATH = originalPath;
|
||||
fs.rmSync(baseDir, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'findAppBinary excludes PATH matches that canonicalize to the launcher path',
|
||||
{ concurrency: false },
|
||||
() => {
|
||||
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-test-realpath-'));
|
||||
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');
|
||||
const canonicalPath = path.join(baseDir, 'launch', 'subminer');
|
||||
makeExecutable(wrapperPath);
|
||||
process.env.PATH = `${binDir}${path.delimiter}${originalPath ?? ''}`;
|
||||
|
||||
withFindAppBinaryPlatformSandbox('linux', (pathModule) => {
|
||||
withAccessSyncStub(
|
||||
(filePath) => filePath === wrapperPath,
|
||||
() => {
|
||||
withRealpathSyncStub(
|
||||
(filePath) => {
|
||||
if (filePath === canonicalPath || filePath === wrapperPath) {
|
||||
return canonicalPath;
|
||||
}
|
||||
return filePath;
|
||||
},
|
||||
() => {
|
||||
const result = findAppBinary(canonicalPath, pathModule);
|
||||
assert.equal(result, null);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
} finally {
|
||||
os.homedir = originalHomedir;
|
||||
process.env.PATH = originalPath;
|
||||
fs.rmSync(baseDir, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
test('findAppBinary resolves Windows install paths when present', { concurrency: false }, () => {
|
||||
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-test-win-'));
|
||||
const originalHomedir = os.homedir;
|
||||
const originalLocalAppData = process.env.LOCALAPPDATA;
|
||||
try {
|
||||
os.homedir = () => baseDir;
|
||||
withFindAppBinaryEnvSandbox(() => {
|
||||
process.env.LOCALAPPDATA = path.win32.join(baseDir, 'AppData', 'Local');
|
||||
const appExe = path.win32.join(
|
||||
baseDir,
|
||||
'AppData',
|
||||
'Local',
|
||||
'Programs',
|
||||
'SubMiner',
|
||||
'SubMiner.exe',
|
||||
);
|
||||
|
||||
withFindAppBinaryPlatformSandbox('win32', (pathModule) => {
|
||||
withAccessSyncStub(
|
||||
(filePath) => filePath === '/opt/SubMiner/SubMiner.AppImage',
|
||||
(filePath) => filePath === appExe,
|
||||
() => {
|
||||
const result = findAppBinary('/some/other/path/subminer');
|
||||
assert.equal(result, '/opt/SubMiner/SubMiner.AppImage');
|
||||
const result = findAppBinary(
|
||||
pathModule.join(baseDir, 'launcher', 'SubMiner.exe'),
|
||||
pathModule,
|
||||
);
|
||||
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 finds subminer on PATH when AppImage candidates do not exist', () => {
|
||||
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-test-path-'));
|
||||
test('findAppBinary resolves SubMiner.exe on PATH on Windows', { concurrency: false }, () => {
|
||||
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;
|
||||
// No AppImage candidates in empty home dir; place subminer wrapper on PATH
|
||||
const binDir = path.join(baseDir, 'bin');
|
||||
const wrapperPath = path.join(binDir, 'subminer');
|
||||
const binDir = path.win32.join(baseDir, 'bin');
|
||||
const wrapperPath = path.win32.join(binDir, 'SubMiner.exe');
|
||||
makeExecutable(wrapperPath);
|
||||
process.env.PATH = `${binDir}${path.delimiter}${originalPath ?? ''}`;
|
||||
process.env.PATH = `${binDir}${path.win32.delimiter}${originalPath ?? ''}`;
|
||||
|
||||
withFindAppBinaryEnvSandbox(() => {
|
||||
withFindAppBinaryPlatformSandbox('win32', (pathModule) => {
|
||||
withAccessSyncStub(
|
||||
(filePath) => filePath === wrapperPath,
|
||||
() => {
|
||||
// selfPath must differ from wrapperPath so the self-check does not exclude it
|
||||
const result = findAppBinary(path.join(baseDir, 'launcher', 'subminer'));
|
||||
const result = findAppBinary(
|
||||
pathModule.join(baseDir, 'launcher', 'SubMiner.exe'),
|
||||
pathModule,
|
||||
);
|
||||
assert.equal(result, wrapperPath);
|
||||
},
|
||||
);
|
||||
@@ -513,3 +686,42 @@ test('findAppBinary finds subminer on PATH when AppImage candidates do not exist
|
||||
fs.rmSync(baseDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test(
|
||||
'findAppBinary resolves a Windows install directory to SubMiner.exe',
|
||||
{ concurrency: false },
|
||||
() => {
|
||||
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-test-win-dir-'));
|
||||
const originalHomedir = os.homedir;
|
||||
const originalSubminerBinaryPath = process.env.SUBMINER_BINARY_PATH;
|
||||
try {
|
||||
os.homedir = () => baseDir;
|
||||
const installDir = path.win32.join(baseDir, 'Programs', 'SubMiner');
|
||||
const appExe = path.win32.join(installDir, 'SubMiner.exe');
|
||||
process.env.SUBMINER_BINARY_PATH = installDir;
|
||||
fs.mkdirSync(installDir, { recursive: true });
|
||||
fs.writeFileSync(appExe, '#!/bin/sh\nexit 0\n');
|
||||
fs.chmodSync(appExe, 0o755);
|
||||
|
||||
const originalPlatform = process.platform;
|
||||
try {
|
||||
Object.defineProperty(process, 'platform', { value: 'win32', configurable: true });
|
||||
const result = findAppBinary(
|
||||
path.win32.join(baseDir, 'launcher', 'SubMiner.exe'),
|
||||
path.win32,
|
||||
);
|
||||
assert.equal(result, appExe);
|
||||
} finally {
|
||||
Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true });
|
||||
}
|
||||
} finally {
|
||||
os.homedir = originalHomedir;
|
||||
if (originalSubminerBinaryPath === undefined) {
|
||||
delete process.env.SUBMINER_BINARY_PATH;
|
||||
} else {
|
||||
process.env.SUBMINER_BINARY_PATH = originalSubminerBinaryPath;
|
||||
}
|
||||
fs.rmSync(baseDir, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
121
launcher/mpv.ts
121
launcher/mpv.ts
@@ -14,11 +14,11 @@ import {
|
||||
isExecutable,
|
||||
resolveBinaryPathCandidate,
|
||||
resolveCommandInvocation,
|
||||
realpathMaybe,
|
||||
isYoutubeTarget,
|
||||
uniqueNormalizedLangCodes,
|
||||
sleep,
|
||||
normalizeLangCode,
|
||||
realpathMaybe,
|
||||
} from './util.js';
|
||||
|
||||
export const state = {
|
||||
@@ -35,6 +35,8 @@ type SpawnTarget = {
|
||||
args: string[];
|
||||
};
|
||||
|
||||
type PathModule = Pick<typeof path, 'join' | 'extname' | 'delimiter' | 'sep' | 'resolve'>;
|
||||
|
||||
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;
|
||||
@@ -225,6 +227,7 @@ export function makeTempDir(prefix: string): string {
|
||||
|
||||
export function detectBackend(backend: Backend): Exclude<Backend, 'auto'> {
|
||||
if (backend !== 'auto') return backend;
|
||||
if (process.platform === 'win32') return 'windows';
|
||||
if (process.platform === 'darwin') return 'macos';
|
||||
const xdgCurrentDesktop = (process.env.XDG_CURRENT_DESKTOP || '').toLowerCase();
|
||||
const xdgSessionDesktop = (process.env.XDG_SESSION_DESKTOP || '').toLowerCase();
|
||||
@@ -243,18 +246,49 @@ export function detectBackend(backend: Backend): Exclude<Backend, 'auto'> {
|
||||
fail('Could not detect display backend');
|
||||
}
|
||||
|
||||
function resolveMacAppBinaryCandidate(candidate: string): string {
|
||||
function resolveAppBinaryCandidate(candidate: string, pathModule: PathModule = path): string {
|
||||
const direct = resolveBinaryPathCandidate(candidate);
|
||||
if (!direct) return '';
|
||||
|
||||
if (process.platform !== 'darwin') {
|
||||
return isExecutable(direct) ? direct : '';
|
||||
if (process.platform === 'win32') {
|
||||
try {
|
||||
if (fs.existsSync(direct) && fs.statSync(direct).isDirectory()) {
|
||||
for (const candidateBinary of ['SubMiner.exe', 'subminer.exe']) {
|
||||
const nestedCandidate = pathModule.join(direct, candidateBinary);
|
||||
if (isExecutable(nestedCandidate)) {
|
||||
return nestedCandidate;
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
if (isExecutable(direct)) {
|
||||
return direct;
|
||||
}
|
||||
|
||||
if (!pathModule.extname(direct)) {
|
||||
for (const extension of ['.exe', '.cmd', '.bat']) {
|
||||
const withExtension = `${direct}${extension}`;
|
||||
if (isExecutable(withExtension)) {
|
||||
return withExtension;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
if (isExecutable(direct)) {
|
||||
return direct;
|
||||
}
|
||||
|
||||
if (process.platform !== 'darwin') {
|
||||
return '';
|
||||
}
|
||||
|
||||
const appIndex = direct.indexOf('.app/');
|
||||
const appPath =
|
||||
direct.endsWith('.app') && direct.includes('.app')
|
||||
@@ -265,8 +299,8 @@ function resolveMacAppBinaryCandidate(candidate: string): string {
|
||||
if (!appPath) return '';
|
||||
|
||||
const candidates = [
|
||||
path.join(appPath, 'Contents', 'MacOS', 'SubMiner'),
|
||||
path.join(appPath, 'Contents', 'MacOS', 'subminer'),
|
||||
pathModule.join(appPath, 'Contents', 'MacOS', 'SubMiner'),
|
||||
pathModule.join(appPath, 'Contents', 'MacOS', 'subminer'),
|
||||
];
|
||||
|
||||
for (const candidateBinary of candidates) {
|
||||
@@ -278,37 +312,78 @@ function resolveMacAppBinaryCandidate(candidate: string): string {
|
||||
return '';
|
||||
}
|
||||
|
||||
export function findAppBinary(selfPath: string): string | null {
|
||||
function findCommandOnPath(candidates: string[], pathModule: PathModule = path): string {
|
||||
const pathDirs = getPathEnv().split(pathModule.delimiter);
|
||||
for (const candidateName of candidates) {
|
||||
for (const dir of pathDirs) {
|
||||
if (!dir) continue;
|
||||
|
||||
const directCandidate = pathModule.join(dir, candidateName);
|
||||
if (isExecutable(directCandidate)) {
|
||||
return directCandidate;
|
||||
}
|
||||
|
||||
if (process.platform === 'win32' && !pathModule.extname(candidateName)) {
|
||||
for (const extension of ['.exe', '.cmd', '.bat']) {
|
||||
const extendedCandidate = pathModule.join(dir, `${candidateName}${extension}`);
|
||||
if (isExecutable(extendedCandidate)) {
|
||||
return extendedCandidate;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
export function findAppBinary(selfPath: string, pathModule: PathModule = path): 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, pathModule);
|
||||
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, `${pathModule.sep}Local`) ||
|
||||
pathModule.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(pathModule.join(localAppData, 'Programs', 'SubMiner', 'SubMiner.exe'));
|
||||
candidates.push(pathModule.join(programFiles, 'SubMiner', 'SubMiner.exe'));
|
||||
candidates.push(pathModule.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'));
|
||||
candidates.push(
|
||||
pathModule.join(os.homedir(), 'Applications/SubMiner.app/Contents/MacOS/SubMiner'),
|
||||
);
|
||||
candidates.push(
|
||||
pathModule.join(os.homedir(), 'Applications/SubMiner.app/Contents/MacOS/subminer'),
|
||||
);
|
||||
} else {
|
||||
candidates.push(pathModule.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, pathModule);
|
||||
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'],
|
||||
pathModule,
|
||||
);
|
||||
|
||||
if (fromPath) {
|
||||
const resolvedSelf = realpathMaybe(selfPath);
|
||||
@@ -634,7 +709,9 @@ export async function startMpv(
|
||||
mpvArgs.push(`--input-ipc-server=${socketPath}`);
|
||||
mpvArgs.push(target);
|
||||
|
||||
const mpvTarget = resolveCommandInvocation('mpv', mpvArgs);
|
||||
const mpvTarget = resolveCommandInvocation('mpv', mpvArgs, {
|
||||
normalizeWindowsShellArgs: false,
|
||||
});
|
||||
state.mpvProc = spawn(mpvTarget.command, mpvTarget.args, { stdio: 'inherit' });
|
||||
}
|
||||
|
||||
@@ -1076,7 +1153,9 @@ export function launchMpvIdleDetached(
|
||||
);
|
||||
mpvArgs.push(`--log-file=${getMpvLogPath()}`);
|
||||
mpvArgs.push(`--input-ipc-server=${socketPath}`);
|
||||
const mpvTarget = resolveCommandInvocation('mpv', mpvArgs);
|
||||
const mpvTarget = resolveCommandInvocation('mpv', mpvArgs, {
|
||||
normalizeWindowsShellArgs: false,
|
||||
});
|
||||
const proc = spawn(mpvTarget.command, mpvTarget.args, {
|
||||
stdio: 'ignore',
|
||||
detached: true,
|
||||
|
||||
@@ -116,6 +116,36 @@ test('ensureLauncherSetupReady bypasses setup gate when external yomitan is conf
|
||||
assert.deepEqual(calls, []);
|
||||
});
|
||||
|
||||
test('ensureLauncherSetupReady bypasses setup gate when plugin is already installed', async () => {
|
||||
const calls: string[] = [];
|
||||
|
||||
const ready = await ensureLauncherSetupReady({
|
||||
readSetupState: () => ({
|
||||
version: 3,
|
||||
status: 'cancelled',
|
||||
completedAt: null,
|
||||
completionSource: null,
|
||||
yomitanSetupMode: null,
|
||||
lastSeenYomitanDictionaryCount: 0,
|
||||
pluginInstallStatus: 'unknown',
|
||||
pluginInstallPathSummary: null,
|
||||
windowsMpvShortcutPreferences: { startMenuEnabled: true, desktopEnabled: true },
|
||||
windowsMpvShortcutLastStatus: 'unknown',
|
||||
}),
|
||||
isPluginInstalled: () => true,
|
||||
launchSetupApp: () => {
|
||||
calls.push('launch');
|
||||
},
|
||||
sleep: async () => undefined,
|
||||
now: () => 0,
|
||||
timeoutMs: 5_000,
|
||||
pollIntervalMs: 100,
|
||||
});
|
||||
|
||||
assert.equal(ready, true);
|
||||
assert.deepEqual(calls, []);
|
||||
});
|
||||
|
||||
test('ensureLauncherSetupReady fails on timeout/cancelled state', async () => {
|
||||
const result = await ensureLauncherSetupReady({
|
||||
readSetupState: () => ({
|
||||
@@ -132,10 +162,73 @@ test('ensureLauncherSetupReady fails on timeout/cancelled state', async () => {
|
||||
}),
|
||||
launchSetupApp: () => undefined,
|
||||
sleep: async () => undefined,
|
||||
now: () => 0,
|
||||
now: (() => {
|
||||
let value = 0;
|
||||
return () => (value += 100);
|
||||
})(),
|
||||
timeoutMs: 5_000,
|
||||
pollIntervalMs: 100,
|
||||
});
|
||||
|
||||
assert.equal(result, false);
|
||||
});
|
||||
|
||||
test('ensureLauncherSetupReady ignores stale cancelled state after launching setup app', async () => {
|
||||
let reads = 0;
|
||||
|
||||
const result = await ensureLauncherSetupReady({
|
||||
readSetupState: () => {
|
||||
reads += 1;
|
||||
if (reads <= 2) {
|
||||
return {
|
||||
version: 3,
|
||||
status: 'cancelled',
|
||||
completedAt: null,
|
||||
completionSource: null,
|
||||
yomitanSetupMode: null,
|
||||
lastSeenYomitanDictionaryCount: 0,
|
||||
pluginInstallStatus: 'unknown',
|
||||
pluginInstallPathSummary: null,
|
||||
windowsMpvShortcutPreferences: { startMenuEnabled: true, desktopEnabled: true },
|
||||
windowsMpvShortcutLastStatus: 'unknown',
|
||||
};
|
||||
}
|
||||
if (reads === 3) {
|
||||
return {
|
||||
version: 3,
|
||||
status: 'in_progress',
|
||||
completedAt: null,
|
||||
completionSource: null,
|
||||
yomitanSetupMode: null,
|
||||
lastSeenYomitanDictionaryCount: 0,
|
||||
pluginInstallStatus: 'unknown',
|
||||
pluginInstallPathSummary: null,
|
||||
windowsMpvShortcutPreferences: { startMenuEnabled: true, desktopEnabled: true },
|
||||
windowsMpvShortcutLastStatus: 'unknown',
|
||||
};
|
||||
}
|
||||
return {
|
||||
version: 3,
|
||||
status: 'completed',
|
||||
completedAt: '2026-03-07T00:00:00.000Z',
|
||||
completionSource: 'legacy_auto_detected',
|
||||
yomitanSetupMode: 'internal',
|
||||
lastSeenYomitanDictionaryCount: 1,
|
||||
pluginInstallStatus: 'installed',
|
||||
pluginInstallPathSummary: '/tmp/mpv',
|
||||
windowsMpvShortcutPreferences: { startMenuEnabled: true, desktopEnabled: true },
|
||||
windowsMpvShortcutLastStatus: 'unknown',
|
||||
};
|
||||
},
|
||||
launchSetupApp: () => undefined,
|
||||
sleep: async () => undefined,
|
||||
now: (() => {
|
||||
let value = 0;
|
||||
return () => (value += 100);
|
||||
})(),
|
||||
timeoutMs: 5_000,
|
||||
pollIntervalMs: 100,
|
||||
});
|
||||
|
||||
assert.equal(result, true);
|
||||
});
|
||||
|
||||
@@ -6,15 +6,24 @@ export async function waitForSetupCompletion(deps: {
|
||||
now: () => number;
|
||||
timeoutMs: number;
|
||||
pollIntervalMs: number;
|
||||
ignoreInitialCancelledState?: boolean;
|
||||
}): Promise<'completed' | 'cancelled' | 'timeout'> {
|
||||
const deadline = deps.now() + deps.timeoutMs;
|
||||
let ignoringCancelled = deps.ignoreInitialCancelledState === true;
|
||||
|
||||
while (deps.now() <= deadline) {
|
||||
const state = deps.readSetupState();
|
||||
if (isSetupCompleted(state)) {
|
||||
return 'completed';
|
||||
}
|
||||
if (ignoringCancelled && state != null && state.status !== 'cancelled') {
|
||||
ignoringCancelled = false;
|
||||
}
|
||||
if (state?.status === 'cancelled') {
|
||||
if (ignoringCancelled) {
|
||||
await deps.sleep(deps.pollIntervalMs);
|
||||
continue;
|
||||
}
|
||||
return 'cancelled';
|
||||
}
|
||||
await deps.sleep(deps.pollIntervalMs);
|
||||
@@ -26,6 +35,7 @@ export async function waitForSetupCompletion(deps: {
|
||||
export async function ensureLauncherSetupReady(deps: {
|
||||
readSetupState: () => SetupState | null;
|
||||
isExternalYomitanConfigured?: () => boolean;
|
||||
isPluginInstalled?: () => boolean;
|
||||
launchSetupApp: () => void;
|
||||
sleep: (ms: number) => Promise<void>;
|
||||
now: () => number;
|
||||
@@ -35,11 +45,18 @@ export async function ensureLauncherSetupReady(deps: {
|
||||
if (deps.isExternalYomitanConfigured?.()) {
|
||||
return true;
|
||||
}
|
||||
if (isSetupCompleted(deps.readSetupState())) {
|
||||
if (deps.isPluginInstalled?.()) {
|
||||
return true;
|
||||
}
|
||||
const initialState = deps.readSetupState();
|
||||
if (isSetupCompleted(initialState)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
deps.launchSetupApp();
|
||||
const result = await waitForSetupCompletion(deps);
|
||||
const result = await waitForSetupCompletion({
|
||||
...deps,
|
||||
ignoreInitialCancelledState: initialState?.status === 'cancelled',
|
||||
});
|
||||
return result === 'completed';
|
||||
}
|
||||
|
||||
@@ -79,7 +79,7 @@ function createSmokeCase(name: string): SmokeCase {
|
||||
|
||||
writeExecutable(
|
||||
fakeMpvPath,
|
||||
`#!/usr/bin/env node
|
||||
`#!/usr/bin/env bun
|
||||
const fs = require('node:fs');
|
||||
const net = require('node:net');
|
||||
const path = require('node:path');
|
||||
@@ -118,7 +118,7 @@ process.on('SIGTERM', closeAndExit);
|
||||
|
||||
writeExecutable(
|
||||
fakeAppPath,
|
||||
`#!/usr/bin/env node
|
||||
`#!/usr/bin/env bun
|
||||
const fs = require('node:fs');
|
||||
|
||||
const logPath = ${JSON.stringify(fakeAppLogPath)};
|
||||
|
||||
@@ -68,7 +68,7 @@ export const DEFAULT_MPV_SUBMINER_ARGS = [
|
||||
] as const;
|
||||
|
||||
export type LogLevel = 'debug' | 'info' | 'warn' | 'error';
|
||||
export type Backend = 'auto' | 'hyprland' | 'x11' | 'macos';
|
||||
export type Backend = 'auto' | 'hyprland' | 'sway' | 'x11' | 'macos' | 'windows';
|
||||
export type JimakuLanguagePreference = 'ja' | 'en' | 'none';
|
||||
|
||||
export interface LauncherAiConfig {
|
||||
|
||||
@@ -244,13 +244,19 @@ export function inferWhisperLanguage(langCodes: string[], fallback: string): str
|
||||
return fallback;
|
||||
}
|
||||
|
||||
export interface CommandInvocationOptions {
|
||||
normalizeWindowsShellArgs?: boolean;
|
||||
}
|
||||
|
||||
export function resolveCommandInvocation(
|
||||
executable: string,
|
||||
args: string[],
|
||||
options: CommandInvocationOptions = {},
|
||||
): { command: string; args: string[] } {
|
||||
if (process.platform !== 'win32') {
|
||||
return { command: executable, args };
|
||||
}
|
||||
const { normalizeWindowsShellArgs = true } = options;
|
||||
|
||||
const resolvedExecutable = resolveExecutablePath(executable) ?? executable;
|
||||
const extension = path.extname(resolvedExecutable).toLowerCase();
|
||||
@@ -267,7 +273,9 @@ export function resolveCommandInvocation(
|
||||
command: bashTarget.command,
|
||||
args: [
|
||||
normalizeWindowsShellArg(resolvedExecutable, bashTarget.flavor),
|
||||
...args.map((arg) => normalizeWindowsShellArg(arg, bashTarget.flavor)),
|
||||
...args.map((arg) =>
|
||||
normalizeWindowsShellArgs ? normalizeWindowsShellArg(arg, bashTarget.flavor) : arg,
|
||||
),
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -280,7 +288,9 @@ export function resolveCommandInvocation(
|
||||
command: bashTarget.command,
|
||||
args: [
|
||||
normalizeWindowsShellArg(resolvedExecutable, bashTarget.flavor),
|
||||
...args.map((arg) => normalizeWindowsShellArg(arg, bashTarget.flavor)),
|
||||
...args.map((arg) =>
|
||||
normalizeWindowsShellArgs ? normalizeWindowsShellArg(arg, bashTarget.flavor) : arg,
|
||||
),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user