mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-05-26 00:55:16 -07:00
fix(macos): release overlay when mpv loses focus
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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[] = [];
|
||||
|
||||
@@ -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<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 = [
|
||||
@@ -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();
|
||||
|
||||
@@ -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<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') {
|
||||
@@ -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<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;
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user