Fix macOS overlay foreground handling and character-dictionary cache reuse (#68)

This commit is contained in:
2026-05-16 20:43:01 -07:00
committed by GitHub
parent 89723e2ccb
commit 49f89e6452
55 changed files with 2088 additions and 227 deletions
+317 -3
View File
@@ -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'), {
@@ -10,6 +17,99 @@ test('parseMacOSHelperOutput parses minimized state', () => {
});
});
test('parseMacOSHelperOutput parses active focused state without geometry', () => {
assert.deepEqual(parseMacOSHelperOutput('active'), {
geometry: null,
focused: true,
active: true,
});
});
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<typeof setTimeout>;
}) 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<typeof setTimeout>;
}) 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 = [
@@ -55,10 +155,221 @@ test('MacOSWindowTracker keeps the last geometry through a single helper miss',
});
});
test('MacOSWindowTracker preserves target focus on helper not-found while retaining geometry', async () => {
let callIndex = 0;
const focusChanges: boolean[] = [];
const outputs = [
{ stdout: '10,20,1280,720,1', 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)!,
trackingLossGraceMs: 1_500,
});
tracker.onWindowFocusChange = (focused) => {
focusChanges.push(focused);
};
(tracker as unknown as { pollGeometry: () => void }).pollGeometry();
await new Promise((resolve) => setTimeout(resolve, 0));
assert.equal(tracker.isTargetWindowFocused(), true);
(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(), true);
assert.deepEqual(focusChanges, [true]);
});
test('MacOSWindowTracker keeps focused fullscreen target through active helper misses after grace', async () => {
let callIndex = 0;
let now = 1_000;
const outputs = [
{ stdout: '10,20,1280,720,1', stderr: '' },
{ stdout: 'active', stderr: '' },
{ stdout: 'active', 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 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));
assert.equal(tracker.isTracking(), true);
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,
});
});
test('MacOSWindowTracker drops previously focused target after repeated not-found misses exceed 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: '' },
];
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));
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]);
now += 1_000;
(tracker as unknown as { pollGeometry: () => void }).pollGeometry();
await new Promise((resolve) => setTimeout(resolve, 0));
assert.equal(tracker.isTracking(), false);
assert.equal(tracker.isTargetWindowFocused(), false);
assert.equal(tracker.getGeometry(), null);
assert.deepEqual(focusChanges, [true, false]);
});
test('MacOSWindowTracker drops previously focused target after repeated helper execution failures exceed grace', 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(), false);
assert.equal(tracker.isTargetWindowFocused(), false);
assert.equal(tracker.getGeometry(), null);
assert.deepEqual(focusChanges, [true, false]);
});
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: '' },
];
@@ -75,6 +386,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));
@@ -84,6 +396,7 @@ test('MacOSWindowTracker drops tracking after consecutive helper misses', async
await new Promise((resolve) => setTimeout(resolve, 0));
assert.equal(tracker.isTracking(), false);
assert.equal(tracker.getGeometry(), null);
assert.equal(tracker.isTargetWindowFocused(), false);
});
test('MacOSWindowTracker keeps tracking through repeated helper misses inside grace window', async () => {
@@ -137,7 +450,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: '' },
@@ -156,6 +469,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();
+164 -19
View File
@@ -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 =
@@ -49,11 +55,29 @@ export type MacOSHelperWindowState =
geometry: WindowGeometry;
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(
@@ -90,6 +114,25 @@ function runHelperWithExecFile(
});
}
export function isCompiledMacOSHelperCurrent(
binaryPath: string,
sourcePath: string,
helperFs: Pick<typeof fs, 'existsSync' | 'statSync'> = 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') {
@@ -99,6 +142,20 @@ export function parseMacOSHelperOutput(result: string): MacOSHelperWindowState |
minimized: true,
};
}
if (trimmed === 'active') {
return {
geometry: null,
focused: true,
active: true,
};
}
if (trimmed === 'inactive') {
return {
geometry: null,
focused: false,
inactive: true,
};
}
if (!trimmed || trimmed === 'not-found') {
return null;
}
@@ -138,8 +195,9 @@ export function parseMacOSHelperOutput(result: string): MacOSHelperWindowState |
}
export class MacOSWindowTracker extends BaseWindowTracker {
private pollInterval: ReturnType<typeof setInterval> | null = null;
private pollTimeout: ReturnType<typeof setTimeout> | null = null;
private pollInFlight = false;
private started = false;
private helperPath: string | null = null;
private helperType: 'binary' | 'swift' | null = null;
private lastExecErrorFingerprint: string | null = null;
@@ -154,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;
@@ -169,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;
@@ -216,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;
@@ -235,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;
}
@@ -269,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 {
@@ -303,7 +388,21 @@ 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()) {
if (this.trackingLossStartedAtMs === null) {
this.trackingLossStartedAtMs = this.now();
return;
}
if (this.now() - this.trackingLossStartedAtMs <= graceMs) {
return;
}
}
this.consecutiveMisses += 1;
if (this.shouldDropTracking(graceMs)) {
this.updateGeometry(null);
@@ -311,6 +410,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;
@@ -327,10 +459,22 @@ export class MacOSWindowTracker extends BaseWindowTracker {
this.registerTrackingMiss(this.minimizedTrackingLossGraceMs);
return;
}
if (parsed.active) {
this.resetTrackingLossState();
this.targetWindowMinimized = false;
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;
}
@@ -352,6 +496,7 @@ export class MacOSWindowTracker extends BaseWindowTracker {
})
.finally(() => {
this.pollInFlight = false;
this.scheduleNextPoll();
});
}
}