diff --git a/scripts/test-auto-quit-manager.mjs b/scripts/test-auto-quit-manager.mjs new file mode 100644 index 00000000..a2127915 --- /dev/null +++ b/scripts/test-auto-quit-manager.mjs @@ -0,0 +1,310 @@ +#!/usr/bin/env node + +import assert from 'assert/strict'; +import fs from 'fs'; +import path from 'path'; +import vm from 'vm'; +import { createRequire } from 'module'; + +const require = createRequire(import.meta.url); +const ts = require('typescript'); +const { promisify } = require('util'); + +const autoQuitManagerPath = path.resolve('src/main/auto-quit-manager.ts'); +const metricsMode = process.argv.includes('--metrics'); + +function loadAutoQuitManager({ + frontmostBundleId = 'com.apple.finder', + recording = false, + musicPlaying = false, +} = {}) { + let now = 0; + let intervalCallback = null; + const counts = { + frontmost: 0, + recording: 0, + music: 0, + quit: 0, + other: 0, + osascript: 0, + }; + const quits = []; + + function classifyAppleScript(args) { + const script = Array.isArray(args) ? args.join('\n') : ''; + if (script.includes('frontmost is true')) return 'frontmost'; + if (script.includes('AppleHDAEngineInput')) return 'recording'; + if (script.includes('player state is playing')) return 'music'; + if (script.includes('runningApplications()')) return 'quit'; + return 'other'; + } + + function execFile() { + throw new Error('execFile callback form is not supported by this test harness'); + } + + execFile[promisify.custom] = async (file, args) => { + if (file === '/usr/bin/osascript') { + counts.osascript += 1; + const kind = classifyAppleScript(args); + counts[kind] += 1; + if (kind === 'frontmost') return { stdout: `${frontmostBundleId}\n`, stderr: '' }; + if (kind === 'recording') return { stdout: recording ? '1\n' : '0\n', stderr: '' }; + if (kind === 'music') return { stdout: musicPlaying ? 'true\n' : 'false\n', stderr: '' }; + if (kind === 'quit') { + const script = args.at(-1) || ''; + const match = script.match(/if bid is "([^"]+)"/); + quits.push(match?.[1] || 'unknown'); + return { stdout: '', stderr: '' }; + } + } + return { stdout: '', stderr: '' }; + }; + + const source = fs.readFileSync(autoQuitManagerPath, 'utf8'); + const transpiled = ts.transpileModule(source, { + compilerOptions: { + module: ts.ModuleKind.CommonJS, + target: ts.ScriptTarget.ES2022, + esModuleInterop: true, + importsNotUsedAsValues: ts.ImportsNotUsedAsValues.Remove, + }, + fileName: autoQuitManagerPath, + }); + + const module = { exports: {} }; + const localRequire = (request) => { + if (request === 'child_process') return { execFile }; + return require(request); + }; + const testDate = { + now: () => now, + }; + const sandbox = { + module, + exports: module.exports, + require: localRequire, + console, + Date: testDate, + setInterval: (callback) => { + intervalCallback = callback; + return { callback }; + }, + clearInterval: (interval) => { + if (interval?.callback === intervalCallback) intervalCallback = null; + }, + }; + vm.runInNewContext(transpiled.outputText, sandbox, { filename: autoQuitManagerPath }); + + return { + counts, + quits, + module: module.exports, + advance(ms) { + now += ms; + }, + async tick() { + assert.equal(typeof intervalCallback, 'function', 'auto-quit interval should be running'); + await intervalCallback(); + }, + }; +} + +async function runNoTrackedAppDueScenario() { + const harness = loadAutoQuitManager(); + harness.module.startAutoQuit([ + { + bundleId: 'com.example.editor', + appName: 'Example Editor', + appPath: '/Applications/Example Editor.app', + timeoutSeconds: 60, + }, + ]); + + for (let i = 0; i < 3; i += 1) { + harness.advance(5000); + await harness.tick(); + } + + harness.module.stopAutoQuit(); + return { counts: harness.counts, quits: harness.quits }; +} + +async function runOneNonMusicAppDueScenario() { + const harness = loadAutoQuitManager({ frontmostBundleId: 'com.apple.finder' }); + harness.module.startAutoQuit([ + { + bundleId: 'com.example.editor', + appName: 'Example Editor', + appPath: '/Applications/Example Editor.app', + timeoutSeconds: 5, + }, + ]); + + harness.advance(5000); + await harness.tick(); + + harness.module.stopAutoQuit(); + return { counts: harness.counts, quits: harness.quits }; +} + +async function runNonMusicDueWithMusicTrackedButNotDueScenario() { + const harness = loadAutoQuitManager({ frontmostBundleId: 'com.apple.finder' }); + harness.module.startAutoQuit([ + { + bundleId: 'com.example.editor', + appName: 'Example Editor', + appPath: '/Applications/Example Editor.app', + timeoutSeconds: 5, + }, + { + bundleId: 'com.spotify.client', + appName: 'Spotify', + appPath: '/Applications/Spotify.app', + timeoutSeconds: 60, + }, + ]); + + harness.advance(5000); + await harness.tick(); + + harness.module.stopAutoQuit(); + return { counts: harness.counts, quits: harness.quits }; +} + +async function runDueAppIsFrontmostScenario() { + const harness = loadAutoQuitManager({ frontmostBundleId: 'com.example.editor' }); + harness.module.startAutoQuit([ + { + bundleId: 'com.example.editor', + appName: 'Example Editor', + appPath: '/Applications/Example Editor.app', + timeoutSeconds: 5, + }, + ]); + + harness.advance(5000); + await harness.tick(); + + harness.module.stopAutoQuit(); + return { counts: harness.counts, quits: harness.quits }; +} + +async function runMusicAppDueAndPlayingScenario() { + const harness = loadAutoQuitManager({ + frontmostBundleId: 'com.apple.finder', + musicPlaying: true, + }); + harness.module.startAutoQuit([ + { + bundleId: 'com.spotify.client', + appName: 'Spotify', + appPath: '/Applications/Spotify.app', + timeoutSeconds: 5, + }, + ]); + + harness.advance(5000); + await harness.tick(); + + harness.module.stopAutoQuit(); + return { counts: harness.counts, quits: harness.quits }; +} + +function compactCounts({ counts, quits }) { + return { + osascript: counts.osascript, + frontmost: counts.frontmost, + recording: counts.recording, + music: counts.music, + quit: counts.quit, + quitBundleIds: quits, + }; +} + +async function printMetrics() { + const metrics = { + noTrackedAppDueOver3Ticks: compactCounts(await runNoTrackedAppDueScenario()), + oneNonMusicAppDue: compactCounts(await runOneNonMusicAppDueScenario()), + }; + console.log(JSON.stringify(metrics, null, 2)); +} + +const tests = []; +function test(name, fn) { + tests.push({ name, fn }); +} + +test('skips AppleScript probes while no tracked app is due', async () => { + const result = await runNoTrackedAppDueScenario(); + assert.deepEqual(compactCounts(result), { + osascript: 0, + frontmost: 0, + recording: 0, + music: 0, + quit: 0, + quitBundleIds: [], + }); +}); + +test('only probes frontmost, recording, and quit for a due non-music app', async () => { + const result = await runOneNonMusicAppDueScenario(); + assert.deepEqual(compactCounts(result), { + osascript: 3, + frontmost: 1, + recording: 1, + music: 0, + quit: 1, + quitBundleIds: ['com.example.editor'], + }); +}); + +test('does not check music when only non-music candidates are due', async () => { + const result = await runNonMusicDueWithMusicTrackedButNotDueScenario(); + assert.deepEqual(compactCounts(result), { + osascript: 3, + frontmost: 1, + recording: 1, + music: 0, + quit: 1, + quitBundleIds: ['com.example.editor'], + }); +}); + +test('does not run recording or music probes when the only due app is frontmost', async () => { + const result = await runDueAppIsFrontmostScenario(); + assert.deepEqual(compactCounts(result), { + osascript: 1, + frontmost: 1, + recording: 0, + music: 0, + quit: 0, + quitBundleIds: [], + }); +}); + +test('checks music playback only when a due music app would otherwise quit', async () => { + const result = await runMusicAppDueAndPlayingScenario(); + assert.deepEqual(compactCounts(result), { + osascript: 3, + frontmost: 1, + recording: 1, + music: 1, + quit: 0, + quitBundleIds: [], + }); +}); + +if (metricsMode) { + await printMetrics(); +} else { + for (const { name, fn } of tests) { + try { + await fn(); + console.log(`PASS ${name}`); + } catch (error) { + console.error(`FAIL ${name}`); + throw error; + } + } +} diff --git a/src/main/auto-quit-manager.ts b/src/main/auto-quit-manager.ts index d79f98a3..76fbdc30 100644 --- a/src/main/auto-quit-manager.ts +++ b/src/main/auto-quit-manager.ts @@ -37,6 +37,8 @@ const MUSIC_BUNDLE_IDS = new Set([ let pollInterval: ReturnType | null = null; let lastFrontmostAt = new Map(); // bundleId → timestamp let autoQuitApps: AutoQuitAppEntry[] = []; +let trackedBundleIds = new Set(); +let trackedMusicBundleIds = new Set(); let checking = false; /** @@ -134,6 +136,61 @@ async function isMusicPlaying(): Promise { } } +function rebuildTrackedBundleIds(): void { + trackedBundleIds = new Set(); + trackedMusicBundleIds = new Set(); + for (const entry of autoQuitApps) { + trackedBundleIds.add(entry.bundleId); + if (MUSIC_BUNDLE_IDS.has(entry.bundleId)) { + trackedMusicBundleIds.add(entry.bundleId); + } + } +} + +function setAutoQuitApps(apps: AutoQuitAppEntry[]): void { + autoQuitApps = apps; + rebuildTrackedBundleIds(); +} + +function seedMissingLastFrontmostTimestamps(now: number): void { + for (const app of autoQuitApps) { + if (!lastFrontmostAt.has(app.bundleId)) { + lastFrontmostAt.set(app.bundleId, now); + } + } +} + +function getDueAutoQuitCandidates(now: number): AutoQuitAppEntry[] { + const dueCandidates: AutoQuitAppEntry[] = []; + + for (const entry of autoQuitApps) { + if (PROTECTED_BUNDLE_IDS.has(entry.bundleId)) continue; + + const lastActive = lastFrontmostAt.get(entry.bundleId); + if (lastActive === undefined) { + // First time seeing this app; record now as the inactivity baseline. + lastFrontmostAt.set(entry.bundleId, now); + continue; + } + + const inactiveMs = now - lastActive; + const timeoutMs = entry.timeoutSeconds * 1000; + if (inactiveMs >= timeoutMs) { + dueCandidates.push(entry); + } + } + + return dueCandidates; +} + +function pruneLastFrontmostEntries(frontmostBundleId: string | null): void { + for (const bundleId of lastFrontmostAt.keys()) { + if (!trackedBundleIds.has(bundleId) && bundleId !== frontmostBundleId) { + lastFrontmostAt.delete(bundleId); + } + } +} + /** * Check all tracked apps and quit those that exceeded their timeout */ @@ -142,56 +199,48 @@ async function checkAndQuit(): Promise { if (autoQuitApps.length === 0) return; checking = true; try { - // Pause all auto-quit if system is recording - const recording = await isSystemRecording(); - if (recording) return; + const now = Date.now(); + const dueCandidates = getDueAutoQuitCandidates(now); + if (dueCandidates.length === 0) { + pruneLastFrontmostEntries(null); + return; + } const frontmostBundleId = await getFrontmostBundleId(); if (!frontmostBundleId) return; - // Check if music is playing (protect music apps) - const musicPlaying = await isMusicPlaying(); - - const now = Date.now(); - // Update frontmost timestamp lastFrontmostAt.set(frontmostBundleId, now); - // Check each auto-quit app - for (const entry of autoQuitApps) { - // Skip if this app is currently frontmost - if (entry.bundleId === frontmostBundleId) continue; + const nonFrontmostDueCandidates = dueCandidates.filter( + (entry) => entry.bundleId !== frontmostBundleId + ); + if (nonFrontmostDueCandidates.length === 0) { + pruneLastFrontmostEntries(frontmostBundleId); + return; + } - // Skip protected apps - if (PROTECTED_BUNDLE_IDS.has(entry.bundleId)) continue; + // Pause auto-quit only when a non-frontmost tracked app is actually due. + const recording = await isSystemRecording(); + if (recording) { + pruneLastFrontmostEntries(frontmostBundleId); + return; + } - // Skip music apps if music is playing + const hasDueMusicApp = nonFrontmostDueCandidates.some((entry) => + trackedMusicBundleIds.has(entry.bundleId) + ); + const musicPlaying = hasDueMusicApp ? await isMusicPlaying() : false; + + for (const entry of nonFrontmostDueCandidates) { if (musicPlaying && MUSIC_BUNDLE_IDS.has(entry.bundleId)) continue; - const lastActive = lastFrontmostAt.get(entry.bundleId); - if (lastActive === undefined) { - // First time seeing this app — record now as baseline - lastFrontmostAt.set(entry.bundleId, now); - continue; - } - - const inactiveMs = now - lastActive; - const timeoutMs = entry.timeoutSeconds * 1000; - - if (inactiveMs >= timeoutMs) { - await quitApp(entry.bundleId); - // Remove from tracking so we don't try to quit again - lastFrontmostAt.delete(entry.bundleId); - } + await quitApp(entry.bundleId); + // Remove from tracking so we don't try to quit again + lastFrontmostAt.delete(entry.bundleId); } - // Prune lastFrontmostAt entries for apps no longer tracked or frontmost - const trackedBundleIds = new Set(autoQuitApps.map((e) => e.bundleId)); - for (const bundleId of lastFrontmostAt.keys()) { - if (!trackedBundleIds.has(bundleId) && bundleId !== frontmostBundleId) { - lastFrontmostAt.delete(bundleId); - } - } + pruneLastFrontmostEntries(frontmostBundleId); } finally { checking = false; } @@ -201,18 +250,13 @@ async function checkAndQuit(): Promise { * Start the auto-quit polling loop */ export function startAutoQuit(apps: AutoQuitAppEntry[]): void { - autoQuitApps = apps; - if (pollInterval) return; // Already running + setAutoQuitApps(apps); if (apps.length === 0) return; // Initialize all tracked apps with current time - const now = Date.now(); - for (const app of apps) { - if (!lastFrontmostAt.has(app.bundleId)) { - lastFrontmostAt.set(app.bundleId, now); - } - } + seedMissingLastFrontmostTimestamps(Date.now()); + if (pollInterval) return; // Already running pollInterval = setInterval(checkAndQuit, 5000); } @@ -230,12 +274,14 @@ export function stopAutoQuit(): void { * Update the app list (restarts polling if needed) */ export function updateAutoQuitApps(apps: AutoQuitAppEntry[]): void { - autoQuitApps = apps; + setAutoQuitApps(apps); if (apps.length === 0) { stopAutoQuit(); lastFrontmostAt.clear(); } else if (!pollInterval) { startAutoQuit(apps); + } else { + seedMissingLastFrontmostTimestamps(Date.now()); } } @@ -251,6 +297,7 @@ export function addAutoQuitApp(entry: AutoQuitAppEntry): void { } else { autoQuitApps.push(entry); } + rebuildTrackedBundleIds(); // Set baseline timestamp lastFrontmostAt.set(entry.bundleId, Date.now()); if (!pollInterval) { @@ -262,7 +309,7 @@ export function addAutoQuitApp(entry: AutoQuitAppEntry): void { * Remove an app from auto-quit */ export function removeAutoQuitApp(bundleId: string): void { - autoQuitApps = autoQuitApps.filter(a => a.bundleId !== bundleId); + setAutoQuitApps(autoQuitApps.filter(a => a.bundleId !== bundleId)); lastFrontmostAt.delete(bundleId); if (autoQuitApps.length === 0) { stopAutoQuit();