mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-03-20 12:11:28 -07:00
feat: stabilize startup sync and overlay/runtime paths
This commit is contained in:
172
src/window-trackers/macos-tracker.test.ts
Normal file
172
src/window-trackers/macos-tracker.test.ts
Normal file
@@ -0,0 +1,172 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { MacOSWindowTracker } from './macos-tracker';
|
||||
|
||||
test('MacOSWindowTracker keeps the last geometry through a single helper miss', async () => {
|
||||
let callIndex = 0;
|
||||
const outputs = [
|
||||
{ stdout: '10,20,1280,720,1', stderr: '' },
|
||||
{ stdout: 'not-found', stderr: '' },
|
||||
{ stdout: '10,20,1280,720,1', stderr: '' },
|
||||
];
|
||||
|
||||
const tracker = new MacOSWindowTracker('/tmp/mpv.sock', {
|
||||
resolveHelper: () => ({
|
||||
helperPath: 'helper.swift',
|
||||
helperType: 'swift',
|
||||
}),
|
||||
runHelper: async () => outputs[callIndex++] ?? outputs.at(-1)!,
|
||||
trackingLossGraceMs: 0,
|
||||
});
|
||||
|
||||
(tracker as unknown as { pollGeometry: () => void }).pollGeometry();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
assert.deepEqual(tracker.getGeometry(), {
|
||||
x: 10,
|
||||
y: 20,
|
||||
width: 1280,
|
||||
height: 720,
|
||||
});
|
||||
|
||||
(tracker as unknown as { pollGeometry: () => void }).pollGeometry();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
assert.deepEqual(tracker.getGeometry(), {
|
||||
x: 10,
|
||||
y: 20,
|
||||
width: 1280,
|
||||
height: 720,
|
||||
});
|
||||
|
||||
(tracker as unknown as { pollGeometry: () => void }).pollGeometry();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
assert.deepEqual(tracker.getGeometry(), {
|
||||
x: 10,
|
||||
y: 20,
|
||||
width: 1280,
|
||||
height: 720,
|
||||
});
|
||||
});
|
||||
|
||||
test('MacOSWindowTracker drops tracking after consecutive helper misses', async () => {
|
||||
let callIndex = 0;
|
||||
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)!,
|
||||
trackingLossGraceMs: 0,
|
||||
});
|
||||
|
||||
(tracker as unknown as { pollGeometry: () => void }).pollGeometry();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
assert.equal(tracker.isTracking(), true);
|
||||
|
||||
(tracker as unknown as { pollGeometry: () => void }).pollGeometry();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
assert.equal(tracker.isTracking(), true);
|
||||
|
||||
(tracker as unknown as { pollGeometry: () => void }).pollGeometry();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
assert.equal(tracker.isTracking(), false);
|
||||
assert.equal(tracker.getGeometry(), null);
|
||||
});
|
||||
|
||||
test('MacOSWindowTracker keeps tracking through repeated helper misses inside grace window', async () => {
|
||||
let callIndex = 0;
|
||||
let now = 1_000;
|
||||
const outputs = [
|
||||
{ stdout: '10,20,1280,720,1', stderr: '' },
|
||||
{ stdout: 'not-found', 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: 1_500,
|
||||
});
|
||||
|
||||
(tracker as unknown as { pollGeometry: () => void }).pollGeometry();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
assert.equal(tracker.isTracking(), true);
|
||||
|
||||
now += 250;
|
||||
(tracker as unknown as { pollGeometry: () => void }).pollGeometry();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
assert.equal(tracker.isTracking(), true);
|
||||
|
||||
now += 250;
|
||||
(tracker as unknown as { pollGeometry: () => void }).pollGeometry();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
assert.equal(tracker.isTracking(), true);
|
||||
|
||||
now += 250;
|
||||
(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,
|
||||
});
|
||||
});
|
||||
|
||||
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: '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 as unknown as { pollGeometry: () => void }).pollGeometry();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
assert.equal(tracker.isTracking(), true);
|
||||
|
||||
now += 250;
|
||||
(tracker as unknown as { pollGeometry: () => void }).pollGeometry();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
assert.equal(tracker.isTracking(), true);
|
||||
|
||||
now += 250;
|
||||
(tracker as unknown as { pollGeometry: () => void }).pollGeometry();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
assert.equal(tracker.isTracking(), true);
|
||||
|
||||
now += 250;
|
||||
(tracker as unknown as { pollGeometry: () => void }).pollGeometry();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
assert.equal(tracker.isTracking(), true);
|
||||
|
||||
now += 250;
|
||||
(tracker as unknown as { pollGeometry: () => void }).pollGeometry();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
assert.equal(tracker.isTracking(), false);
|
||||
assert.equal(tracker.getGeometry(), null);
|
||||
});
|
||||
@@ -26,11 +26,62 @@ import type { WindowGeometry } from '../types';
|
||||
|
||||
const log = createLogger('tracker').child('macos');
|
||||
|
||||
type MacOSTrackerRunnerResult = {
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
};
|
||||
|
||||
type MacOSTrackerDeps = {
|
||||
resolveHelper?: () => { helperPath: string; helperType: 'binary' | 'swift' } | null;
|
||||
runHelper?: (
|
||||
helperPath: string,
|
||||
helperType: 'binary' | 'swift',
|
||||
targetMpvSocketPath: string | null,
|
||||
) => Promise<MacOSTrackerRunnerResult>;
|
||||
maxConsecutiveMisses?: number;
|
||||
trackingLossGraceMs?: number;
|
||||
now?: () => number;
|
||||
};
|
||||
|
||||
export interface MacOSHelperWindowState {
|
||||
geometry: WindowGeometry;
|
||||
focused: boolean;
|
||||
}
|
||||
|
||||
function runHelperWithExecFile(
|
||||
helperPath: string,
|
||||
helperType: 'binary' | 'swift',
|
||||
targetMpvSocketPath: string | null,
|
||||
): Promise<MacOSTrackerRunnerResult> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const command = helperType === 'binary' ? helperPath : 'swift';
|
||||
const args = helperType === 'binary' ? [] : [helperPath];
|
||||
if (targetMpvSocketPath) {
|
||||
args.push(targetMpvSocketPath);
|
||||
}
|
||||
|
||||
execFile(
|
||||
command,
|
||||
args,
|
||||
{
|
||||
encoding: 'utf-8',
|
||||
timeout: 1000,
|
||||
maxBuffer: 1024 * 1024,
|
||||
},
|
||||
(error, stdout, stderr) => {
|
||||
if (error) {
|
||||
reject(Object.assign(error, { stderr }));
|
||||
return;
|
||||
}
|
||||
resolve({
|
||||
stdout: stdout || '',
|
||||
stderr: stderr || '',
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export function parseMacOSHelperOutput(result: string): MacOSHelperWindowState | null {
|
||||
const trimmed = result.trim();
|
||||
if (!trimmed || trimmed === 'not-found') {
|
||||
@@ -79,11 +130,31 @@ export class MacOSWindowTracker extends BaseWindowTracker {
|
||||
private lastExecErrorFingerprint: string | null = null;
|
||||
private lastExecErrorLoggedAtMs = 0;
|
||||
private readonly targetMpvSocketPath: string | null;
|
||||
private readonly runHelper: (
|
||||
helperPath: string,
|
||||
helperType: 'binary' | 'swift',
|
||||
targetMpvSocketPath: string | null,
|
||||
) => Promise<MacOSTrackerRunnerResult>;
|
||||
private readonly maxConsecutiveMisses: number;
|
||||
private readonly trackingLossGraceMs: number;
|
||||
private readonly now: () => number;
|
||||
private consecutiveMisses = 0;
|
||||
private trackingLossStartedAtMs: number | null = null;
|
||||
|
||||
constructor(targetMpvSocketPath?: string) {
|
||||
constructor(targetMpvSocketPath?: string, deps: MacOSTrackerDeps = {}) {
|
||||
super();
|
||||
this.targetMpvSocketPath = targetMpvSocketPath?.trim() || null;
|
||||
this.detectHelper();
|
||||
this.runHelper = deps.runHelper ?? runHelperWithExecFile;
|
||||
this.maxConsecutiveMisses = Math.max(1, Math.floor(deps.maxConsecutiveMisses ?? 2));
|
||||
this.trackingLossGraceMs = Math.max(0, Math.floor(deps.trackingLossGraceMs ?? 1_500));
|
||||
this.now = deps.now ?? (() => Date.now());
|
||||
const resolvedHelper = deps.resolveHelper?.() ?? null;
|
||||
if (resolvedHelper) {
|
||||
this.helperPath = resolvedHelper.helperPath;
|
||||
this.helperType = resolvedHelper.helperType;
|
||||
} else {
|
||||
this.detectHelper();
|
||||
}
|
||||
}
|
||||
|
||||
private materializeAsarHelper(sourcePath: string, helperType: 'binary' | 'swift'): string | null {
|
||||
@@ -188,48 +259,65 @@ export class MacOSWindowTracker extends BaseWindowTracker {
|
||||
}
|
||||
}
|
||||
|
||||
private resetTrackingLossState(): void {
|
||||
this.consecutiveMisses = 0;
|
||||
this.trackingLossStartedAtMs = null;
|
||||
}
|
||||
|
||||
private shouldDropTracking(): boolean {
|
||||
if (!this.isTracking()) {
|
||||
return true;
|
||||
}
|
||||
if (this.trackingLossGraceMs === 0) {
|
||||
return this.consecutiveMisses >= this.maxConsecutiveMisses;
|
||||
}
|
||||
if (this.trackingLossStartedAtMs === null) {
|
||||
this.trackingLossStartedAtMs = this.now();
|
||||
return false;
|
||||
}
|
||||
return this.now() - this.trackingLossStartedAtMs > this.trackingLossGraceMs;
|
||||
}
|
||||
|
||||
private registerTrackingMiss(): void {
|
||||
this.consecutiveMisses += 1;
|
||||
if (this.shouldDropTracking()) {
|
||||
this.updateGeometry(null);
|
||||
this.resetTrackingLossState();
|
||||
}
|
||||
}
|
||||
|
||||
private pollGeometry(): void {
|
||||
if (this.pollInFlight || !this.helperPath || !this.helperType) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.pollInFlight = true;
|
||||
|
||||
// Use Core Graphics API via Swift helper for reliable window detection
|
||||
// This works with both bundled and unbundled mpv installations
|
||||
const command = this.helperType === 'binary' ? this.helperPath : 'swift';
|
||||
const args = this.helperType === 'binary' ? [] : [this.helperPath];
|
||||
if (this.targetMpvSocketPath) {
|
||||
args.push(this.targetMpvSocketPath);
|
||||
}
|
||||
|
||||
execFile(
|
||||
command,
|
||||
args,
|
||||
{
|
||||
encoding: 'utf-8',
|
||||
timeout: 1000,
|
||||
maxBuffer: 1024 * 1024,
|
||||
},
|
||||
(err, stdout, stderr) => {
|
||||
if (err) {
|
||||
this.maybeLogExecError(err, stderr || '');
|
||||
this.updateGeometry(null);
|
||||
this.pollInFlight = false;
|
||||
return;
|
||||
}
|
||||
|
||||
void this.runHelper(this.helperPath, this.helperType, this.targetMpvSocketPath)
|
||||
.then(({ stdout }) => {
|
||||
const parsed = parseMacOSHelperOutput(stdout || '');
|
||||
if (parsed) {
|
||||
this.resetTrackingLossState();
|
||||
this.updateFocus(parsed.focused);
|
||||
this.updateGeometry(parsed.geometry);
|
||||
this.pollInFlight = false;
|
||||
return;
|
||||
}
|
||||
|
||||
this.updateGeometry(null);
|
||||
this.registerTrackingMiss();
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
const err = error instanceof Error ? error : new Error(String(error));
|
||||
const stderr =
|
||||
typeof error === 'object' &&
|
||||
error !== null &&
|
||||
'stderr' in error &&
|
||||
typeof (error as { stderr?: unknown }).stderr === 'string'
|
||||
? (error as { stderr: string }).stderr
|
||||
: '';
|
||||
this.maybeLogExecError(err, stderr);
|
||||
this.registerTrackingMiss();
|
||||
})
|
||||
.finally(() => {
|
||||
this.pollInFlight = false;
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user