mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-05-26 12: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.
|
- 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.
|
- 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.
|
- 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.
|
// It works with both bundled and unbundled mpv installations.
|
||||||
//
|
//
|
||||||
// Usage: swift get-mpv-window-macos.swift
|
// 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
|
import Cocoa
|
||||||
@@ -34,6 +34,7 @@ private enum WindowLookupResult {
|
|||||||
case visible(WindowState)
|
case visible(WindowState)
|
||||||
case minimized
|
case minimized
|
||||||
case active
|
case active
|
||||||
|
case inactive
|
||||||
}
|
}
|
||||||
|
|
||||||
private let targetMpvSocketPath: String? = {
|
private let targetMpvSocketPath: String? = {
|
||||||
@@ -176,11 +177,17 @@ private func isFocusedMpvWindow(ownerPid: pid_t, frontmost: FrontmostApplication
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func isFrontmostTargetMpv(_ frontmost: FrontmostApplicationState?) -> Bool {
|
private func isFrontmostTargetMpv(_ frontmost: FrontmostApplicationState?) -> Bool {
|
||||||
guard let frontmost = frontmost else {
|
guard let frontmost = frontmost, frontmost.isMpv else {
|
||||||
return false
|
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? {
|
private func windowStateFromAccessibilityAPI() -> WindowLookupResult? {
|
||||||
@@ -307,9 +314,13 @@ private let lookupResult: WindowLookupResult? = {
|
|||||||
if let cgWindow = windowStateFromCoreGraphics() {
|
if let cgWindow = windowStateFromCoreGraphics() {
|
||||||
return .visible(cgWindow)
|
return .visible(cgWindow)
|
||||||
}
|
}
|
||||||
if isFrontmostTargetMpv(frontmostApplicationState()) {
|
let frontmost = frontmostApplicationState()
|
||||||
|
if isFrontmostTargetMpv(frontmost) {
|
||||||
return .active
|
return .active
|
||||||
}
|
}
|
||||||
|
if frontmost != nil {
|
||||||
|
return .inactive
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}()
|
}()
|
||||||
|
|
||||||
@@ -323,6 +334,8 @@ if let result = lookupResult {
|
|||||||
print("minimized")
|
print("minimized")
|
||||||
case .active:
|
case .active:
|
||||||
print("active")
|
print("active")
|
||||||
|
case .inactive:
|
||||||
|
print("inactive")
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
print("not-found")
|
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', () => {
|
test('frontmost mpv app emits active state when geometry lookup misses', () => {
|
||||||
assert.ok(
|
assert.ok(
|
||||||
source.includes('case active'),
|
/case\s+\.active:/.test(source),
|
||||||
'helper should expose an active state without window geometry',
|
'helper should expose an active state without window geometry',
|
||||||
);
|
);
|
||||||
assert.ok(
|
assert.ok(
|
||||||
source.includes('frontmost.isMpv && windowHasTargetSocket(frontmost.pid)'),
|
source.includes('if windowHasTargetSocket(frontmost.pid)'),
|
||||||
'active state should be limited to the frontmost target mpv process',
|
'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(
|
assert.ok(
|
||||||
source.includes('return .active'),
|
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');
|
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'));
|
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', () => {
|
test('macOS suppresses immediate repeat loading OSD after tracker recovery until cooldown expires', () => {
|
||||||
const { window } = createMainWindowRecorder();
|
const { window } = createMainWindowRecorder();
|
||||||
const osdMessages: string[] = [];
|
const osdMessages: string[] = [];
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
import assert from 'node:assert/strict';
|
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 test from 'node:test';
|
||||||
import { MacOSWindowTracker, parseMacOSHelperOutput } from './macos-tracker';
|
import {
|
||||||
|
isCompiledMacOSHelperCurrent,
|
||||||
|
MacOSWindowTracker,
|
||||||
|
parseMacOSHelperOutput,
|
||||||
|
} from './macos-tracker';
|
||||||
|
|
||||||
test('parseMacOSHelperOutput parses minimized state', () => {
|
test('parseMacOSHelperOutput parses minimized state', () => {
|
||||||
assert.deepEqual(parseMacOSHelperOutput('minimized'), {
|
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 () => {
|
test('MacOSWindowTracker keeps the last geometry through a single helper miss', async () => {
|
||||||
let callIndex = 0;
|
let callIndex = 0;
|
||||||
const outputs = [
|
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;
|
let callIndex = 0;
|
||||||
const focusChanges: boolean[] = [];
|
const focusChanges: boolean[] = [];
|
||||||
const outputs = [
|
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 () => {
|
test('MacOSWindowTracker drops tracking after consecutive helper misses', async () => {
|
||||||
let callIndex = 0;
|
let callIndex = 0;
|
||||||
const outputs = [
|
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: '' },
|
{ stdout: 'not-found', stderr: '' },
|
||||||
];
|
];
|
||||||
@@ -164,6 +391,7 @@ test('MacOSWindowTracker drops tracking after consecutive helper misses', async
|
|||||||
(tracker as unknown as { pollGeometry: () => void }).pollGeometry();
|
(tracker as unknown as { pollGeometry: () => void }).pollGeometry();
|
||||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
assert.equal(tracker.isTracking(), true);
|
assert.equal(tracker.isTracking(), true);
|
||||||
|
assert.equal(tracker.isTargetWindowFocused(), false);
|
||||||
|
|
||||||
(tracker as unknown as { pollGeometry: () => void }).pollGeometry();
|
(tracker as unknown as { pollGeometry: () => void }).pollGeometry();
|
||||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
@@ -227,7 +455,7 @@ test('MacOSWindowTracker drops tracking after grace window expires', async () =>
|
|||||||
let callIndex = 0;
|
let callIndex = 0;
|
||||||
let now = 1_000;
|
let now = 1_000;
|
||||||
const outputs = [
|
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: '' },
|
{ 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();
|
(tracker as unknown as { pollGeometry: () => void }).pollGeometry();
|
||||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
assert.equal(tracker.isTracking(), true);
|
assert.equal(tracker.isTracking(), true);
|
||||||
|
assert.equal(tracker.isTargetWindowFocused(), false);
|
||||||
|
|
||||||
now += 250;
|
now += 250;
|
||||||
(tracker as unknown as { pollGeometry: () => void }).pollGeometry();
|
(tracker as unknown as { pollGeometry: () => void }).pollGeometry();
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ import { createLogger } from '../logger';
|
|||||||
import type { WindowGeometry } from '../types';
|
import type { WindowGeometry } from '../types';
|
||||||
|
|
||||||
const log = createLogger('tracker').child('macos');
|
const log = createLogger('tracker').child('macos');
|
||||||
|
const MACOS_FAST_POLL_INTERVAL_MS = 250;
|
||||||
|
const MACOS_STABLE_FOCUSED_POLL_INTERVAL_MS = 1_000;
|
||||||
|
|
||||||
type MacOSTrackerRunnerResult = {
|
type MacOSTrackerRunnerResult = {
|
||||||
stdout: string;
|
stdout: string;
|
||||||
@@ -42,6 +44,10 @@ type MacOSTrackerDeps = {
|
|||||||
trackingLossGraceMs?: number;
|
trackingLossGraceMs?: number;
|
||||||
minimizedTrackingLossGraceMs?: number;
|
minimizedTrackingLossGraceMs?: number;
|
||||||
now?: () => number;
|
now?: () => number;
|
||||||
|
fastPollIntervalMs?: number;
|
||||||
|
stablePollIntervalMs?: number;
|
||||||
|
setPollTimeout?: typeof setTimeout;
|
||||||
|
clearPollTimeout?: typeof clearTimeout;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type MacOSHelperWindowState =
|
export type MacOSHelperWindowState =
|
||||||
@@ -50,18 +56,28 @@ export type MacOSHelperWindowState =
|
|||||||
focused: boolean;
|
focused: boolean;
|
||||||
minimized?: false;
|
minimized?: false;
|
||||||
active?: false;
|
active?: false;
|
||||||
|
inactive?: false;
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
geometry: null;
|
geometry: null;
|
||||||
focused: true;
|
focused: true;
|
||||||
active: true;
|
active: true;
|
||||||
minimized?: false;
|
minimized?: false;
|
||||||
|
inactive?: false;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
geometry: null;
|
||||||
|
focused: false;
|
||||||
|
inactive: true;
|
||||||
|
active?: false;
|
||||||
|
minimized?: false;
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
geometry: null;
|
geometry: null;
|
||||||
focused: false;
|
focused: false;
|
||||||
minimized: true;
|
minimized: true;
|
||||||
active?: false;
|
active?: false;
|
||||||
|
inactive?: false;
|
||||||
};
|
};
|
||||||
|
|
||||||
function runHelperWithExecFile(
|
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 {
|
export function parseMacOSHelperOutput(result: string): MacOSHelperWindowState | null {
|
||||||
const trimmed = result.trim();
|
const trimmed = result.trim();
|
||||||
if (trimmed === 'minimized') {
|
if (trimmed === 'minimized') {
|
||||||
@@ -114,6 +149,13 @@ export function parseMacOSHelperOutput(result: string): MacOSHelperWindowState |
|
|||||||
active: true,
|
active: true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
if (trimmed === 'inactive') {
|
||||||
|
return {
|
||||||
|
geometry: null,
|
||||||
|
focused: false,
|
||||||
|
inactive: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
if (!trimmed || trimmed === 'not-found') {
|
if (!trimmed || trimmed === 'not-found') {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -153,8 +195,9 @@ export function parseMacOSHelperOutput(result: string): MacOSHelperWindowState |
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class MacOSWindowTracker extends BaseWindowTracker {
|
export class MacOSWindowTracker extends BaseWindowTracker {
|
||||||
private pollInterval: ReturnType<typeof setInterval> | null = null;
|
private pollTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||||
private pollInFlight = false;
|
private pollInFlight = false;
|
||||||
|
private started = false;
|
||||||
private helperPath: string | null = null;
|
private helperPath: string | null = null;
|
||||||
private helperType: 'binary' | 'swift' | null = null;
|
private helperType: 'binary' | 'swift' | null = null;
|
||||||
private lastExecErrorFingerprint: string | null = null;
|
private lastExecErrorFingerprint: string | null = null;
|
||||||
@@ -169,6 +212,10 @@ export class MacOSWindowTracker extends BaseWindowTracker {
|
|||||||
private readonly trackingLossGraceMs: number;
|
private readonly trackingLossGraceMs: number;
|
||||||
private readonly minimizedTrackingLossGraceMs: number;
|
private readonly minimizedTrackingLossGraceMs: number;
|
||||||
private readonly now: () => 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 consecutiveMisses = 0;
|
||||||
private trackingLossStartedAtMs: number | null = null;
|
private trackingLossStartedAtMs: number | null = null;
|
||||||
private targetWindowMinimized = false;
|
private targetWindowMinimized = false;
|
||||||
@@ -184,6 +231,16 @@ export class MacOSWindowTracker extends BaseWindowTracker {
|
|||||||
Math.floor(deps.minimizedTrackingLossGraceMs ?? 500),
|
Math.floor(deps.minimizedTrackingLossGraceMs ?? 500),
|
||||||
);
|
);
|
||||||
this.now = deps.now ?? (() => Date.now());
|
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;
|
const resolvedHelper = deps.resolveHelper?.() ?? null;
|
||||||
if (resolvedHelper) {
|
if (resolvedHelper) {
|
||||||
this.helperPath = resolvedHelper.helperPath;
|
this.helperPath = resolvedHelper.helperPath;
|
||||||
@@ -231,15 +288,15 @@ export class MacOSWindowTracker extends BaseWindowTracker {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
private detectHelper(): void {
|
private tryUseCompiledHelper(candidatePath: string, sourcePath: string): boolean {
|
||||||
const shouldFilterBySocket = this.targetMpvSocketPath !== null;
|
if (!isCompiledMacOSHelperCurrent(candidatePath, sourcePath)) {
|
||||||
|
return false;
|
||||||
// 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;
|
|
||||||
}
|
}
|
||||||
|
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.
|
// Prefer resources path (outside asar) in packaged apps.
|
||||||
const resourcesPath = process.resourcesPath;
|
const resourcesPath = process.resourcesPath;
|
||||||
@@ -250,9 +307,21 @@ export class MacOSWindowTracker extends BaseWindowTracker {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Dist binary path (development / unpacked installs).
|
// Built source runs from dist/window-trackers, so the compiled helper is a sibling of dist.
|
||||||
const distBinaryPath = path.join(__dirname, '..', '..', 'scripts', 'get-mpv-window-macos');
|
const bundledBinaryPath = path.join(__dirname, '..', 'scripts', 'get-mpv-window-macos');
|
||||||
if (this.tryUseHelper(distBinaryPath, 'binary')) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -284,15 +353,16 @@ export class MacOSWindowTracker extends BaseWindowTracker {
|
|||||||
}
|
}
|
||||||
|
|
||||||
start(): void {
|
start(): void {
|
||||||
this.pollInterval = setInterval(() => this.pollGeometry(), 250);
|
if (this.started) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.started = true;
|
||||||
this.pollGeometry();
|
this.pollGeometry();
|
||||||
}
|
}
|
||||||
|
|
||||||
stop(): void {
|
stop(): void {
|
||||||
if (this.pollInterval) {
|
this.started = false;
|
||||||
clearInterval(this.pollInterval);
|
this.clearScheduledPoll();
|
||||||
this.pollInterval = null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override isTargetWindowMinimized(): boolean {
|
override isTargetWindowMinimized(): boolean {
|
||||||
@@ -318,7 +388,16 @@ export class MacOSWindowTracker extends BaseWindowTracker {
|
|||||||
return this.now() - this.trackingLossStartedAtMs > graceMs;
|
return this.now() - this.trackingLossStartedAtMs > graceMs;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private shouldPreserveFocusedTargetOnMiss(): boolean {
|
||||||
|
return this.isTracking() && this.isTargetWindowFocused() && this.getGeometry() !== null;
|
||||||
|
}
|
||||||
|
|
||||||
private registerTrackingMiss(graceMs = this.trackingLossGraceMs): void {
|
private registerTrackingMiss(graceMs = this.trackingLossGraceMs): void {
|
||||||
|
if (this.shouldPreserveFocusedTargetOnMiss()) {
|
||||||
|
this.resetTrackingLossState();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
this.consecutiveMisses += 1;
|
this.consecutiveMisses += 1;
|
||||||
if (this.shouldDropTracking(graceMs)) {
|
if (this.shouldDropTracking(graceMs)) {
|
||||||
this.updateGeometry(null);
|
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 {
|
private pollGeometry(): void {
|
||||||
if (this.pollInFlight || !this.helperPath || !this.helperType) {
|
if (this.pollInFlight || !this.helperPath || !this.helperType) {
|
||||||
return;
|
return;
|
||||||
@@ -348,10 +460,16 @@ export class MacOSWindowTracker extends BaseWindowTracker {
|
|||||||
this.updateTargetWindowFocused(true);
|
this.updateTargetWindowFocused(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (parsed.inactive) {
|
||||||
|
this.targetWindowMinimized = false;
|
||||||
|
this.updateTargetWindowFocused(false);
|
||||||
|
this.registerTrackingMiss();
|
||||||
|
return;
|
||||||
|
}
|
||||||
this.resetTrackingLossState();
|
this.resetTrackingLossState();
|
||||||
this.targetWindowMinimized = false;
|
this.targetWindowMinimized = false;
|
||||||
this.updateFocus(parsed.focused);
|
this.updateGeometry(parsed.geometry, parsed.focused);
|
||||||
this.updateGeometry(parsed.geometry);
|
this.updateTargetWindowFocused(parsed.focused);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -373,6 +491,7 @@ export class MacOSWindowTracker extends BaseWindowTracker {
|
|||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
this.pollInFlight = false;
|
this.pollInFlight = false;
|
||||||
|
this.scheduleNextPoll();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user