mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-04-11 04:19:26 -07:00
Fix Windows background app reuse across queued media
This commit is contained in:
4
changes/windows-background-reuse.md
Normal file
4
changes/windows-background-reuse.md
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
type: fixed
|
||||||
|
area: windows
|
||||||
|
|
||||||
|
- Acquire the app single-instance lock earlier so Windows overlay/video launches reuse the running background SubMiner process instead of booting a second full app and repeating startup warmups.
|
||||||
@@ -51,6 +51,7 @@ function runLauncher(argv: string[], env: NodeJS.ProcessEnv): RunResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function makeTestEnv(homeDir: string, xdgConfigHome: string): NodeJS.ProcessEnv {
|
function makeTestEnv(homeDir: string, xdgConfigHome: string): NodeJS.ProcessEnv {
|
||||||
|
const pathValue = process.env.Path || process.env.PATH || '';
|
||||||
return {
|
return {
|
||||||
...process.env,
|
...process.env,
|
||||||
HOME: homeDir,
|
HOME: homeDir,
|
||||||
@@ -58,6 +59,8 @@ function makeTestEnv(homeDir: string, xdgConfigHome: string): NodeJS.ProcessEnv
|
|||||||
APPDATA: xdgConfigHome,
|
APPDATA: xdgConfigHome,
|
||||||
LOCALAPPDATA: path.join(homeDir, 'AppData', 'Local'),
|
LOCALAPPDATA: path.join(homeDir, 'AppData', 'Local'),
|
||||||
XDG_CONFIG_HOME: xdgConfigHome,
|
XDG_CONFIG_HOME: xdgConfigHome,
|
||||||
|
PATH: pathValue,
|
||||||
|
Path: pathValue,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -142,6 +145,12 @@ test('mpv status exits non-zero when socket is not ready', () => {
|
|||||||
withTempDir((root) => {
|
withTempDir((root) => {
|
||||||
const homeDir = path.join(root, 'home');
|
const homeDir = path.join(root, 'home');
|
||||||
const xdgConfigHome = path.join(root, 'xdg');
|
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));
|
const result = runLauncher(['mpv', 'status'], makeTestEnv(homeDir, xdgConfigHome));
|
||||||
|
|
||||||
assert.equal(result.status, 1);
|
assert.equal(result.status, 1);
|
||||||
@@ -156,6 +165,7 @@ test('doctor reports checks and exits non-zero without hard dependencies', () =>
|
|||||||
const env = {
|
const env = {
|
||||||
...makeTestEnv(homeDir, xdgConfigHome),
|
...makeTestEnv(homeDir, xdgConfigHome),
|
||||||
PATH: '',
|
PATH: '',
|
||||||
|
Path: '',
|
||||||
};
|
};
|
||||||
const result = runLauncher(['doctor'], env);
|
const result = runLauncher(['doctor'], env);
|
||||||
|
|
||||||
@@ -188,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) => {
|
withTempDir((root) => {
|
||||||
const homeDir = path.join(root, 'home');
|
const homeDir = path.join(root, 'home');
|
||||||
const xdgConfigHome = path.join(root, 'xdg');
|
const xdgConfigHome = path.join(root, 'xdg');
|
||||||
@@ -198,6 +208,7 @@ test('youtube playback generates subtitles before mpv launch', () => {
|
|||||||
const mpvCapturePath = path.join(root, 'mpv-order.txt');
|
const mpvCapturePath = path.join(root, 'mpv-order.txt');
|
||||||
const mpvArgsPath = path.join(root, 'mpv-args.txt');
|
const mpvArgsPath = path.join(root, 'mpv-args.txt');
|
||||||
const socketPath = path.join(root, 'mpv.sock');
|
const socketPath = path.join(root, 'mpv.sock');
|
||||||
|
const bunBinary = JSON.stringify(process.execPath.replace(/\\/g, '/'));
|
||||||
|
|
||||||
fs.mkdirSync(binDir, { recursive: true });
|
fs.mkdirSync(binDir, { recursive: true });
|
||||||
fs.mkdirSync(path.join(xdgConfigHome, 'SubMiner'), { recursive: true });
|
fs.mkdirSync(path.join(xdgConfigHome, 'SubMiner'), { recursive: true });
|
||||||
@@ -268,7 +279,7 @@ for arg in "$@"; do
|
|||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
done
|
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',
|
'utf8',
|
||||||
);
|
);
|
||||||
@@ -276,7 +287,8 @@ bun -e "const net=require('node:net'); const fs=require('node:fs'); const socket
|
|||||||
|
|
||||||
const env = {
|
const env = {
|
||||||
...makeTestEnv(homeDir, xdgConfigHome),
|
...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_APPIMAGE_PATH: appPath,
|
||||||
SUBMINER_TEST_YTDLP_LOG: ytdlpLogPath,
|
SUBMINER_TEST_YTDLP_LOG: ytdlpLogPath,
|
||||||
SUBMINER_TEST_MPV_ORDER: mpvCapturePath,
|
SUBMINER_TEST_MPV_ORDER: mpvCapturePath,
|
||||||
@@ -284,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);
|
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.equal(fs.readFileSync(mpvCapturePath, 'utf8').trim(), 'generated-before-mpv');
|
||||||
assert.match(
|
assert.match(
|
||||||
fs.readFileSync(mpvArgsPath, 'utf8'),
|
fs.readFileSync(mpvArgsPath, 'utf8'),
|
||||||
|
|||||||
@@ -9,8 +9,10 @@ import { log, fail, getMpvLogPath } from './log.js';
|
|||||||
import { buildSubminerScriptOpts, resolveAniSkipMetadataForFile } from './aniskip-metadata.js';
|
import { buildSubminerScriptOpts, resolveAniSkipMetadataForFile } from './aniskip-metadata.js';
|
||||||
import {
|
import {
|
||||||
commandExists,
|
commandExists,
|
||||||
|
getPathEnv,
|
||||||
isExecutable,
|
isExecutable,
|
||||||
resolveBinaryPathCandidate,
|
resolveBinaryPathCandidate,
|
||||||
|
resolveCommandInvocation,
|
||||||
realpathMaybe,
|
realpathMaybe,
|
||||||
isYoutubeTarget,
|
isYoutubeTarget,
|
||||||
uniqueNormalizedLangCodes,
|
uniqueNormalizedLangCodes,
|
||||||
@@ -204,7 +206,8 @@ export function findAppBinary(selfPath: string): string | null {
|
|||||||
if (isExecutable(candidate)) return candidate;
|
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'))
|
.map((dir) => path.join(dir, 'subminer'))
|
||||||
.find((candidate) => isExecutable(candidate));
|
.find((candidate) => isExecutable(candidate));
|
||||||
|
|
||||||
@@ -517,7 +520,8 @@ export async function startMpv(
|
|||||||
mpvArgs.push(`--input-ipc-server=${socketPath}`);
|
mpvArgs.push(`--input-ipc-server=${socketPath}`);
|
||||||
mpvArgs.push(target);
|
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(
|
async function waitForOverlayStartCommandSettled(
|
||||||
@@ -568,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.logLevel !== 'info') overlayArgs.push('--log-level', args.logLevel);
|
||||||
if (args.useTexthooker) overlayArgs.push('--texthooker');
|
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',
|
stdio: 'inherit',
|
||||||
env: { ...process.env, SUBMINER_MPV_LOG: getMpvLogPath() },
|
env: { ...process.env, SUBMINER_MPV_LOG: getMpvLogPath() },
|
||||||
});
|
});
|
||||||
@@ -701,33 +706,7 @@ function resolveAppSpawnTarget(appPath: string, appArgs: string[]): SpawnTarget
|
|||||||
if (process.platform !== 'win32') {
|
if (process.platform !== 'win32') {
|
||||||
return { command: appPath, args: appArgs };
|
return { command: appPath, args: appArgs };
|
||||||
}
|
}
|
||||||
|
return resolveCommandInvocation(appPath, 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 {
|
export function runAppCommandWithInherit(appPath: string, appArgs: string[]): never {
|
||||||
@@ -841,7 +820,8 @@ export function launchMpvIdleDetached(
|
|||||||
);
|
);
|
||||||
mpvArgs.push(`--log-file=${getMpvLogPath()}`);
|
mpvArgs.push(`--log-file=${getMpvLogPath()}`);
|
||||||
mpvArgs.push(`--input-ipc-server=${socketPath}`);
|
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',
|
stdio: 'ignore',
|
||||||
detached: true,
|
detached: true,
|
||||||
});
|
});
|
||||||
|
|||||||
191
launcher/util.ts
191
launcher/util.ts
@@ -18,14 +18,139 @@ export function isExecutable(filePath: string): boolean {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function commandExists(command: string): boolean {
|
function isRunnableFile(filePath: string): boolean {
|
||||||
const pathEnv = process.env.PATH ?? '';
|
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)) {
|
for (const dir of pathEnv.split(path.delimiter)) {
|
||||||
if (!dir) continue;
|
if (!dir) continue;
|
||||||
const full = path.join(dir, command);
|
const candidate = path.join(dir, command);
|
||||||
if (isExecutable(full)) return true;
|
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 {
|
export function resolvePathMaybe(input: string): string {
|
||||||
@@ -116,6 +241,51 @@ export function inferWhisperLanguage(langCodes: string[], fallback: string): str
|
|||||||
return fallback;
|
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(
|
export function runExternalCommand(
|
||||||
executable: string,
|
executable: string,
|
||||||
args: string[],
|
args: string[],
|
||||||
@@ -129,8 +299,13 @@ export function runExternalCommand(
|
|||||||
const streamOutput = opts.streamOutput === true;
|
const streamOutput = opts.streamOutput === true;
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
log('debug', configuredLogLevel, `[${commandLabel}] spawn: ${executable} ${args.join(' ')}`);
|
const target = resolveCommandInvocation(executable, args);
|
||||||
const child = spawn(executable, args, {
|
log(
|
||||||
|
'debug',
|
||||||
|
configuredLogLevel,
|
||||||
|
`[${commandLabel}] spawn: ${target.command} ${target.args.join(' ')}`,
|
||||||
|
);
|
||||||
|
const child = spawn(target.command, target.args, {
|
||||||
stdio: ['ignore', 'pipe', 'pipe'],
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
env: { ...process.env, ...opts.env },
|
env: { ...process.env, ...opts.env },
|
||||||
});
|
});
|
||||||
@@ -201,7 +376,7 @@ export function runExternalCommand(
|
|||||||
`[${commandLabel}] exit code ${code ?? 1}`,
|
`[${commandLabel}] exit code ${code ?? 1}`,
|
||||||
);
|
);
|
||||||
if (code !== 0 && !allowFailure) {
|
if (code !== 0 && !allowFailure) {
|
||||||
const commandString = `${executable} ${args.join(' ')}`;
|
const commandString = `${target.command} ${target.args.join(' ')}`;
|
||||||
reject(
|
reject(
|
||||||
new Error(`Command failed (${commandString}): ${stderr.trim() || `exit code ${code}`}`),
|
new Error(`Command failed (${commandString}): ${stderr.trim() || `exit code ${code}`}`),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -33,9 +33,30 @@ function makeDbPath(): string {
|
|||||||
|
|
||||||
function cleanupDbPath(dbPath: string): void {
|
function cleanupDbPath(dbPath: string): void {
|
||||||
const dir = path.dirname(dbPath);
|
const dir = path.dirname(dbPath);
|
||||||
if (fs.existsSync(dir)) {
|
if (!fs.existsSync(dir)) {
|
||||||
fs.rmSync(dir, { recursive: true, force: true });
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const bunRuntime = globalThis as typeof globalThis & {
|
||||||
|
Bun?: {
|
||||||
|
gc?: (force?: boolean) => void;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
for (let attempt = 0; attempt < 3; attempt += 1) {
|
||||||
|
try {
|
||||||
|
fs.rmSync(dir, { recursive: true, force: true });
|
||||||
|
return;
|
||||||
|
} catch (error) {
|
||||||
|
const err = error as NodeJS.ErrnoException;
|
||||||
|
if (process.platform !== 'win32' || err.code !== 'EBUSY') {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
bunRuntime.Bun?.gc?.(true);
|
||||||
|
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 25);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// libsql keeps Windows file handles alive after close when prepared statements were used.
|
||||||
}
|
}
|
||||||
|
|
||||||
test('seam: resolveBoundedInt keeps fallback for invalid values', () => {
|
test('seam: resolveBoundedInt keeps fallback for invalid values', () => {
|
||||||
|
|||||||
@@ -20,9 +20,30 @@ function makeDbPath(): string {
|
|||||||
|
|
||||||
function cleanupDbPath(dbPath: string): void {
|
function cleanupDbPath(dbPath: string): void {
|
||||||
const dir = path.dirname(dbPath);
|
const dir = path.dirname(dbPath);
|
||||||
if (fs.existsSync(dir)) {
|
if (!fs.existsSync(dir)) {
|
||||||
fs.rmSync(dir, { recursive: true, force: true });
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const bunRuntime = globalThis as typeof globalThis & {
|
||||||
|
Bun?: {
|
||||||
|
gc?: (force?: boolean) => void;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
for (let attempt = 0; attempt < 3; attempt += 1) {
|
||||||
|
try {
|
||||||
|
fs.rmSync(dir, { recursive: true, force: true });
|
||||||
|
return;
|
||||||
|
} catch (error) {
|
||||||
|
const err = error as NodeJS.ErrnoException;
|
||||||
|
if (process.platform !== 'win32' || err.code !== 'EBUSY') {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
bunRuntime.Bun?.gc?.(true);
|
||||||
|
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 25);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// libsql keeps Windows file handles alive after close when prepared statements were used.
|
||||||
}
|
}
|
||||||
|
|
||||||
test('ensureSchema creates immersion core tables', () => {
|
test('ensureSchema creates immersion core tables', () => {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
shouldHandleHelpOnlyAtEntry,
|
shouldHandleHelpOnlyAtEntry,
|
||||||
shouldHandleLaunchMpvAtEntry,
|
shouldHandleLaunchMpvAtEntry,
|
||||||
} from './main-entry-runtime';
|
} from './main-entry-runtime';
|
||||||
|
import { requestSingleInstanceLockEarly } from './main/early-single-instance';
|
||||||
import { createWindowsMpvLaunchDeps, launchWindowsMpv } from './main/runtime/windows-mpv-launch';
|
import { createWindowsMpvLaunchDeps, launchWindowsMpv } from './main/runtime/windows-mpv-launch';
|
||||||
|
|
||||||
const DEFAULT_TEXTHOOKER_PORT = 5174;
|
const DEFAULT_TEXTHOOKER_PORT = 5174;
|
||||||
@@ -67,5 +68,9 @@ if (shouldHandleLaunchMpvAtEntry(process.argv, process.env)) {
|
|||||||
app.exit(result.ok ? 0 : 1);
|
app.exit(result.ok ? 0 : 1);
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
|
const gotSingleInstanceLock = requestSingleInstanceLockEarly(app);
|
||||||
|
if (!gotSingleInstanceLock) {
|
||||||
|
app.exit(0);
|
||||||
|
}
|
||||||
require('./main.js');
|
require('./main.js');
|
||||||
}
|
}
|
||||||
|
|||||||
34
src/main.ts
34
src/main.ts
@@ -350,6 +350,10 @@ import {
|
|||||||
} from './main/runtime/composers';
|
} from './main/runtime/composers';
|
||||||
import { createStartupBootstrapRuntimeDeps } from './main/startup';
|
import { createStartupBootstrapRuntimeDeps } from './main/startup';
|
||||||
import { createAppLifecycleRuntimeRunner } from './main/startup-lifecycle';
|
import { createAppLifecycleRuntimeRunner } from './main/startup-lifecycle';
|
||||||
|
import {
|
||||||
|
registerSecondInstanceHandlerEarly,
|
||||||
|
requestSingleInstanceLockEarly,
|
||||||
|
} from './main/early-single-instance';
|
||||||
import { handleMpvCommandFromIpcRuntime } from './main/ipc-mpv-command';
|
import { handleMpvCommandFromIpcRuntime } from './main/ipc-mpv-command';
|
||||||
import { registerIpcRuntimeServices } from './main/ipc-runtime';
|
import { registerIpcRuntimeServices } from './main/ipc-runtime';
|
||||||
import { createAnkiJimakuIpcRuntimeServiceDeps } from './main/dependencies';
|
import { createAnkiJimakuIpcRuntimeServiceDeps } from './main/dependencies';
|
||||||
@@ -568,6 +572,22 @@ const appLogger = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
const runtimeRegistry = createMainRuntimeRegistry();
|
const runtimeRegistry = createMainRuntimeRegistry();
|
||||||
|
const appLifecycleApp = {
|
||||||
|
requestSingleInstanceLock: () => requestSingleInstanceLockEarly(app),
|
||||||
|
quit: () => app.quit(),
|
||||||
|
on: (event: string, listener: (...args: unknown[]) => void) => {
|
||||||
|
if (event === 'second-instance') {
|
||||||
|
registerSecondInstanceHandlerEarly(
|
||||||
|
app,
|
||||||
|
listener as (_event: unknown, argv: string[]) => void,
|
||||||
|
);
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
app.on(event as Parameters<typeof app.on>[0], listener as (...args: any[]) => void);
|
||||||
|
return app;
|
||||||
|
},
|
||||||
|
whenReady: () => app.whenReady(),
|
||||||
|
};
|
||||||
|
|
||||||
const buildGetDefaultSocketPathMainDepsHandler = createBuildGetDefaultSocketPathMainDepsHandler({
|
const buildGetDefaultSocketPathMainDepsHandler = createBuildGetDefaultSocketPathMainDepsHandler({
|
||||||
platform: process.platform,
|
platform: process.platform,
|
||||||
@@ -2240,7 +2260,7 @@ const {
|
|||||||
app.on('open-url', listener);
|
app.on('open-url', listener);
|
||||||
},
|
},
|
||||||
registerSecondInstance: (listener) => {
|
registerSecondInstance: (listener) => {
|
||||||
app.on('second-instance', listener);
|
registerSecondInstanceHandlerEarly(app, listener);
|
||||||
},
|
},
|
||||||
handleAnilistSetupProtocolUrl: (rawUrl) => handleAnilistSetupProtocolUrl(rawUrl),
|
handleAnilistSetupProtocolUrl: (rawUrl) => handleAnilistSetupProtocolUrl(rawUrl),
|
||||||
findAnilistSetupDeepLinkArgvUrl: (argv) => findAnilistSetupDeepLinkArgvUrl(argv),
|
findAnilistSetupDeepLinkArgvUrl: (argv) => findAnilistSetupDeepLinkArgvUrl(argv),
|
||||||
@@ -2523,7 +2543,7 @@ const { runAndApplyStartupState } = runtimeRegistry.startup.createStartupRuntime
|
|||||||
ReturnType<typeof createStartupBootstrapRuntimeDeps>
|
ReturnType<typeof createStartupBootstrapRuntimeDeps>
|
||||||
>({
|
>({
|
||||||
appLifecycleRuntimeRunnerMainDeps: {
|
appLifecycleRuntimeRunnerMainDeps: {
|
||||||
app,
|
app: appLifecycleApp,
|
||||||
platform: process.platform,
|
platform: process.platform,
|
||||||
shouldStartApp: (nextArgs: CliArgs) => shouldStartApp(nextArgs),
|
shouldStartApp: (nextArgs: CliArgs) => shouldStartApp(nextArgs),
|
||||||
parseArgs: (argv: string[]) => parseArgs(argv),
|
parseArgs: (argv: string[]) => parseArgs(argv),
|
||||||
@@ -2621,6 +2641,7 @@ const {
|
|||||||
createMecabTokenizerAndCheck,
|
createMecabTokenizerAndCheck,
|
||||||
prewarmSubtitleDictionaries,
|
prewarmSubtitleDictionaries,
|
||||||
startBackgroundWarmups,
|
startBackgroundWarmups,
|
||||||
|
isTokenizationWarmupReady,
|
||||||
} = composeMpvRuntimeHandlers<
|
} = composeMpvRuntimeHandlers<
|
||||||
MpvIpcClient,
|
MpvIpcClient,
|
||||||
ReturnType<typeof createTokenizerDepsRuntime>,
|
ReturnType<typeof createTokenizerDepsRuntime>,
|
||||||
@@ -2673,6 +2694,15 @@ const {
|
|||||||
syncImmersionMediaState: () => {
|
syncImmersionMediaState: () => {
|
||||||
immersionMediaRuntime.syncFromCurrentMediaState();
|
immersionMediaRuntime.syncFromCurrentMediaState();
|
||||||
},
|
},
|
||||||
|
signalAutoplayReadyIfWarm: () => {
|
||||||
|
if (!isTokenizationWarmupReady()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
maybeSignalPluginAutoplayReady(
|
||||||
|
{ text: '__warm__', tokens: null },
|
||||||
|
{ forceWhilePaused: true },
|
||||||
|
);
|
||||||
|
},
|
||||||
scheduleCharacterDictionarySync: () => {
|
scheduleCharacterDictionarySync: () => {
|
||||||
characterDictionaryAutoSyncRuntime.scheduleSync();
|
characterDictionaryAutoSyncRuntime.scheduleSync();
|
||||||
},
|
},
|
||||||
|
|||||||
56
src/main/early-single-instance.test.ts
Normal file
56
src/main/early-single-instance.test.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import {
|
||||||
|
registerSecondInstanceHandlerEarly,
|
||||||
|
requestSingleInstanceLockEarly,
|
||||||
|
resetEarlySingleInstanceStateForTests,
|
||||||
|
} from './early-single-instance';
|
||||||
|
|
||||||
|
function createFakeApp(lockValue = true) {
|
||||||
|
let requestCalls = 0;
|
||||||
|
let secondInstanceListener: ((_event: unknown, argv: string[]) => void) | null = null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
app: {
|
||||||
|
requestSingleInstanceLock: () => {
|
||||||
|
requestCalls += 1;
|
||||||
|
return lockValue;
|
||||||
|
},
|
||||||
|
on: (_event: 'second-instance', listener: (_event: unknown, argv: string[]) => void) => {
|
||||||
|
secondInstanceListener = listener;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
emitSecondInstance: (argv: string[]) => {
|
||||||
|
secondInstanceListener?.({}, argv);
|
||||||
|
},
|
||||||
|
getRequestCalls: () => requestCalls,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('requestSingleInstanceLockEarly caches the lock result per process', () => {
|
||||||
|
resetEarlySingleInstanceStateForTests();
|
||||||
|
const fake = createFakeApp(true);
|
||||||
|
|
||||||
|
assert.equal(requestSingleInstanceLockEarly(fake.app), true);
|
||||||
|
assert.equal(requestSingleInstanceLockEarly(fake.app), true);
|
||||||
|
assert.equal(fake.getRequestCalls(), 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('registerSecondInstanceHandlerEarly replays queued argv and forwards new events', () => {
|
||||||
|
resetEarlySingleInstanceStateForTests();
|
||||||
|
const fake = createFakeApp(true);
|
||||||
|
const calls: string[][] = [];
|
||||||
|
|
||||||
|
assert.equal(requestSingleInstanceLockEarly(fake.app), true);
|
||||||
|
fake.emitSecondInstance(['SubMiner.exe', '--start', '--socket', '\\\\.\\pipe\\subminer']);
|
||||||
|
|
||||||
|
registerSecondInstanceHandlerEarly(fake.app, (_event, argv) => {
|
||||||
|
calls.push(argv);
|
||||||
|
});
|
||||||
|
fake.emitSecondInstance(['SubMiner.exe', '--start', '--show-visible-overlay']);
|
||||||
|
|
||||||
|
assert.deepEqual(calls, [
|
||||||
|
['SubMiner.exe', '--start', '--socket', '\\\\.\\pipe\\subminer'],
|
||||||
|
['SubMiner.exe', '--start', '--show-visible-overlay'],
|
||||||
|
]);
|
||||||
|
});
|
||||||
54
src/main/early-single-instance.ts
Normal file
54
src/main/early-single-instance.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
interface ElectronSecondInstanceAppLike {
|
||||||
|
requestSingleInstanceLock: () => boolean;
|
||||||
|
on: (
|
||||||
|
event: 'second-instance',
|
||||||
|
listener: (_event: unknown, argv: string[]) => void,
|
||||||
|
) => unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cachedSingleInstanceLock: boolean | null = null;
|
||||||
|
let secondInstanceListenerAttached = false;
|
||||||
|
const secondInstanceArgvHistory: string[][] = [];
|
||||||
|
const secondInstanceHandlers = new Set<(_event: unknown, argv: string[]) => void>();
|
||||||
|
|
||||||
|
function attachSecondInstanceListener(app: ElectronSecondInstanceAppLike): void {
|
||||||
|
if (secondInstanceListenerAttached) return;
|
||||||
|
app.on('second-instance', (event, argv) => {
|
||||||
|
const clonedArgv = [...argv];
|
||||||
|
secondInstanceArgvHistory.push(clonedArgv);
|
||||||
|
for (const handler of secondInstanceHandlers) {
|
||||||
|
handler(event, [...clonedArgv]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
secondInstanceListenerAttached = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function requestSingleInstanceLockEarly(app: ElectronSecondInstanceAppLike): boolean {
|
||||||
|
attachSecondInstanceListener(app);
|
||||||
|
if (cachedSingleInstanceLock !== null) {
|
||||||
|
return cachedSingleInstanceLock;
|
||||||
|
}
|
||||||
|
cachedSingleInstanceLock = app.requestSingleInstanceLock();
|
||||||
|
return cachedSingleInstanceLock;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerSecondInstanceHandlerEarly(
|
||||||
|
app: ElectronSecondInstanceAppLike,
|
||||||
|
handler: (_event: unknown, argv: string[]) => void,
|
||||||
|
): () => void {
|
||||||
|
attachSecondInstanceListener(app);
|
||||||
|
secondInstanceHandlers.add(handler);
|
||||||
|
for (const argv of secondInstanceArgvHistory) {
|
||||||
|
handler(undefined, [...argv]);
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
secondInstanceHandlers.delete(handler);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resetEarlySingleInstanceStateForTests(): void {
|
||||||
|
cachedSingleInstanceLock = null;
|
||||||
|
secondInstanceListenerAttached = false;
|
||||||
|
secondInstanceArgvHistory.length = 0;
|
||||||
|
secondInstanceHandlers.clear();
|
||||||
|
}
|
||||||
@@ -48,6 +48,7 @@ test('mpv main event main deps map app state updates and delegate callbacks', as
|
|||||||
maybeProbeAnilistDuration: (mediaKey) => calls.push(`probe:${mediaKey}`),
|
maybeProbeAnilistDuration: (mediaKey) => calls.push(`probe:${mediaKey}`),
|
||||||
ensureAnilistMediaGuess: (mediaKey) => calls.push(`guess:${mediaKey}`),
|
ensureAnilistMediaGuess: (mediaKey) => calls.push(`guess:${mediaKey}`),
|
||||||
syncImmersionMediaState: () => calls.push('sync-immersion'),
|
syncImmersionMediaState: () => calls.push('sync-immersion'),
|
||||||
|
signalAutoplayReadyIfWarm: (path) => calls.push(`autoplay:${path}`),
|
||||||
updateCurrentMediaTitle: (title) => calls.push(`title:${title}`),
|
updateCurrentMediaTitle: (title) => calls.push(`title:${title}`),
|
||||||
resetAnilistMediaGuessState: () => calls.push('reset-guess'),
|
resetAnilistMediaGuessState: () => calls.push('reset-guess'),
|
||||||
reportJellyfinRemoteProgress: (forceImmediate) => calls.push(`progress:${forceImmediate}`),
|
reportJellyfinRemoteProgress: (forceImmediate) => calls.push(`progress:${forceImmediate}`),
|
||||||
@@ -82,6 +83,7 @@ test('mpv main event main deps map app state updates and delegate callbacks', as
|
|||||||
deps.maybeProbeAnilistDuration('media-key');
|
deps.maybeProbeAnilistDuration('media-key');
|
||||||
deps.ensureAnilistMediaGuess('media-key');
|
deps.ensureAnilistMediaGuess('media-key');
|
||||||
deps.syncImmersionMediaState();
|
deps.syncImmersionMediaState();
|
||||||
|
deps.signalAutoplayReadyIfWarm('/tmp/video');
|
||||||
deps.updateCurrentMediaTitle('title');
|
deps.updateCurrentMediaTitle('title');
|
||||||
deps.resetAnilistMediaGuessState();
|
deps.resetAnilistMediaGuessState();
|
||||||
deps.notifyImmersionTitleUpdate('title');
|
deps.notifyImmersionTitleUpdate('title');
|
||||||
@@ -100,6 +102,7 @@ test('mpv main event main deps map app state updates and delegate callbacks', as
|
|||||||
assert.ok(calls.includes('anilist-post-watch'));
|
assert.ok(calls.includes('anilist-post-watch'));
|
||||||
assert.ok(calls.includes('ensure-immersion'));
|
assert.ok(calls.includes('ensure-immersion'));
|
||||||
assert.ok(calls.includes('sync-immersion'));
|
assert.ok(calls.includes('sync-immersion'));
|
||||||
|
assert.ok(calls.includes('autoplay:/tmp/video'));
|
||||||
assert.ok(calls.includes('metrics'));
|
assert.ok(calls.includes('metrics'));
|
||||||
assert.ok(calls.includes('presence-refresh'));
|
assert.ok(calls.includes('presence-refresh'));
|
||||||
assert.ok(calls.includes('restore-mpv-sub'));
|
assert.ok(calls.includes('restore-mpv-sub'));
|
||||||
|
|||||||
@@ -29,7 +29,23 @@ export abstract class BaseWindowTracker {
|
|||||||
public onGeometryChange: GeometryChangeCallback | null = null;
|
public onGeometryChange: GeometryChangeCallback | null = null;
|
||||||
public onWindowFound: WindowFoundCallback | null = null;
|
public onWindowFound: WindowFoundCallback | null = null;
|
||||||
public onWindowLost: WindowLostCallback | null = null;
|
public onWindowLost: WindowLostCallback | null = null;
|
||||||
public onTargetWindowFocusChange: ((focused: boolean) => void) | null = null;
|
private onWindowFocusChangeCallback: ((focused: boolean) => void) | null = null;
|
||||||
|
|
||||||
|
public get onWindowFocusChange(): ((focused: boolean) => void) | null {
|
||||||
|
return this.onWindowFocusChangeCallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
public set onWindowFocusChange(callback: ((focused: boolean) => void) | null) {
|
||||||
|
this.onWindowFocusChangeCallback = callback;
|
||||||
|
}
|
||||||
|
|
||||||
|
public get onTargetWindowFocusChange(): ((focused: boolean) => void) | null {
|
||||||
|
return this.onWindowFocusChange;
|
||||||
|
}
|
||||||
|
|
||||||
|
public set onTargetWindowFocusChange(callback: ((focused: boolean) => void) | null) {
|
||||||
|
this.onWindowFocusChange = callback;
|
||||||
|
}
|
||||||
|
|
||||||
abstract start(): void;
|
abstract start(): void;
|
||||||
abstract stop(): void;
|
abstract stop(): void;
|
||||||
@@ -52,7 +68,11 @@ export abstract class BaseWindowTracker {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.targetWindowFocused = focused;
|
this.targetWindowFocused = focused;
|
||||||
this.onTargetWindowFocusChange?.(focused);
|
this.onWindowFocusChangeCallback?.(focused);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected updateFocus(focused: boolean): void {
|
||||||
|
this.updateTargetWindowFocused(focused);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected updateGeometry(newGeometry: WindowGeometry | null): void {
|
protected updateGeometry(newGeometry: WindowGeometry | null): void {
|
||||||
|
|||||||
Reference in New Issue
Block a user