From a36e628512883e66c6913a9ae7d75eaf0cd91e99 Mon Sep 17 00:00:00 2001 From: sudacode Date: Sat, 16 May 2026 17:41:58 -0700 Subject: [PATCH] fix(macos): release overlay when mpv loses focus --- changes/fix-macos-overlay-layering.md | 2 + scripts/get-mpv-window-macos.swift | 21 +- scripts/get-mpv-window-macos.test.ts | 26 +- src/core/services/overlay-visibility.test.ts | 49 ++++ src/window-trackers/macos-tracker.test.ts | 237 ++++++++++++++++++- src/window-trackers/macos-tracker.ts | 157 ++++++++++-- 6 files changed, 462 insertions(+), 30 deletions(-) diff --git a/changes/fix-macos-overlay-layering.md b/changes/fix-macos-overlay-layering.md index 82188e47..5c616459 100644 --- a/changes/fix-macos-overlay-layering.md +++ b/changes/fix-macos-overlay-layering.md @@ -6,3 +6,5 @@ area: overlay - Opened the stats overlay inactive on macOS so it appears over fullscreen mpv instead of switching back to SubMiner's original desktop. - Preserved the active mpv focus state through transient macOS helper misses so subtitles do not flicker while mpv remains foreground. - Kept fullscreen macOS overlays stable when mpv remains frontmost but window geometry temporarily disappears from the macOS window APIs. +- Released the macOS overlay when the helper reports mpv is no longer foreground so other apps are no longer covered. +- Reduced macOS window-tracker background work by preferring the compiled helper and slowing polls while mpv is stably focused. diff --git a/scripts/get-mpv-window-macos.swift b/scripts/get-mpv-window-macos.swift index 3d9f2f1c..61e01fd7 100644 --- a/scripts/get-mpv-window-macos.swift +++ b/scripts/get-mpv-window-macos.swift @@ -7,7 +7,7 @@ // It works with both bundled and unbundled mpv installations. // // Usage: swift get-mpv-window-macos.swift -// Output: "x,y,width,height,focused", "minimized", "active", or "not-found" +// Output: "x,y,width,height,focused", "minimized", "active", "inactive", or "not-found" // import Cocoa @@ -34,6 +34,7 @@ private enum WindowLookupResult { case visible(WindowState) case minimized case active + case inactive } private let targetMpvSocketPath: String? = { @@ -176,11 +177,17 @@ private func isFocusedMpvWindow(ownerPid: pid_t, frontmost: FrontmostApplication } private func isFrontmostTargetMpv(_ frontmost: FrontmostApplicationState?) -> Bool { - guard let frontmost = frontmost else { + guard let frontmost = frontmost, frontmost.isMpv else { return false } - return frontmost.isMpv && windowHasTargetSocket(frontmost.pid) + if windowHasTargetSocket(frontmost.pid) { + return true + } + + // When macOS says mpv is frontmost but geometry APIs miss, keep the + // overlay stable even if ps cannot expose the socket argument. + return targetMpvSocketPath != nil } private func windowStateFromAccessibilityAPI() -> WindowLookupResult? { @@ -307,9 +314,13 @@ private let lookupResult: WindowLookupResult? = { if let cgWindow = windowStateFromCoreGraphics() { return .visible(cgWindow) } - if isFrontmostTargetMpv(frontmostApplicationState()) { + let frontmost = frontmostApplicationState() + if isFrontmostTargetMpv(frontmost) { return .active } + if frontmost != nil { + return .inactive + } return nil }() @@ -323,6 +334,8 @@ if let result = lookupResult { print("minimized") case .active: print("active") + case .inactive: + print("inactive") } } else { print("not-found") diff --git a/scripts/get-mpv-window-macos.test.ts b/scripts/get-mpv-window-macos.test.ts index 57d7f1c8..59baab6f 100644 --- a/scripts/get-mpv-window-macos.test.ts +++ b/scripts/get-mpv-window-macos.test.ts @@ -59,12 +59,16 @@ test('focused mpv window follows the frontmost mpv app signal', () => { test('frontmost mpv app emits active state when geometry lookup misses', () => { assert.ok( - source.includes('case active'), + /case\s+\.active:/.test(source), 'helper should expose an active state without window geometry', ); assert.ok( - source.includes('frontmost.isMpv && windowHasTargetSocket(frontmost.pid)'), - 'active state should be limited to the frontmost target mpv process', + source.includes('if windowHasTargetSocket(frontmost.pid)'), + 'active state should still accept a matching target socket when available', + ); + assert.ok( + source.includes('return targetMpvSocketPath != nil'), + 'active state should preserve frontmost mpv even if command-line socket detection fails', ); assert.ok( source.includes('return .active'), @@ -72,3 +76,19 @@ test('frontmost mpv app emits active state when geometry lookup misses', () => { ); assert.ok(source.includes('print("active")'), 'active state should be printed for the tracker'); }); + +test('frontmost non-mpv app emits inactive state when geometry lookup misses', () => { + assert.ok( + /case\s+\.inactive:/.test(source), + 'helper should expose an inactive state without window geometry', + ); + assert.ok( + source.includes('if frontmost != nil'), + 'helper should distinguish a known non-mpv frontmost app from an unknown miss', + ); + assert.ok(source.includes('return .inactive'), 'known non-mpv focus should return inactive'); + assert.ok( + source.includes('print("inactive")'), + 'inactive state should be printed for the tracker', + ); +}); diff --git a/src/core/services/overlay-visibility.test.ts b/src/core/services/overlay-visibility.test.ts index 9619dc90..ff345010 100644 --- a/src/core/services/overlay-visibility.test.ts +++ b/src/core/services/overlay-visibility.test.ts @@ -1598,6 +1598,55 @@ test('macOS hides visible overlay during tracker loss after mpv loses foreground assert.ok(!calls.includes('loading-osd')); }); +test('macOS keeps a focused overlay visible during tracker loss', () => { + const { window, calls, setFocused } = createMainWindowRecorder(); + const tracker: WindowTrackerStub = { + isTracking: () => false, + getGeometry: () => null, + isTargetWindowFocused: () => false, + isTargetWindowMinimized: () => false, + }; + + window.show(); + setFocused(true); + calls.length = 0; + + updateVisibleOverlayVisibility({ + visibleOverlayVisible: true, + mainWindow: window as never, + windowTracker: tracker as never, + trackerNotReadyWarningShown: false, + setTrackerNotReadyWarningShown: () => {}, + updateVisibleOverlayBounds: () => { + calls.push('update-bounds'); + }, + ensureOverlayWindowLevel: () => { + calls.push('ensure-level'); + }, + syncPrimaryOverlayWindowLayer: () => { + calls.push('sync-layer'); + }, + enforceOverlayLayerOrder: () => { + calls.push('enforce-order'); + }, + syncOverlayShortcuts: () => { + calls.push('sync-shortcuts'); + }, + isMacOSPlatform: true, + showOverlayLoadingOsd: () => { + calls.push('loading-osd'); + }, + } as never); + + assert.ok(calls.includes('sync-layer')); + assert.ok(calls.includes('mouse-ignore:true:forward')); + assert.ok(calls.includes('ensure-level')); + assert.ok(calls.includes('enforce-order')); + assert.ok(calls.includes('sync-shortcuts')); + assert.ok(!calls.includes('hide')); + assert.ok(!calls.includes('loading-osd')); +}); + test('macOS suppresses immediate repeat loading OSD after tracker recovery until cooldown expires', () => { const { window } = createMainWindowRecorder(); const osdMessages: string[] = []; diff --git a/src/window-trackers/macos-tracker.test.ts b/src/window-trackers/macos-tracker.test.ts index d7154176..8e8553b2 100644 --- a/src/window-trackers/macos-tracker.test.ts +++ b/src/window-trackers/macos-tracker.test.ts @@ -1,6 +1,13 @@ import assert from 'node:assert/strict'; +import { mkdtempSync, rmSync, utimesSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import test from 'node:test'; -import { MacOSWindowTracker, parseMacOSHelperOutput } from './macos-tracker'; +import { + isCompiledMacOSHelperCurrent, + MacOSWindowTracker, + parseMacOSHelperOutput, +} from './macos-tracker'; test('parseMacOSHelperOutput parses minimized state', () => { assert.deepEqual(parseMacOSHelperOutput('minimized'), { @@ -18,6 +25,91 @@ test('parseMacOSHelperOutput parses active focused state without geometry', () = }); }); +test('parseMacOSHelperOutput parses inactive state without geometry', () => { + assert.deepEqual(parseMacOSHelperOutput('inactive'), { + geometry: null, + focused: false, + inactive: true, + }); +}); + +test('isCompiledMacOSHelperCurrent rejects binaries older than the Swift source', () => { + const tempDir = mkdtempSync(join(tmpdir(), 'subminer-macos-helper-')); + try { + const binaryPath = join(tempDir, 'get-mpv-window-macos'); + const sourcePath = join(tempDir, 'get-mpv-window-macos.swift'); + writeFileSync(binaryPath, 'binary'); + writeFileSync(sourcePath, 'source'); + + const older = new Date('2026-01-01T00:00:00Z'); + const newer = new Date('2026-01-01T00:00:05Z'); + utimesSync(binaryPath, older, older); + utimesSync(sourcePath, newer, newer); + + assert.equal(isCompiledMacOSHelperCurrent(binaryPath, sourcePath), false); + + utimesSync(binaryPath, newer, newer); + utimesSync(sourcePath, older, older); + + assert.equal(isCompiledMacOSHelperCurrent(binaryPath, sourcePath), true); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } +}); + +test('MacOSWindowTracker slows polling while focused target is stable', async () => { + const scheduledDelays: number[] = []; + let callIndex = 0; + const tracker = new MacOSWindowTracker('/tmp/mpv.sock', { + resolveHelper: () => ({ + helperPath: 'helper', + helperType: 'binary', + }), + runHelper: async () => { + callIndex += 1; + return { stdout: '10,20,1280,720,1', stderr: '' }; + }, + fastPollIntervalMs: 250, + stablePollIntervalMs: 1_000, + setPollTimeout: ((_callback: () => void, delayMs: number) => { + scheduledDelays.push(delayMs); + return {} as ReturnType; + }) as never, + clearPollTimeout: (() => {}) as never, + } as never); + + tracker.start(); + await new Promise((resolve) => setTimeout(resolve, 0)); + tracker.stop(); + + assert.equal(callIndex, 1); + assert.deepEqual(scheduledDelays, [1_000]); +}); + +test('MacOSWindowTracker keeps fast polling while target is not focused', async () => { + const scheduledDelays: number[] = []; + const tracker = new MacOSWindowTracker('/tmp/mpv.sock', { + resolveHelper: () => ({ + helperPath: 'helper', + helperType: 'binary', + }), + runHelper: async () => ({ stdout: '10,20,1280,720,0', stderr: '' }), + fastPollIntervalMs: 250, + stablePollIntervalMs: 1_000, + setPollTimeout: ((_callback: () => void, delayMs: number) => { + scheduledDelays.push(delayMs); + return {} as ReturnType; + }) as never, + clearPollTimeout: (() => {}) as never, + } as never); + + tracker.start(); + await new Promise((resolve) => setTimeout(resolve, 0)); + tracker.stop(); + + assert.deepEqual(scheduledDelays, [250]); +}); + test('MacOSWindowTracker keeps the last geometry through a single helper miss', async () => { let callIndex = 0; const outputs = [ @@ -63,7 +155,7 @@ test('MacOSWindowTracker keeps the last geometry through a single helper miss', }); }); -test('MacOSWindowTracker preserves target focus during transient helper misses', async () => { +test('MacOSWindowTracker preserves target focus on helper not-found while retaining geometry', async () => { let callIndex = 0; const focusChanges: boolean[] = []; const outputs = [ @@ -144,10 +236,145 @@ test('MacOSWindowTracker keeps focused fullscreen target through active helper m }); }); +test('MacOSWindowTracker keeps previously focused target through repeated not-found misses after grace', async () => { + let callIndex = 0; + let now = 1_000; + const focusChanges: boolean[] = []; + const outputs = [ + { stdout: '10,20,1280,720,1', stderr: '' }, + { stdout: 'not-found', stderr: '' }, + { stdout: 'not-found', stderr: '' }, + { stdout: 'not-found', stderr: '' }, + ]; + + const tracker = new MacOSWindowTracker('/tmp/mpv.sock', { + resolveHelper: () => ({ + helperPath: 'helper.swift', + helperType: 'swift', + }), + runHelper: async () => outputs[callIndex++] ?? outputs.at(-1)!, + now: () => now, + trackingLossGraceMs: 500, + }); + tracker.onWindowFocusChange = (focused) => { + focusChanges.push(focused); + }; + + (tracker as unknown as { pollGeometry: () => void }).pollGeometry(); + await new Promise((resolve) => setTimeout(resolve, 0)); + assert.equal(tracker.isTracking(), true); + assert.equal(tracker.isTargetWindowFocused(), true); + + now += 1_000; + (tracker as unknown as { pollGeometry: () => void }).pollGeometry(); + await new Promise((resolve) => setTimeout(resolve, 0)); + + now += 1_000; + (tracker as unknown as { pollGeometry: () => void }).pollGeometry(); + await new Promise((resolve) => setTimeout(resolve, 0)); + + now += 1_000; + (tracker as unknown as { pollGeometry: () => void }).pollGeometry(); + await new Promise((resolve) => setTimeout(resolve, 0)); + + assert.equal(tracker.isTracking(), true); + assert.equal(tracker.isTargetWindowFocused(), true); + assert.deepEqual(tracker.getGeometry(), { + x: 10, + y: 20, + width: 1280, + height: 720, + }); + assert.deepEqual(focusChanges, [true]); +}); + +test('MacOSWindowTracker keeps previously focused target through repeated helper execution failures', async () => { + let callIndex = 0; + let now = 1_000; + const focusChanges: boolean[] = []; + + const tracker = new MacOSWindowTracker('/tmp/mpv.sock', { + resolveHelper: () => ({ + helperPath: 'helper.swift', + helperType: 'swift', + }), + runHelper: async () => { + callIndex += 1; + if (callIndex === 1) { + return { stdout: '10,20,1280,720,1', stderr: '' }; + } + throw Object.assign(new Error('helper timed out'), { stderr: 'timeout' }); + }, + now: () => now, + trackingLossGraceMs: 500, + }); + tracker.onWindowFocusChange = (focused) => { + focusChanges.push(focused); + }; + + (tracker as unknown as { pollGeometry: () => void }).pollGeometry(); + await new Promise((resolve) => setTimeout(resolve, 0)); + + now += 1_000; + (tracker as unknown as { pollGeometry: () => void }).pollGeometry(); + await new Promise((resolve) => setTimeout(resolve, 0)); + + now += 1_000; + (tracker as unknown as { pollGeometry: () => void }).pollGeometry(); + await new Promise((resolve) => setTimeout(resolve, 0)); + + assert.equal(tracker.isTracking(), true); + assert.equal(tracker.isTargetWindowFocused(), true); + assert.deepEqual(tracker.getGeometry(), { + x: 10, + y: 20, + width: 1280, + height: 720, + }); + assert.deepEqual(focusChanges, [true]); +}); + +test('MacOSWindowTracker marks target unfocused on explicit inactive helper signal', async () => { + let callIndex = 0; + const focusChanges: boolean[] = []; + const outputs = [ + { stdout: '10,20,1280,720,1', stderr: '' }, + { stdout: 'inactive', stderr: '' }, + ]; + + const tracker = new MacOSWindowTracker('/tmp/mpv.sock', { + resolveHelper: () => ({ + helperPath: 'helper.swift', + helperType: 'swift', + }), + runHelper: async () => outputs[callIndex++] ?? outputs.at(-1)!, + trackingLossGraceMs: 1_500, + }); + tracker.onWindowFocusChange = (focused) => { + focusChanges.push(focused); + }; + + (tracker as unknown as { pollGeometry: () => void }).pollGeometry(); + await new Promise((resolve) => setTimeout(resolve, 0)); + + (tracker as unknown as { pollGeometry: () => void }).pollGeometry(); + await new Promise((resolve) => setTimeout(resolve, 0)); + + assert.equal(tracker.isTracking(), true); + assert.deepEqual(tracker.getGeometry(), { + x: 10, + y: 20, + width: 1280, + height: 720, + }); + assert.equal(tracker.isTargetWindowFocused(), false); + assert.deepEqual(focusChanges, [true, false]); +}); + test('MacOSWindowTracker drops tracking after consecutive helper misses', async () => { let callIndex = 0; const outputs = [ - { stdout: '10,20,1280,720,1', stderr: '' }, + { stdout: '10,20,1280,720,0', stderr: '' }, { stdout: 'not-found', stderr: '' }, { stdout: 'not-found', stderr: '' }, ]; @@ -164,6 +391,7 @@ test('MacOSWindowTracker drops tracking after consecutive helper misses', async (tracker as unknown as { pollGeometry: () => void }).pollGeometry(); await new Promise((resolve) => setTimeout(resolve, 0)); assert.equal(tracker.isTracking(), true); + assert.equal(tracker.isTargetWindowFocused(), false); (tracker as unknown as { pollGeometry: () => void }).pollGeometry(); await new Promise((resolve) => setTimeout(resolve, 0)); @@ -227,7 +455,7 @@ test('MacOSWindowTracker drops tracking after grace window expires', async () => let callIndex = 0; let now = 1_000; const outputs = [ - { stdout: '10,20,1280,720,1', stderr: '' }, + { stdout: '10,20,1280,720,0', stderr: '' }, { stdout: 'not-found', stderr: '' }, { stdout: 'not-found', stderr: '' }, { stdout: 'not-found', stderr: '' }, @@ -246,6 +474,7 @@ test('MacOSWindowTracker drops tracking after grace window expires', async () => (tracker as unknown as { pollGeometry: () => void }).pollGeometry(); await new Promise((resolve) => setTimeout(resolve, 0)); assert.equal(tracker.isTracking(), true); + assert.equal(tracker.isTargetWindowFocused(), false); now += 250; (tracker as unknown as { pollGeometry: () => void }).pollGeometry(); diff --git a/src/window-trackers/macos-tracker.ts b/src/window-trackers/macos-tracker.ts index 7656d792..33885bea 100644 --- a/src/window-trackers/macos-tracker.ts +++ b/src/window-trackers/macos-tracker.ts @@ -25,6 +25,8 @@ import { createLogger } from '../logger'; import type { WindowGeometry } from '../types'; const log = createLogger('tracker').child('macos'); +const MACOS_FAST_POLL_INTERVAL_MS = 250; +const MACOS_STABLE_FOCUSED_POLL_INTERVAL_MS = 1_000; type MacOSTrackerRunnerResult = { stdout: string; @@ -42,6 +44,10 @@ type MacOSTrackerDeps = { trackingLossGraceMs?: number; minimizedTrackingLossGraceMs?: number; now?: () => number; + fastPollIntervalMs?: number; + stablePollIntervalMs?: number; + setPollTimeout?: typeof setTimeout; + clearPollTimeout?: typeof clearTimeout; }; export type MacOSHelperWindowState = @@ -50,18 +56,28 @@ export type MacOSHelperWindowState = focused: boolean; minimized?: false; active?: false; + inactive?: false; } | { geometry: null; focused: true; active: true; minimized?: false; + inactive?: false; + } + | { + geometry: null; + focused: false; + inactive: true; + active?: false; + minimized?: false; } | { geometry: null; focused: false; minimized: true; active?: false; + inactive?: false; }; function runHelperWithExecFile( @@ -98,6 +114,25 @@ function runHelperWithExecFile( }); } +export function isCompiledMacOSHelperCurrent( + binaryPath: string, + sourcePath: string, + helperFs: Pick = fs, +): boolean { + if (!helperFs.existsSync(binaryPath)) { + return false; + } + if (!helperFs.existsSync(sourcePath)) { + return true; + } + + try { + return helperFs.statSync(binaryPath).mtimeMs >= helperFs.statSync(sourcePath).mtimeMs; + } catch { + return false; + } +} + export function parseMacOSHelperOutput(result: string): MacOSHelperWindowState | null { const trimmed = result.trim(); if (trimmed === 'minimized') { @@ -114,6 +149,13 @@ export function parseMacOSHelperOutput(result: string): MacOSHelperWindowState | active: true, }; } + if (trimmed === 'inactive') { + return { + geometry: null, + focused: false, + inactive: true, + }; + } if (!trimmed || trimmed === 'not-found') { return null; } @@ -153,8 +195,9 @@ export function parseMacOSHelperOutput(result: string): MacOSHelperWindowState | } export class MacOSWindowTracker extends BaseWindowTracker { - private pollInterval: ReturnType | null = null; + private pollTimeout: ReturnType | null = null; private pollInFlight = false; + private started = false; private helperPath: string | null = null; private helperType: 'binary' | 'swift' | null = null; private lastExecErrorFingerprint: string | null = null; @@ -169,6 +212,10 @@ export class MacOSWindowTracker extends BaseWindowTracker { private readonly trackingLossGraceMs: number; private readonly minimizedTrackingLossGraceMs: number; private readonly now: () => number; + private readonly fastPollIntervalMs: number; + private readonly stablePollIntervalMs: number; + private readonly setPollTimeout: typeof setTimeout; + private readonly clearPollTimeout: typeof clearTimeout; private consecutiveMisses = 0; private trackingLossStartedAtMs: number | null = null; private targetWindowMinimized = false; @@ -184,6 +231,16 @@ export class MacOSWindowTracker extends BaseWindowTracker { Math.floor(deps.minimizedTrackingLossGraceMs ?? 500), ); this.now = deps.now ?? (() => Date.now()); + this.fastPollIntervalMs = Math.max( + 50, + Math.floor(deps.fastPollIntervalMs ?? MACOS_FAST_POLL_INTERVAL_MS), + ); + this.stablePollIntervalMs = Math.max( + this.fastPollIntervalMs, + Math.floor(deps.stablePollIntervalMs ?? MACOS_STABLE_FOCUSED_POLL_INTERVAL_MS), + ); + this.setPollTimeout = deps.setPollTimeout ?? setTimeout; + this.clearPollTimeout = deps.clearPollTimeout ?? clearTimeout; const resolvedHelper = deps.resolveHelper?.() ?? null; if (resolvedHelper) { this.helperPath = resolvedHelper.helperPath; @@ -231,15 +288,15 @@ export class MacOSWindowTracker extends BaseWindowTracker { return true; } - private detectHelper(): void { - const shouldFilterBySocket = this.targetMpvSocketPath !== null; - - // Fall back to Swift helper first when filtering by socket path to avoid - // stale prebuilt binaries that don't support the new socket filter argument. - const swiftPath = path.join(__dirname, '..', '..', 'scripts', 'get-mpv-window-macos.swift'); - if (shouldFilterBySocket && this.tryUseHelper(swiftPath, 'swift')) { - return; + private tryUseCompiledHelper(candidatePath: string, sourcePath: string): boolean { + if (!isCompiledMacOSHelperCurrent(candidatePath, sourcePath)) { + return false; } + return this.tryUseHelper(candidatePath, 'binary'); + } + + private detectHelper(): void { + const swiftPath = path.join(__dirname, '..', '..', 'scripts', 'get-mpv-window-macos.swift'); // Prefer resources path (outside asar) in packaged apps. const resourcesPath = process.resourcesPath; @@ -250,9 +307,21 @@ export class MacOSWindowTracker extends BaseWindowTracker { } } - // Dist binary path (development / unpacked installs). - const distBinaryPath = path.join(__dirname, '..', '..', 'scripts', 'get-mpv-window-macos'); - if (this.tryUseHelper(distBinaryPath, 'binary')) { + // Built source runs from dist/window-trackers, so the compiled helper is a sibling of dist. + const bundledBinaryPath = path.join(__dirname, '..', 'scripts', 'get-mpv-window-macos'); + if (this.tryUseCompiledHelper(bundledBinaryPath, swiftPath)) { + return; + } + + // Source-tree/manual helper build path. + const sourceTreeBinaryPath = path.join( + __dirname, + '..', + '..', + 'scripts', + 'get-mpv-window-macos', + ); + if (this.tryUseCompiledHelper(sourceTreeBinaryPath, swiftPath)) { return; } @@ -284,15 +353,16 @@ export class MacOSWindowTracker extends BaseWindowTracker { } start(): void { - this.pollInterval = setInterval(() => this.pollGeometry(), 250); + if (this.started) { + return; + } + this.started = true; this.pollGeometry(); } stop(): void { - if (this.pollInterval) { - clearInterval(this.pollInterval); - this.pollInterval = null; - } + this.started = false; + this.clearScheduledPoll(); } override isTargetWindowMinimized(): boolean { @@ -318,7 +388,16 @@ export class MacOSWindowTracker extends BaseWindowTracker { return this.now() - this.trackingLossStartedAtMs > graceMs; } + private shouldPreserveFocusedTargetOnMiss(): boolean { + return this.isTracking() && this.isTargetWindowFocused() && this.getGeometry() !== null; + } + private registerTrackingMiss(graceMs = this.trackingLossGraceMs): void { + if (this.shouldPreserveFocusedTargetOnMiss()) { + this.resetTrackingLossState(); + return; + } + this.consecutiveMisses += 1; if (this.shouldDropTracking(graceMs)) { this.updateGeometry(null); @@ -326,6 +405,39 @@ export class MacOSWindowTracker extends BaseWindowTracker { } } + private resolveNextPollIntervalMs(): number { + if ( + this.isTracking() && + this.isTargetWindowFocused() && + !this.targetWindowMinimized && + this.getGeometry() !== null + ) { + return this.stablePollIntervalMs; + } + + return this.fastPollIntervalMs; + } + + private clearScheduledPoll(): void { + if (!this.pollTimeout) { + return; + } + + this.clearPollTimeout(this.pollTimeout); + this.pollTimeout = null; + } + + private scheduleNextPoll(): void { + if (!this.started || this.pollTimeout) { + return; + } + + this.pollTimeout = this.setPollTimeout(() => { + this.pollTimeout = null; + this.pollGeometry(); + }, this.resolveNextPollIntervalMs()); + } + private pollGeometry(): void { if (this.pollInFlight || !this.helperPath || !this.helperType) { return; @@ -348,10 +460,16 @@ export class MacOSWindowTracker extends BaseWindowTracker { this.updateTargetWindowFocused(true); return; } + if (parsed.inactive) { + this.targetWindowMinimized = false; + this.updateTargetWindowFocused(false); + this.registerTrackingMiss(); + return; + } this.resetTrackingLossState(); this.targetWindowMinimized = false; - this.updateFocus(parsed.focused); - this.updateGeometry(parsed.geometry); + this.updateGeometry(parsed.geometry, parsed.focused); + this.updateTargetWindowFocused(parsed.focused); return; } @@ -373,6 +491,7 @@ export class MacOSWindowTracker extends BaseWindowTracker { }) .finally(() => { this.pollInFlight = false; + this.scheduleNextPoll(); }); } }