Fix Windows overlay tracking, z-order, and startup visibility

- switch Windows overlay tracking to native win32 polling with native owner and z-order helpers
- keep the visible overlay and stats overlay aligned across focus handoff, transient tracker misses, and minimize/restore cycles
- start the visible overlay click-through and hide the initial opaque startup frame until the tracked transparent state settles
- add a backlog task for the inconsistent mpv y-t overlay toggle after menu toggles
This commit is contained in:
2026-04-10 01:00:53 -07:00
committed by sudacode
parent fff54e914a
commit f457801708
35 changed files with 2658 additions and 230 deletions

View File

@@ -62,6 +62,10 @@ export abstract class BaseWindowTracker {
return this.targetWindowFocused;
}
isTargetWindowMinimized(): boolean {
return false;
}
protected updateTargetWindowFocused(focused: boolean): void {
if (this.targetWindowFocused === focused) {
return;

View File

@@ -0,0 +1,249 @@
import koffi from 'koffi';
const user32 = koffi.load('user32.dll');
const dwmapi = koffi.load('dwmapi.dll');
const kernel32 = koffi.load('kernel32.dll');
const RECT = koffi.struct('RECT', {
Left: 'int',
Top: 'int',
Right: 'int',
Bottom: 'int',
});
const MARGINS = koffi.struct('MARGINS', {
cxLeftWidth: 'int',
cxRightWidth: 'int',
cyTopHeight: 'int',
cyBottomHeight: 'int',
});
const WNDENUMPROC = koffi.proto('bool __stdcall WNDENUMPROC(intptr hwnd, intptr lParam)');
const EnumWindows = user32.func('bool __stdcall EnumWindows(WNDENUMPROC *cb, intptr lParam)');
const IsWindowVisible = user32.func('bool __stdcall IsWindowVisible(intptr hwnd)');
const IsIconic = user32.func('bool __stdcall IsIconic(intptr hwnd)');
const GetForegroundWindow = user32.func('intptr __stdcall GetForegroundWindow()');
const SetWindowPos = user32.func(
'bool __stdcall SetWindowPos(intptr hwnd, intptr hWndInsertAfter, int X, int Y, int cx, int cy, uint uFlags)',
);
const GetWindowThreadProcessId = user32.func(
'uint __stdcall GetWindowThreadProcessId(intptr hwnd, _Out_ uint *lpdwProcessId)',
);
const GetWindowLongW = user32.func('int __stdcall GetWindowLongW(intptr hwnd, int nIndex)');
const SetWindowLongPtrW = user32.func(
'intptr __stdcall SetWindowLongPtrW(intptr hwnd, int nIndex, intptr dwNewLong)',
);
const GetWindowFn = user32.func('intptr __stdcall GetWindow(intptr hwnd, uint uCmd)');
const GetWindowRect = user32.func('bool __stdcall GetWindowRect(intptr hwnd, _Out_ RECT *lpRect)');
const DwmGetWindowAttribute = dwmapi.func(
'int __stdcall DwmGetWindowAttribute(intptr hwnd, uint dwAttribute, _Out_ RECT *pvAttribute, uint cbAttribute)',
);
const DwmExtendFrameIntoClientArea = dwmapi.func(
'int __stdcall DwmExtendFrameIntoClientArea(intptr hwnd, MARGINS *pMarInset)',
);
const OpenProcess = kernel32.func(
'intptr __stdcall OpenProcess(uint dwDesiredAccess, bool bInheritHandle, uint dwProcessId)',
);
const CloseHandle = kernel32.func('bool __stdcall CloseHandle(intptr hObject)');
const QueryFullProcessImageNameW = kernel32.func(
'bool __stdcall QueryFullProcessImageNameW(intptr hProcess, uint dwFlags, _Out_ uint16 *lpExeName, _Inout_ uint *lpdwSize)',
);
const GWL_EXSTYLE = -20;
const WS_EX_TOPMOST = 0x00000008;
const GWLP_HWNDPARENT = -8;
const GW_HWNDPREV = 3;
const DWMWA_EXTENDED_FRAME_BOUNDS = 9;
const PROCESS_QUERY_LIMITED_INFORMATION = 0x1000;
const SWP_NOSIZE = 0x0001;
const SWP_NOMOVE = 0x0002;
const SWP_NOACTIVATE = 0x0010;
const SWP_NOOWNERZORDER = 0x0200;
const SWP_FLAGS = SWP_NOSIZE | SWP_NOMOVE | SWP_NOACTIVATE | SWP_NOOWNERZORDER;
const HWND_TOP = 0;
const HWND_BOTTOM = 1;
const HWND_TOPMOST = -1;
const HWND_NOTOPMOST = -2;
function extendOverlayFrameIntoClientArea(overlayHwnd: number): void {
DwmExtendFrameIntoClientArea(overlayHwnd, {
cxLeftWidth: -1,
cxRightWidth: -1,
cyTopHeight: -1,
cyBottomHeight: -1,
});
}
export interface WindowBounds {
x: number;
y: number;
width: number;
height: number;
}
export interface MpvWindowMatch {
hwnd: number;
bounds: WindowBounds;
area: number;
isForeground: boolean;
}
export interface MpvPollResult {
matches: MpvWindowMatch[];
focusState: boolean;
windowState: 'visible' | 'minimized' | 'not-found';
}
function getWindowBounds(hwnd: number): WindowBounds | null {
const rect = { Left: 0, Top: 0, Right: 0, Bottom: 0 };
const hr = DwmGetWindowAttribute(hwnd, DWMWA_EXTENDED_FRAME_BOUNDS, rect, koffi.sizeof(RECT));
if (hr !== 0) {
if (!GetWindowRect(hwnd, rect)) {
return null;
}
}
const width = rect.Right - rect.Left;
const height = rect.Bottom - rect.Top;
if (width <= 0 || height <= 0) return null;
return { x: rect.Left, y: rect.Top, width, height };
}
function getProcessNameByPid(pid: number): string | null {
const hProcess = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, pid);
if (!hProcess) return null;
try {
const buffer = new Uint16Array(260);
const size = new Uint32Array([260]);
if (!QueryFullProcessImageNameW(hProcess, 0, buffer, size)) {
return null;
}
const fullPath = String.fromCharCode(...buffer.slice(0, size[0]));
const fileName = fullPath.split('\\').pop() || '';
return fileName.replace(/\.exe$/i, '');
} finally {
CloseHandle(hProcess);
}
}
export function findMpvWindows(): MpvPollResult {
const foregroundHwnd = GetForegroundWindow();
const matches: MpvWindowMatch[] = [];
let hasMinimized = false;
let hasFocused = false;
const processNameCache = new Map<number, string | null>();
const cb = koffi.register((hwnd: number, _lParam: number) => {
if (!IsWindowVisible(hwnd)) return true;
const pid = new Uint32Array(1);
GetWindowThreadProcessId(hwnd, pid);
const pidValue = pid[0]!;
if (pidValue === 0) return true;
let processName = processNameCache.get(pidValue);
if (processName === undefined) {
processName = getProcessNameByPid(pidValue);
processNameCache.set(pidValue, processName);
}
if (!processName || processName.toLowerCase() !== 'mpv') return true;
if (IsIconic(hwnd)) {
hasMinimized = true;
return true;
}
const bounds = getWindowBounds(hwnd);
if (!bounds) return true;
const isForeground = foregroundHwnd !== 0 && hwnd === foregroundHwnd;
if (isForeground) hasFocused = true;
matches.push({
hwnd,
bounds,
area: bounds.width * bounds.height,
isForeground,
});
return true;
}, koffi.pointer(WNDENUMPROC));
try {
EnumWindows(cb, 0);
} finally {
koffi.unregister(cb);
}
return {
matches,
focusState: hasFocused,
windowState: matches.length > 0 ? 'visible' : hasMinimized ? 'minimized' : 'not-found',
};
}
export function getForegroundProcessName(): string | null {
const foregroundHwnd = GetForegroundWindow();
if (!foregroundHwnd) return null;
const pid = new Uint32Array(1);
GetWindowThreadProcessId(foregroundHwnd, pid);
const pidValue = pid[0]!;
if (pidValue === 0) return null;
return getProcessNameByPid(pidValue);
}
export function setOverlayOwner(overlayHwnd: number, mpvHwnd: number): void {
SetWindowLongPtrW(overlayHwnd, GWLP_HWNDPARENT, mpvHwnd);
extendOverlayFrameIntoClientArea(overlayHwnd);
}
export function ensureOverlayTransparency(overlayHwnd: number): void {
extendOverlayFrameIntoClientArea(overlayHwnd);
}
export function clearOverlayOwner(overlayHwnd: number): void {
SetWindowLongPtrW(overlayHwnd, GWLP_HWNDPARENT, 0);
}
export function bindOverlayAboveMpv(overlayHwnd: number, mpvHwnd: number): void {
const mpvExStyle = GetWindowLongW(mpvHwnd, GWL_EXSTYLE);
const mpvIsTopmost = (mpvExStyle & WS_EX_TOPMOST) !== 0;
const overlayExStyle = GetWindowLongW(overlayHwnd, GWL_EXSTYLE);
const overlayIsTopmost = (overlayExStyle & WS_EX_TOPMOST) !== 0;
if (mpvIsTopmost && !overlayIsTopmost) {
SetWindowPos(overlayHwnd, HWND_TOPMOST, 0, 0, 0, 0, SWP_FLAGS);
} else if (!mpvIsTopmost && overlayIsTopmost) {
SetWindowPos(overlayHwnd, HWND_NOTOPMOST, 0, 0, 0, 0, SWP_FLAGS);
}
const windowAboveMpv = GetWindowFn(mpvHwnd, GW_HWNDPREV);
if (windowAboveMpv !== 0 && windowAboveMpv === overlayHwnd) return;
let insertAfter = HWND_TOP;
if (windowAboveMpv !== 0) {
const aboveExStyle = GetWindowLongW(windowAboveMpv, GWL_EXSTYLE);
const aboveIsTopmost = (aboveExStyle & WS_EX_TOPMOST) !== 0;
if (aboveIsTopmost === mpvIsTopmost) {
insertAfter = windowAboveMpv;
}
}
SetWindowPos(overlayHwnd, insertAfter, 0, 0, 0, 0, SWP_FLAGS);
}
export function lowerOverlay(overlayHwnd: number): void {
SetWindowPos(overlayHwnd, HWND_NOTOPMOST, 0, 0, 0, 0, SWP_FLAGS);
SetWindowPos(overlayHwnd, HWND_BOTTOM, 0, 0, 0, 0, SWP_FLAGS);
}

View File

@@ -1,9 +1,14 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
lowerWindowsOverlayInZOrder,
parseWindowTrackerHelperForegroundProcess,
parseWindowTrackerHelperFocusState,
parseWindowTrackerHelperOutput,
parseWindowTrackerHelperState,
queryWindowsForegroundProcessName,
resolveWindowsTrackerHelper,
syncWindowsOverlayToMpvZOrder,
} from './windows-helper';
test('parseWindowTrackerHelperOutput parses helper geometry output', () => {
@@ -28,6 +33,105 @@ test('parseWindowTrackerHelperFocusState parses helper stderr metadata', () => {
assert.equal(parseWindowTrackerHelperFocusState(''), null);
});
test('parseWindowTrackerHelperState parses helper stderr metadata', () => {
assert.equal(parseWindowTrackerHelperState('state=visible'), 'visible');
assert.equal(parseWindowTrackerHelperState('focus=not-focused\nstate=minimized'), 'minimized');
assert.equal(parseWindowTrackerHelperState('state=unknown'), null);
assert.equal(parseWindowTrackerHelperState(''), null);
});
test('parseWindowTrackerHelperForegroundProcess parses helper stdout metadata', () => {
assert.equal(parseWindowTrackerHelperForegroundProcess('process=mpv'), 'mpv');
assert.equal(parseWindowTrackerHelperForegroundProcess('process=chrome'), 'chrome');
assert.equal(parseWindowTrackerHelperForegroundProcess('not-found'), null);
assert.equal(parseWindowTrackerHelperForegroundProcess(''), null);
});
test('queryWindowsForegroundProcessName reads foreground process from powershell helper', async () => {
const processName = await queryWindowsForegroundProcessName({
resolveHelper: () => ({
kind: 'powershell',
command: 'powershell.exe',
args: ['-File', 'helper.ps1'],
helperPath: 'helper.ps1',
}),
runHelper: async () => ({
stdout: 'process=mpv',
stderr: '',
}),
});
assert.equal(processName, 'mpv');
});
test('queryWindowsForegroundProcessName returns null when no powershell helper is available', async () => {
const processName = await queryWindowsForegroundProcessName({
resolveHelper: () => ({
kind: 'native',
command: 'helper.exe',
args: [],
helperPath: 'helper.exe',
}),
});
assert.equal(processName, null);
});
test('syncWindowsOverlayToMpvZOrder forwards socket path and overlay handle to powershell helper', async () => {
let capturedMode: string | null = null;
let capturedArgs: string[] | null = null;
const synced = await syncWindowsOverlayToMpvZOrder({
overlayWindowHandle: '12345',
targetMpvSocketPath: '\\\\.\\pipe\\subminer-socket',
resolveHelper: () => ({
kind: 'powershell',
command: 'powershell.exe',
args: ['-File', 'helper.ps1'],
helperPath: 'helper.ps1',
}),
runHelper: async (_spec, mode, extraArgs = []) => {
capturedMode = mode;
capturedArgs = extraArgs;
return {
stdout: 'ok',
stderr: '',
};
},
});
assert.equal(synced, true);
assert.equal(capturedMode, 'bind-overlay');
assert.deepEqual(capturedArgs, ['\\\\.\\pipe\\subminer-socket', '12345']);
});
test('lowerWindowsOverlayInZOrder forwards overlay handle to powershell helper', async () => {
let capturedMode: string | null = null;
let capturedArgs: string[] | null = null;
const lowered = await lowerWindowsOverlayInZOrder({
overlayWindowHandle: '67890',
resolveHelper: () => ({
kind: 'powershell',
command: 'powershell.exe',
args: ['-File', 'helper.ps1'],
helperPath: 'helper.ps1',
}),
runHelper: async (_spec, mode, extraArgs = []) => {
capturedMode = mode;
capturedArgs = extraArgs;
return {
stdout: 'ok',
stderr: '',
};
},
});
assert.equal(lowered, true);
assert.equal(capturedMode, 'lower-overlay');
assert.deepEqual(capturedArgs, ['67890']);
});
test('resolveWindowsTrackerHelper auto mode prefers native helper when present', () => {
const helper = resolveWindowsTrackerHelper({
dirname: 'C:\\repo\\dist\\window-trackers',

View File

@@ -19,6 +19,7 @@
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { execFile, type ExecFileException } from 'child_process';
import type { WindowGeometry } from '../types';
import { createLogger } from '../logger';
@@ -26,6 +27,13 @@ const log = createLogger('tracker').child('windows-helper');
export type WindowsTrackerHelperKind = 'powershell' | 'native';
export type WindowsTrackerHelperMode = 'auto' | 'powershell' | 'native';
export type WindowsTrackerHelperRunMode =
| 'geometry'
| 'foreground-process'
| 'bind-overlay'
| 'lower-overlay'
| 'set-owner'
| 'clear-owner';
export type WindowsTrackerHelperLaunchSpec = {
kind: WindowsTrackerHelperKind;
@@ -219,6 +227,182 @@ export function parseWindowTrackerHelperFocusState(output: string): boolean | nu
return null;
}
export function parseWindowTrackerHelperState(output: string): 'visible' | 'minimized' | null {
const stateLine = output
.split(/\r?\n/)
.map((line) => line.trim())
.find((line) => line.startsWith('state='));
if (!stateLine) {
return null;
}
const value = stateLine.slice('state='.length).trim().toLowerCase();
if (value === 'visible') {
return 'visible';
}
if (value === 'minimized') {
return 'minimized';
}
return null;
}
export function parseWindowTrackerHelperForegroundProcess(output: string): string | null {
const processLine = output
.split(/\r?\n/)
.map((line) => line.trim())
.find((line) => line.startsWith('process='));
if (!processLine) {
return null;
}
const value = processLine.slice('process='.length).trim();
return value.length > 0 ? value : null;
}
type WindowsTrackerHelperRunnerResult = {
stdout: string;
stderr: string;
};
function runWindowsTrackerHelperWithExecFile(
spec: WindowsTrackerHelperLaunchSpec,
mode: WindowsTrackerHelperRunMode,
extraArgs: string[] = [],
): Promise<WindowsTrackerHelperRunnerResult> {
return new Promise((resolve, reject) => {
const modeArgs = spec.kind === 'native' ? ['--mode', mode] : ['-Mode', mode];
execFile(
spec.command,
[...spec.args, ...modeArgs, ...extraArgs],
{
encoding: 'utf-8',
timeout: 1000,
maxBuffer: 1024 * 1024,
windowsHide: true,
},
(error: ExecFileException | null, stdout: string, stderr: string) => {
if (error) {
reject(Object.assign(error, { stderr }));
return;
}
resolve({ stdout, stderr });
},
);
});
}
export async function queryWindowsForegroundProcessName(deps: {
resolveHelper?: () => WindowsTrackerHelperLaunchSpec | null;
runHelper?: (
spec: WindowsTrackerHelperLaunchSpec,
mode: WindowsTrackerHelperRunMode,
extraArgs?: string[],
) => Promise<WindowsTrackerHelperRunnerResult>;
} = {}): Promise<string | null> {
const spec =
deps.resolveHelper?.() ??
resolveWindowsTrackerHelper({
helperModeEnv: 'powershell',
});
if (!spec || spec.kind !== 'powershell') {
return null;
}
const runHelper = deps.runHelper ?? runWindowsTrackerHelperWithExecFile;
const { stdout } = await runHelper(spec, 'foreground-process');
return parseWindowTrackerHelperForegroundProcess(stdout);
}
export async function syncWindowsOverlayToMpvZOrder(deps: {
overlayWindowHandle: string;
targetMpvSocketPath?: string | null;
resolveHelper?: () => WindowsTrackerHelperLaunchSpec | null;
runHelper?: (
spec: WindowsTrackerHelperLaunchSpec,
mode: WindowsTrackerHelperRunMode,
extraArgs?: string[],
) => Promise<WindowsTrackerHelperRunnerResult>;
}): Promise<boolean> {
const spec =
deps.resolveHelper?.() ??
resolveWindowsTrackerHelper({
helperModeEnv: 'powershell',
});
if (!spec || spec.kind !== 'powershell') {
return false;
}
const runHelper = deps.runHelper ?? runWindowsTrackerHelperWithExecFile;
const extraArgs = [deps.targetMpvSocketPath ?? '', deps.overlayWindowHandle];
const { stdout } = await runHelper(spec, 'bind-overlay', extraArgs);
return stdout.trim() === 'ok';
}
export async function lowerWindowsOverlayInZOrder(deps: {
overlayWindowHandle: string;
resolveHelper?: () => WindowsTrackerHelperLaunchSpec | null;
runHelper?: (
spec: WindowsTrackerHelperLaunchSpec,
mode: WindowsTrackerHelperRunMode,
extraArgs?: string[],
) => Promise<WindowsTrackerHelperRunnerResult>;
}): Promise<boolean> {
const spec =
deps.resolveHelper?.() ??
resolveWindowsTrackerHelper({
helperModeEnv: 'powershell',
});
if (!spec || spec.kind !== 'powershell') {
return false;
}
const runHelper = deps.runHelper ?? runWindowsTrackerHelperWithExecFile;
const { stdout } = await runHelper(spec, 'lower-overlay', [deps.overlayWindowHandle]);
return stdout.trim() === 'ok';
}
export function setWindowsOverlayOwnerNative(overlayHwnd: number, mpvHwnd: number): boolean {
try {
const win32 = require('./win32') as typeof import('./win32');
win32.setOverlayOwner(overlayHwnd, mpvHwnd);
return true;
} catch {
return false;
}
}
export function ensureWindowsOverlayTransparencyNative(overlayHwnd: number): boolean {
try {
const win32 = require('./win32') as typeof import('./win32');
win32.ensureOverlayTransparency(overlayHwnd);
return true;
} catch {
return false;
}
}
export function clearWindowsOverlayOwnerNative(overlayHwnd: number): boolean {
try {
const win32 = require('./win32') as typeof import('./win32');
win32.clearOverlayOwner(overlayHwnd);
return true;
} catch {
return false;
}
}
export function getWindowsForegroundProcessNameNative(): string | null {
try {
const win32 = require('./win32') as typeof import('./win32');
return win32.getForegroundProcessName();
} catch {
return null;
}
}
export function resolveWindowsTrackerHelper(
options: ResolveWindowsTrackerHelperOptions = {},
): WindowsTrackerHelperLaunchSpec | null {

View File

@@ -1,56 +1,62 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { WindowsWindowTracker } from './windows-tracker';
import type { MpvPollResult } from './win32';
test('WindowsWindowTracker skips overlapping polls while helper is in flight', async () => {
let helperCalls = 0;
let release: (() => void) | undefined;
const gate = new Promise<void>((resolve) => {
release = resolve;
});
function mpvVisible(
overrides: Partial<MpvPollResult & { x?: number; y?: number; width?: number; height?: number; focused?: boolean }> = {},
): MpvPollResult {
return {
matches: [
{
hwnd: 12345,
bounds: {
x: overrides.x ?? 0,
y: overrides.y ?? 0,
width: overrides.width ?? 1280,
height: overrides.height ?? 720,
},
area: (overrides.width ?? 1280) * (overrides.height ?? 720),
isForeground: overrides.focused ?? true,
},
],
focusState: overrides.focused ?? true,
windowState: 'visible',
};
}
const mpvNotFound: MpvPollResult = {
matches: [],
focusState: false,
windowState: 'not-found',
};
const mpvMinimized: MpvPollResult = {
matches: [],
focusState: false,
windowState: 'minimized',
};
test('WindowsWindowTracker skips overlapping polls while poll is in flight', () => {
let pollCalls = 0;
const tracker = new WindowsWindowTracker(undefined, {
resolveHelper: () => ({
kind: 'powershell',
command: 'powershell.exe',
args: ['-File', 'helper.ps1'],
helperPath: 'helper.ps1',
}),
runHelper: async () => {
helperCalls += 1;
await gate;
return {
stdout: '0,0,640,360',
stderr: 'focus=focused',
};
pollMpvWindows: () => {
pollCalls += 1;
return mpvVisible();
},
});
(tracker as unknown as { pollGeometry: () => void }).pollGeometry();
(tracker as unknown as { pollGeometry: () => void }).pollGeometry();
assert.equal(helperCalls, 1);
assert.ok(release);
release();
await new Promise((resolve) => setTimeout(resolve, 0));
assert.equal(pollCalls, 2);
});
test('WindowsWindowTracker updates geometry from helper output', async () => {
test('WindowsWindowTracker updates geometry from poll output', () => {
const tracker = new WindowsWindowTracker(undefined, {
resolveHelper: () => ({
kind: 'powershell',
command: 'powershell.exe',
args: ['-File', 'helper.ps1'],
helperPath: 'helper.ps1',
}),
runHelper: async () => ({
stdout: '10,20,1280,720',
stderr: 'focus=focused',
}),
pollMpvWindows: () => mpvVisible({ 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,
@@ -61,59 +67,180 @@ test('WindowsWindowTracker updates geometry from helper output', async () => {
assert.equal(tracker.isTargetWindowFocused(), true);
});
test('WindowsWindowTracker clears geometry for helper misses', async () => {
test('WindowsWindowTracker clears geometry for poll misses', () => {
const tracker = new WindowsWindowTracker(undefined, {
resolveHelper: () => ({
kind: 'powershell',
command: 'powershell.exe',
args: ['-File', 'helper.ps1'],
helperPath: 'helper.ps1',
}),
runHelper: async () => ({
stdout: 'not-found',
stderr: 'focus=not-focused',
}),
pollMpvWindows: () => mpvNotFound,
trackingLossGraceMs: 0,
});
(tracker as unknown as { pollGeometry: () => void }).pollGeometry();
await new Promise((resolve) => setTimeout(resolve, 0));
assert.equal(tracker.getGeometry(), null);
assert.equal(tracker.isTargetWindowFocused(), false);
});
test('WindowsWindowTracker retries without socket filter when filtered helper lookup misses', async () => {
const helperCalls: Array<string | null> = [];
const tracker = new WindowsWindowTracker('\\\\.\\pipe\\subminer-socket', {
resolveHelper: () => ({
kind: 'powershell',
command: 'powershell.exe',
args: ['-File', 'helper.ps1'],
helperPath: 'helper.ps1',
}),
runHelper: async (_spec, _mode, targetMpvSocketPath) => {
helperCalls.push(targetMpvSocketPath);
if (targetMpvSocketPath) {
return {
stdout: 'not-found',
stderr: 'focus=not-focused',
};
}
return {
stdout: '25,30,1440,810',
stderr: 'focus=focused',
};
},
test('WindowsWindowTracker keeps the last geometry through a single poll miss', () => {
let callIndex = 0;
const outputs = [
mpvVisible({ x: 10, y: 20, width: 1280, height: 720 }),
mpvNotFound,
mpvVisible({ x: 10, y: 20, width: 1280, height: 720 }),
];
const tracker = new WindowsWindowTracker(undefined, {
pollMpvWindows: () => 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 });
assert.deepEqual(helperCalls, ['\\\\.\\pipe\\subminer-socket', null]);
assert.deepEqual(tracker.getGeometry(), {
x: 25,
y: 30,
width: 1440,
height: 810,
});
assert.equal(tracker.isTargetWindowFocused(), true);
(tracker as unknown as { pollGeometry: () => void }).pollGeometry();
assert.deepEqual(tracker.getGeometry(), { x: 10, y: 20, width: 1280, height: 720 });
(tracker as unknown as { pollGeometry: () => void }).pollGeometry();
assert.deepEqual(tracker.getGeometry(), { x: 10, y: 20, width: 1280, height: 720 });
});
test('WindowsWindowTracker drops tracking after grace window expires', () => {
let callIndex = 0;
let now = 1_000;
const outputs = [
mpvVisible({ x: 10, y: 20, width: 1280, height: 720 }),
mpvNotFound,
mpvNotFound,
mpvNotFound,
mpvNotFound,
];
const tracker = new WindowsWindowTracker(undefined, {
pollMpvWindows: () => outputs[callIndex++] ?? outputs.at(-1)!,
now: () => now,
trackingLossGraceMs: 500,
});
(tracker as unknown as { pollGeometry: () => void }).pollGeometry();
assert.equal(tracker.isTracking(), true);
now += 250;
(tracker as unknown as { pollGeometry: () => void }).pollGeometry();
assert.equal(tracker.isTracking(), true);
now += 250;
(tracker as unknown as { pollGeometry: () => void }).pollGeometry();
assert.equal(tracker.isTracking(), true);
now += 250;
(tracker as unknown as { pollGeometry: () => void }).pollGeometry();
assert.equal(tracker.isTracking(), true);
now += 250;
(tracker as unknown as { pollGeometry: () => void }).pollGeometry();
assert.equal(tracker.isTracking(), false);
assert.equal(tracker.getGeometry(), null);
});
test('WindowsWindowTracker keeps tracking through repeated poll misses inside grace window', () => {
let callIndex = 0;
let now = 1_000;
const outputs = [
mpvVisible({ x: 10, y: 20, width: 1280, height: 720 }),
mpvNotFound,
mpvNotFound,
mpvNotFound,
mpvVisible({ x: 10, y: 20, width: 1280, height: 720 }),
];
const tracker = new WindowsWindowTracker(undefined, {
pollMpvWindows: () => outputs[callIndex++] ?? outputs.at(-1)!,
now: () => now,
trackingLossGraceMs: 1_500,
});
(tracker as unknown as { pollGeometry: () => void }).pollGeometry();
assert.equal(tracker.isTracking(), true);
now += 250;
(tracker as unknown as { pollGeometry: () => void }).pollGeometry();
assert.equal(tracker.isTracking(), true);
now += 250;
(tracker as unknown as { pollGeometry: () => void }).pollGeometry();
assert.equal(tracker.isTracking(), true);
now += 250;
(tracker as unknown as { pollGeometry: () => void }).pollGeometry();
assert.equal(tracker.isTracking(), true);
assert.deepEqual(tracker.getGeometry(), { x: 10, y: 20, width: 1280, height: 720 });
now += 250;
(tracker as unknown as { pollGeometry: () => void }).pollGeometry();
assert.equal(tracker.isTracking(), true);
assert.deepEqual(tracker.getGeometry(), { x: 10, y: 20, width: 1280, height: 720 });
});
test('WindowsWindowTracker keeps tracking through a transient minimized report inside minimized grace window', () => {
let callIndex = 0;
let now = 1_000;
const outputs: MpvPollResult[] = [
mpvVisible({ x: 10, y: 20, width: 1280, height: 720 }),
mpvMinimized,
mpvVisible({ x: 10, y: 20, width: 1280, height: 720 }),
];
const tracker = new WindowsWindowTracker(undefined, {
pollMpvWindows: () => outputs[callIndex++] ?? outputs.at(-1)!,
now: () => now,
minimizedTrackingLossGraceMs: 200,
});
(tracker as unknown as { pollGeometry: () => void }).pollGeometry();
assert.equal(tracker.isTracking(), true);
now += 100;
(tracker as unknown as { pollGeometry: () => void }).pollGeometry();
assert.equal(tracker.isTracking(), true);
assert.deepEqual(tracker.getGeometry(), { x: 10, y: 20, width: 1280, height: 720 });
now += 100;
(tracker as unknown as { pollGeometry: () => void }).pollGeometry();
assert.equal(tracker.isTracking(), true);
assert.deepEqual(tracker.getGeometry(), { x: 10, y: 20, width: 1280, height: 720 });
});
test('WindowsWindowTracker keeps tracking through repeated transient minimized reports inside minimized grace window', () => {
let callIndex = 0;
let now = 1_000;
const outputs: MpvPollResult[] = [
mpvVisible({ x: 10, y: 20, width: 1280, height: 720 }),
mpvMinimized,
mpvMinimized,
mpvVisible({ x: 10, y: 20, width: 1280, height: 720 }),
];
const tracker = new WindowsWindowTracker(undefined, {
pollMpvWindows: () => outputs[callIndex++] ?? outputs.at(-1)!,
now: () => now,
minimizedTrackingLossGraceMs: 500,
});
(tracker as unknown as { pollGeometry: () => void }).pollGeometry();
assert.equal(tracker.isTracking(), true);
now += 250;
(tracker as unknown as { pollGeometry: () => void }).pollGeometry();
assert.equal(tracker.isTracking(), true);
assert.equal(tracker.isTargetWindowMinimized(), true);
assert.deepEqual(tracker.getGeometry(), { x: 10, y: 20, width: 1280, height: 720 });
now += 250;
(tracker as unknown as { pollGeometry: () => void }).pollGeometry();
assert.equal(tracker.isTracking(), true);
assert.equal(tracker.isTargetWindowMinimized(), true);
assert.deepEqual(tracker.getGeometry(), { x: 10, y: 20, width: 1280, height: 720 });
now += 250;
(tracker as unknown as { pollGeometry: () => void }).pollGeometry();
assert.equal(tracker.isTracking(), true);
assert.equal(tracker.isTargetWindowMinimized(), false);
assert.deepEqual(tracker.getGeometry(), { x: 10, y: 20, width: 1280, height: 720 });
});

View File

@@ -16,80 +16,50 @@
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { execFile, type ExecFileException } from 'child_process';
import { BaseWindowTracker } from './base-tracker';
import {
parseWindowTrackerHelperFocusState,
parseWindowTrackerHelperOutput,
resolveWindowsTrackerHelper,
type WindowsTrackerHelperLaunchSpec,
} from './windows-helper';
import type { WindowGeometry } from '../types';
import type { MpvPollResult } from './win32';
import { createLogger } from '../logger';
const log = createLogger('tracker').child('windows');
type WindowsTrackerRunnerResult = {
stdout: string;
stderr: string;
};
type WindowsTrackerDeps = {
resolveHelper?: () => WindowsTrackerHelperLaunchSpec | null;
runHelper?: (
spec: WindowsTrackerHelperLaunchSpec,
mode: 'geometry',
targetMpvSocketPath: string | null,
) => Promise<WindowsTrackerRunnerResult>;
pollMpvWindows?: () => MpvPollResult;
maxConsecutiveMisses?: number;
trackingLossGraceMs?: number;
minimizedTrackingLossGraceMs?: number;
now?: () => number;
};
function runHelperWithExecFile(
spec: WindowsTrackerHelperLaunchSpec,
mode: 'geometry',
targetMpvSocketPath: string | null,
): Promise<WindowsTrackerRunnerResult> {
return new Promise((resolve, reject) => {
const modeArgs = spec.kind === 'native' ? ['--mode', mode] : ['-Mode', mode];
const args = targetMpvSocketPath
? [...spec.args, ...modeArgs, targetMpvSocketPath]
: [...spec.args, ...modeArgs];
execFile(
spec.command,
args,
{
encoding: 'utf-8',
timeout: 1000,
maxBuffer: 1024 * 1024,
windowsHide: true,
},
(error: ExecFileException | null, stdout: string, stderr: string) => {
if (error) {
reject(Object.assign(error, { stderr }));
return;
}
resolve({ stdout, stderr });
},
);
});
function defaultPollMpvWindows(): MpvPollResult {
const win32 = require('./win32') as typeof import('./win32');
return win32.findMpvWindows();
}
export class WindowsWindowTracker extends BaseWindowTracker {
private pollInterval: ReturnType<typeof setInterval> | null = null;
private pollInFlight = false;
private helperSpec: WindowsTrackerHelperLaunchSpec | null;
private readonly targetMpvSocketPath: string | null;
private readonly runHelper: (
spec: WindowsTrackerHelperLaunchSpec,
mode: 'geometry',
targetMpvSocketPath: string | null,
) => Promise<WindowsTrackerRunnerResult>;
private lastExecErrorFingerprint: string | null = null;
private lastExecErrorLoggedAtMs = 0;
private readonly pollMpvWindows: () => MpvPollResult;
private readonly maxConsecutiveMisses: number;
private readonly trackingLossGraceMs: number;
private readonly minimizedTrackingLossGraceMs: number;
private readonly now: () => number;
private lastPollErrorFingerprint: string | null = null;
private lastPollErrorLoggedAtMs = 0;
private consecutiveMisses = 0;
private trackingLossStartedAtMs: number | null = null;
private targetWindowMinimized = false;
constructor(targetMpvSocketPath?: string, deps: WindowsTrackerDeps = {}) {
constructor(_targetMpvSocketPath?: string, deps: WindowsTrackerDeps = {}) {
super();
this.targetMpvSocketPath = targetMpvSocketPath?.trim() || null;
this.helperSpec = deps.resolveHelper ? deps.resolveHelper() : resolveWindowsTrackerHelper();
this.runHelper = deps.runHelper ?? runHelperWithExecFile;
this.pollMpvWindows = deps.pollMpvWindows ?? defaultPollMpvWindows;
this.maxConsecutiveMisses = Math.max(1, Math.floor(deps.maxConsecutiveMisses ?? 2));
this.trackingLossGraceMs = Math.max(0, Math.floor(deps.trackingLossGraceMs ?? 1_500));
this.minimizedTrackingLossGraceMs = Math.max(
0,
Math.floor(deps.minimizedTrackingLossGraceMs ?? 500),
);
this.now = deps.now ?? (() => Date.now());
}
start(): void {
@@ -104,72 +74,99 @@ export class WindowsWindowTracker extends BaseWindowTracker {
}
}
private maybeLogExecError(error: Error, stderr: string): void {
const now = Date.now();
const fingerprint = `${error.message}|${stderr.trim()}`;
const shouldLog =
this.lastExecErrorFingerprint !== fingerprint || now - this.lastExecErrorLoggedAtMs >= 5000;
if (!shouldLog) {
return;
}
this.lastExecErrorFingerprint = fingerprint;
this.lastExecErrorLoggedAtMs = now;
log.warn('Windows helper execution failed', {
helperPath: this.helperSpec?.helperPath ?? null,
helperKind: this.helperSpec?.kind ?? null,
error: error.message,
stderr: stderr.trim(),
});
override isTargetWindowMinimized(): boolean {
return this.targetWindowMinimized;
}
private async runHelperWithSocketFallback(): Promise<WindowsTrackerRunnerResult> {
if (!this.helperSpec) {
return { stdout: 'not-found', stderr: '' };
}
private maybeLogPollError(error: Error): void {
const now = Date.now();
const fingerprint = error.message;
const shouldLog =
this.lastPollErrorFingerprint !== fingerprint || now - this.lastPollErrorLoggedAtMs >= 5000;
if (!shouldLog) return;
try {
const primary = await this.runHelper(this.helperSpec, 'geometry', this.targetMpvSocketPath);
const primaryGeometry = parseWindowTrackerHelperOutput(primary.stdout);
if (primaryGeometry || !this.targetMpvSocketPath) {
return primary;
}
} catch (error) {
if (!this.targetMpvSocketPath) {
throw error;
}
}
this.lastPollErrorFingerprint = fingerprint;
this.lastPollErrorLoggedAtMs = now;
log.warn('Windows native poll failed', { error: error.message });
}
return await this.runHelper(this.helperSpec, 'geometry', null);
private resetTrackingLossState(): void {
this.consecutiveMisses = 0;
this.trackingLossStartedAtMs = null;
}
private shouldDropTracking(graceMs = this.trackingLossGraceMs): boolean {
if (!this.isTracking()) {
return true;
}
if (graceMs === 0) {
return this.consecutiveMisses >= this.maxConsecutiveMisses;
}
if (this.trackingLossStartedAtMs === null) {
this.trackingLossStartedAtMs = this.now();
return false;
}
return this.now() - this.trackingLossStartedAtMs > graceMs;
}
private registerTrackingMiss(graceMs = this.trackingLossGraceMs): void {
this.consecutiveMisses += 1;
if (this.shouldDropTracking(graceMs)) {
this.updateGeometry(null);
this.resetTrackingLossState();
}
}
private selectBestMatch(
result: MpvPollResult,
): { geometry: WindowGeometry; focused: boolean } | null {
if (result.matches.length === 0) return null;
const focusedMatch = result.matches.find((m) => m.isForeground);
const best =
focusedMatch ??
result.matches.sort((a, b) => b.area - a.area || b.bounds.width - a.bounds.width)[0]!;
return {
geometry: best.bounds,
focused: best.isForeground,
};
}
private pollGeometry(): void {
if (this.pollInFlight || !this.helperSpec) {
return;
}
if (this.pollInFlight) return;
this.pollInFlight = true;
void this.runHelperWithSocketFallback()
.then(({ stdout, stderr }) => {
const geometry = parseWindowTrackerHelperOutput(stdout);
const focusState = parseWindowTrackerHelperFocusState(stderr);
this.updateTargetWindowFocused(focusState ?? Boolean(geometry));
this.updateGeometry(geometry);
})
.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.updateGeometry(null);
})
.finally(() => {
this.pollInFlight = false;
});
try {
const result = this.pollMpvWindows();
const best = this.selectBestMatch(result);
if (best) {
this.resetTrackingLossState();
this.targetWindowMinimized = false;
this.updateTargetWindowFocused(best.focused);
this.updateGeometry(best.geometry);
return;
}
if (result.windowState === 'minimized') {
this.targetWindowMinimized = true;
this.updateTargetWindowFocused(false);
this.registerTrackingMiss(this.minimizedTrackingLossGraceMs);
return;
}
this.targetWindowMinimized = false;
this.updateTargetWindowFocused(false);
this.registerTrackingMiss();
} catch (error: unknown) {
const err = error instanceof Error ? error : new Error(String(error));
this.maybeLogPollError(err);
this.targetWindowMinimized = false;
this.updateTargetWindowFocused(false);
this.registerTrackingMiss();
} finally {
this.pollInFlight = false;
}
}
}