diff --git a/changes/windows-background-reuse.md b/changes/windows-background-reuse.md new file mode 100644 index 00000000..2279d937 --- /dev/null +++ b/changes/windows-background-reuse.md @@ -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. diff --git a/launcher/main.test.ts b/launcher/main.test.ts index 52b7065f..8d7c2c65 100644 --- a/launcher/main.test.ts +++ b/launcher/main.test.ts @@ -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'), diff --git a/launcher/mpv.ts b/launcher/mpv.ts index e3383e51..5beee3ba 100644 --- a/launcher/mpv.ts +++ b/launcher/mpv.ts @@ -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, }); diff --git a/launcher/util.ts b/launcher/util.ts index ed302723..649c2a8f 100644 --- a/launcher/util.ts +++ b/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}`}`), ); diff --git a/src/core/services/immersion-tracker-service.test.ts b/src/core/services/immersion-tracker-service.test.ts index 99b603e7..d5bad4e9 100644 --- a/src/core/services/immersion-tracker-service.test.ts +++ b/src/core/services/immersion-tracker-service.test.ts @@ -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', () => { diff --git a/src/core/services/immersion-tracker/storage-session.test.ts b/src/core/services/immersion-tracker/storage-session.test.ts index ad12515a..b07d5ece 100644 --- a/src/core/services/immersion-tracker/storage-session.test.ts +++ b/src/core/services/immersion-tracker/storage-session.test.ts @@ -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', () => { diff --git a/src/main-entry.ts b/src/main-entry.ts index 120111f4..c99e6de0 100644 --- a/src/main-entry.ts +++ b/src/main-entry.ts @@ -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'); } diff --git a/src/main.ts b/src/main.ts index b5561738..2b48184c 100644 --- a/src/main.ts +++ b/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[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 >({ 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, @@ -2673,6 +2694,15 @@ const { syncImmersionMediaState: () => { immersionMediaRuntime.syncFromCurrentMediaState(); }, + signalAutoplayReadyIfWarm: () => { + if (!isTokenizationWarmupReady()) { + return; + } + maybeSignalPluginAutoplayReady( + { text: '__warm__', tokens: null }, + { forceWhilePaused: true }, + ); + }, scheduleCharacterDictionarySync: () => { characterDictionaryAutoSyncRuntime.scheduleSync(); }, diff --git a/src/main/early-single-instance.test.ts b/src/main/early-single-instance.test.ts new file mode 100644 index 00000000..48123e3e --- /dev/null +++ b/src/main/early-single-instance.test.ts @@ -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'], + ]); +}); diff --git a/src/main/early-single-instance.ts b/src/main/early-single-instance.ts new file mode 100644 index 00000000..89636f94 --- /dev/null +++ b/src/main/early-single-instance.ts @@ -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(); +} diff --git a/src/main/runtime/mpv-main-event-main-deps.test.ts b/src/main/runtime/mpv-main-event-main-deps.test.ts index 71e3a10f..0ed11083 100644 --- a/src/main/runtime/mpv-main-event-main-deps.test.ts +++ b/src/main/runtime/mpv-main-event-main-deps.test.ts @@ -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')); diff --git a/src/window-trackers/base-tracker.ts b/src/window-trackers/base-tracker.ts index a6155de6..d19172c3 100644 --- a/src/window-trackers/base-tracker.ts +++ b/src/window-trackers/base-tracker.ts @@ -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 {