mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-04-10 16:19:24 -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 {
|
||||
const pathValue = process.env.Path || process.env.PATH || '';
|
||||
return {
|
||||
...process.env,
|
||||
HOME: homeDir,
|
||||
@@ -58,6 +59,8 @@ function makeTestEnv(homeDir: string, xdgConfigHome: string): NodeJS.ProcessEnv
|
||||
APPDATA: xdgConfigHome,
|
||||
LOCALAPPDATA: path.join(homeDir, 'AppData', 'Local'),
|
||||
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) => {
|
||||
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);
|
||||
@@ -156,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);
|
||||
|
||||
@@ -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) => {
|
||||
const homeDir = path.join(root, 'home');
|
||||
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 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 });
|
||||
@@ -268,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',
|
||||
);
|
||||
@@ -276,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,
|
||||
@@ -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);
|
||||
|
||||
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'),
|
||||
|
||||
@@ -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,
|
||||
@@ -204,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));
|
||||
|
||||
@@ -517,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(
|
||||
@@ -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.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() },
|
||||
});
|
||||
@@ -701,33 +706,7 @@ function resolveAppSpawnTarget(appPath: string, appArgs: string[]): SpawnTarget
|
||||
if (process.platform !== 'win32') {
|
||||
return { command: appPath, args: appArgs };
|
||||
}
|
||||
|
||||
const normalizeBashArg = (value: string): string => {
|
||||
const normalized = value.replace(/\\/g, '/');
|
||||
const driveMatch = normalized.match(/^([A-Za-z]):\/(.*)$/);
|
||||
if (!driveMatch) {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
const [, driveLetter, remainder] = driveMatch;
|
||||
return `/mnt/${driveLetter!.toLowerCase()}/${remainder}`;
|
||||
};
|
||||
const extension = path.extname(appPath).toLowerCase();
|
||||
if (extension === '.ps1') {
|
||||
return {
|
||||
command: 'powershell.exe',
|
||||
args: ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', appPath, ...appArgs],
|
||||
};
|
||||
}
|
||||
|
||||
if (extension === '.sh') {
|
||||
return {
|
||||
command: 'bash',
|
||||
args: [normalizeBashArg(appPath), ...appArgs.map(normalizeBashArg)],
|
||||
};
|
||||
}
|
||||
|
||||
return { command: appPath, args: appArgs };
|
||||
return resolveCommandInvocation(appPath, appArgs);
|
||||
}
|
||||
|
||||
export function runAppCommandWithInherit(appPath: string, appArgs: string[]): never {
|
||||
@@ -841,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,
|
||||
});
|
||||
|
||||
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}`}`),
|
||||
);
|
||||
|
||||
@@ -33,9 +33,30 @@ function makeDbPath(): string {
|
||||
|
||||
function cleanupDbPath(dbPath: string): void {
|
||||
const dir = path.dirname(dbPath);
|
||||
if (fs.existsSync(dir)) {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
if (!fs.existsSync(dir)) {
|
||||
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', () => {
|
||||
|
||||
@@ -20,9 +20,30 @@ function makeDbPath(): string {
|
||||
|
||||
function cleanupDbPath(dbPath: string): void {
|
||||
const dir = path.dirname(dbPath);
|
||||
if (fs.existsSync(dir)) {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
if (!fs.existsSync(dir)) {
|
||||
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', () => {
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
shouldHandleHelpOnlyAtEntry,
|
||||
shouldHandleLaunchMpvAtEntry,
|
||||
} from './main-entry-runtime';
|
||||
import { requestSingleInstanceLockEarly } from './main/early-single-instance';
|
||||
import { createWindowsMpvLaunchDeps, launchWindowsMpv } from './main/runtime/windows-mpv-launch';
|
||||
|
||||
const DEFAULT_TEXTHOOKER_PORT = 5174;
|
||||
@@ -67,5 +68,9 @@ if (shouldHandleLaunchMpvAtEntry(process.argv, process.env)) {
|
||||
app.exit(result.ok ? 0 : 1);
|
||||
});
|
||||
} else {
|
||||
const gotSingleInstanceLock = requestSingleInstanceLockEarly(app);
|
||||
if (!gotSingleInstanceLock) {
|
||||
app.exit(0);
|
||||
}
|
||||
require('./main.js');
|
||||
}
|
||||
|
||||
34
src/main.ts
34
src/main.ts
@@ -350,6 +350,10 @@ import {
|
||||
} from './main/runtime/composers';
|
||||
import { createStartupBootstrapRuntimeDeps } from './main/startup';
|
||||
import { createAppLifecycleRuntimeRunner } from './main/startup-lifecycle';
|
||||
import {
|
||||
registerSecondInstanceHandlerEarly,
|
||||
requestSingleInstanceLockEarly,
|
||||
} from './main/early-single-instance';
|
||||
import { handleMpvCommandFromIpcRuntime } from './main/ipc-mpv-command';
|
||||
import { registerIpcRuntimeServices } from './main/ipc-runtime';
|
||||
import { createAnkiJimakuIpcRuntimeServiceDeps } from './main/dependencies';
|
||||
@@ -568,6 +572,22 @@ const appLogger = {
|
||||
},
|
||||
};
|
||||
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({
|
||||
platform: process.platform,
|
||||
@@ -2240,7 +2260,7 @@ const {
|
||||
app.on('open-url', listener);
|
||||
},
|
||||
registerSecondInstance: (listener) => {
|
||||
app.on('second-instance', listener);
|
||||
registerSecondInstanceHandlerEarly(app, listener);
|
||||
},
|
||||
handleAnilistSetupProtocolUrl: (rawUrl) => handleAnilistSetupProtocolUrl(rawUrl),
|
||||
findAnilistSetupDeepLinkArgvUrl: (argv) => findAnilistSetupDeepLinkArgvUrl(argv),
|
||||
@@ -2523,7 +2543,7 @@ const { runAndApplyStartupState } = runtimeRegistry.startup.createStartupRuntime
|
||||
ReturnType<typeof createStartupBootstrapRuntimeDeps>
|
||||
>({
|
||||
appLifecycleRuntimeRunnerMainDeps: {
|
||||
app,
|
||||
app: appLifecycleApp,
|
||||
platform: process.platform,
|
||||
shouldStartApp: (nextArgs: CliArgs) => shouldStartApp(nextArgs),
|
||||
parseArgs: (argv: string[]) => parseArgs(argv),
|
||||
@@ -2621,6 +2641,7 @@ const {
|
||||
createMecabTokenizerAndCheck,
|
||||
prewarmSubtitleDictionaries,
|
||||
startBackgroundWarmups,
|
||||
isTokenizationWarmupReady,
|
||||
} = composeMpvRuntimeHandlers<
|
||||
MpvIpcClient,
|
||||
ReturnType<typeof createTokenizerDepsRuntime>,
|
||||
@@ -2673,6 +2694,15 @@ const {
|
||||
syncImmersionMediaState: () => {
|
||||
immersionMediaRuntime.syncFromCurrentMediaState();
|
||||
},
|
||||
signalAutoplayReadyIfWarm: () => {
|
||||
if (!isTokenizationWarmupReady()) {
|
||||
return;
|
||||
}
|
||||
maybeSignalPluginAutoplayReady(
|
||||
{ text: '__warm__', tokens: null },
|
||||
{ forceWhilePaused: true },
|
||||
);
|
||||
},
|
||||
scheduleCharacterDictionarySync: () => {
|
||||
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}`),
|
||||
ensureAnilistMediaGuess: (mediaKey) => calls.push(`guess:${mediaKey}`),
|
||||
syncImmersionMediaState: () => calls.push('sync-immersion'),
|
||||
signalAutoplayReadyIfWarm: (path) => calls.push(`autoplay:${path}`),
|
||||
updateCurrentMediaTitle: (title) => calls.push(`title:${title}`),
|
||||
resetAnilistMediaGuessState: () => calls.push('reset-guess'),
|
||||
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.ensureAnilistMediaGuess('media-key');
|
||||
deps.syncImmersionMediaState();
|
||||
deps.signalAutoplayReadyIfWarm('/tmp/video');
|
||||
deps.updateCurrentMediaTitle('title');
|
||||
deps.resetAnilistMediaGuessState();
|
||||
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('ensure-immersion'));
|
||||
assert.ok(calls.includes('sync-immersion'));
|
||||
assert.ok(calls.includes('autoplay:/tmp/video'));
|
||||
assert.ok(calls.includes('metrics'));
|
||||
assert.ok(calls.includes('presence-refresh'));
|
||||
assert.ok(calls.includes('restore-mpv-sub'));
|
||||
|
||||
@@ -29,7 +29,23 @@ export abstract class BaseWindowTracker {
|
||||
public onGeometryChange: GeometryChangeCallback | null = null;
|
||||
public onWindowFound: WindowFoundCallback | 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 stop(): void;
|
||||
@@ -52,7 +68,11 @@ export abstract class BaseWindowTracker {
|
||||
}
|
||||
|
||||
this.targetWindowFocused = focused;
|
||||
this.onTargetWindowFocusChange?.(focused);
|
||||
this.onWindowFocusChangeCallback?.(focused);
|
||||
}
|
||||
|
||||
protected updateFocus(focused: boolean): void {
|
||||
this.updateTargetWindowFocused(focused);
|
||||
}
|
||||
|
||||
protected updateGeometry(newGeometry: WindowGeometry | null): void {
|
||||
|
||||
Reference in New Issue
Block a user