mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-04-28 04:19:27 -07:00
Windows update (#49)
This commit is contained in:
@@ -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;
|
||||
@@ -75,11 +79,11 @@ export abstract class BaseWindowTracker {
|
||||
this.updateTargetWindowFocused(focused);
|
||||
}
|
||||
|
||||
protected updateGeometry(newGeometry: WindowGeometry | null): void {
|
||||
protected updateGeometry(newGeometry: WindowGeometry | null, initialFocused = true): void {
|
||||
if (newGeometry) {
|
||||
if (!this.windowFound) {
|
||||
this.windowFound = true;
|
||||
this.updateTargetWindowFocused(true);
|
||||
this.updateTargetWindowFocused(initialFocused);
|
||||
if (this.onWindowFound) this.onWindowFound(newGeometry);
|
||||
}
|
||||
|
||||
|
||||
66
src/window-trackers/mpv-socket-match.test.ts
Normal file
66
src/window-trackers/mpv-socket-match.test.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { filterMpvPollResultBySocketPath, matchesMpvSocketPathInCommandLine } from './mpv-socket-match';
|
||||
import type { MpvPollResult } from './win32';
|
||||
|
||||
function createPollResult(commandLines: Array<string | null>): MpvPollResult {
|
||||
return {
|
||||
matches: commandLines.map((commandLine, index) => ({
|
||||
hwnd: index + 1,
|
||||
bounds: { x: index * 10, y: 0, width: 1280, height: 720 },
|
||||
area: 1280 * 720,
|
||||
isForeground: index === 0,
|
||||
commandLine,
|
||||
})),
|
||||
focusState: true,
|
||||
windowState: 'visible',
|
||||
};
|
||||
}
|
||||
|
||||
test('matchesMpvSocketPathInCommandLine accepts equals and space-delimited socket flags', () => {
|
||||
assert.equal(
|
||||
matchesMpvSocketPathInCommandLine(
|
||||
'mpv.exe --input-ipc-server=\\\\.\\pipe\\subminer-a video.mkv',
|
||||
'\\\\.\\pipe\\subminer-a',
|
||||
),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
matchesMpvSocketPathInCommandLine(
|
||||
'mpv.exe --input-ipc-server "\\\\.\\pipe\\subminer-b" video.mkv',
|
||||
'\\\\.\\pipe\\subminer-b',
|
||||
),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
matchesMpvSocketPathInCommandLine(
|
||||
'mpv.exe --input-ipc-server=\\\\.\\pipe\\subminer-a video.mkv',
|
||||
'\\\\.\\pipe\\subminer-b',
|
||||
),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('filterMpvPollResultBySocketPath keeps only matches for the requested socket path', () => {
|
||||
const result = filterMpvPollResultBySocketPath(
|
||||
createPollResult([
|
||||
'mpv.exe --input-ipc-server=\\\\.\\pipe\\subminer-a video-a.mkv',
|
||||
'mpv.exe --input-ipc-server=\\\\.\\pipe\\subminer-b video-b.mkv',
|
||||
null,
|
||||
]),
|
||||
'\\\\.\\pipe\\subminer-b',
|
||||
);
|
||||
|
||||
assert.deepEqual(result.matches.map((match) => match.hwnd), [2]);
|
||||
assert.equal(result.windowState, 'visible');
|
||||
});
|
||||
|
||||
test('matchesMpvSocketPathInCommandLine rejects socket-path prefix matches', () => {
|
||||
assert.equal(
|
||||
matchesMpvSocketPathInCommandLine(
|
||||
'mpv.exe --input-ipc-server=\\\\.\\pipe\\subminer-10 video.mkv',
|
||||
'\\\\.\\pipe\\subminer-1',
|
||||
),
|
||||
false,
|
||||
);
|
||||
});
|
||||
41
src/window-trackers/mpv-socket-match.ts
Normal file
41
src/window-trackers/mpv-socket-match.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import type { MpvPollResult } from './win32';
|
||||
|
||||
function escapeRegex(text: string): string {
|
||||
return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
export function matchesMpvSocketPathInCommandLine(
|
||||
commandLine: string,
|
||||
targetSocketPath: string,
|
||||
): boolean {
|
||||
if (!commandLine || !targetSocketPath) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const escapedSocketPath = escapeRegex(targetSocketPath);
|
||||
return new RegExp(
|
||||
`(?:^|\\s)--input-ipc-server(?:=|\\s+)(?:"${escapedSocketPath}"|${escapedSocketPath})(?=\\s|$)`,
|
||||
'i',
|
||||
).test(commandLine);
|
||||
}
|
||||
|
||||
export function filterMpvPollResultBySocketPath(
|
||||
result: MpvPollResult,
|
||||
targetSocketPath?: string | null,
|
||||
): MpvPollResult {
|
||||
if (!targetSocketPath) {
|
||||
return result;
|
||||
}
|
||||
|
||||
const matches = result.matches.filter(
|
||||
(match) =>
|
||||
typeof match.commandLine === 'string' &&
|
||||
matchesMpvSocketPathInCommandLine(match.commandLine, targetSocketPath),
|
||||
);
|
||||
|
||||
return {
|
||||
matches,
|
||||
focusState: matches.some((match) => match.isForeground),
|
||||
windowState: matches.length > 0 ? 'visible' : 'not-found',
|
||||
};
|
||||
}
|
||||
377
src/window-trackers/win32.ts
Normal file
377
src/window-trackers/win32.ts
Normal file
@@ -0,0 +1,377 @@
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import koffi from 'koffi';
|
||||
import { matchesMpvSocketPathInCommandLine } from './mpv-socket-match';
|
||||
|
||||
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 GetLastError = kernel32.func('uint __stdcall GetLastError()');
|
||||
const SetLastError = kernel32.func('void __stdcall SetLastError(uint dwErrCode)');
|
||||
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 {
|
||||
const hr = DwmExtendFrameIntoClientArea(overlayHwnd, {
|
||||
cxLeftWidth: -1,
|
||||
cxRightWidth: -1,
|
||||
cyTopHeight: -1,
|
||||
cyBottomHeight: -1,
|
||||
});
|
||||
if (hr !== 0) {
|
||||
throw new Error(`DwmExtendFrameIntoClientArea failed (${hr})`);
|
||||
}
|
||||
}
|
||||
|
||||
function resetLastError(): void {
|
||||
SetLastError(0);
|
||||
}
|
||||
|
||||
function assertSetWindowLongPtrSucceeded(operation: string, result: number): void {
|
||||
if (result !== 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (GetLastError() === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
throw new Error(`${operation} failed (${GetLastError()})`);
|
||||
}
|
||||
|
||||
function assertSetWindowPosSucceeded(operation: string, result: boolean): void {
|
||||
if (result) {
|
||||
return;
|
||||
}
|
||||
|
||||
throw new Error(`${operation} failed (${GetLastError()})`);
|
||||
}
|
||||
|
||||
function assertGetWindowLongSucceeded(operation: string, result: number): number {
|
||||
if (result !== 0 || GetLastError() === 0) {
|
||||
return result;
|
||||
}
|
||||
|
||||
throw new Error(`${operation} failed (${GetLastError()})`);
|
||||
}
|
||||
|
||||
export interface WindowBounds {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface MpvWindowMatch {
|
||||
hwnd: number;
|
||||
bounds: WindowBounds;
|
||||
area: number;
|
||||
isForeground: boolean;
|
||||
commandLine?: string | null;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
const processCommandLineCache = new Map<number, string>();
|
||||
|
||||
function getProcessCommandLineByPid(pid: number): string | null {
|
||||
if (processCommandLineCache.has(pid)) {
|
||||
return processCommandLineCache.get(pid) ?? null;
|
||||
}
|
||||
|
||||
let commandLine: string | null = null;
|
||||
try {
|
||||
const output = execFileSync(
|
||||
'powershell.exe',
|
||||
[
|
||||
'-NoProfile',
|
||||
'-NonInteractive',
|
||||
'-ExecutionPolicy',
|
||||
'Bypass',
|
||||
'-Command',
|
||||
`$process = Get-CimInstance Win32_Process -Filter "ProcessId = ${pid}"; if ($process -and $process.CommandLine) { [Console]::Out.Write($process.CommandLine) }`,
|
||||
],
|
||||
{
|
||||
encoding: 'utf8',
|
||||
windowsHide: true,
|
||||
stdio: ['ignore', 'pipe', 'ignore'],
|
||||
timeout: 1500,
|
||||
},
|
||||
).trim();
|
||||
commandLine = output.length > 0 ? output : null;
|
||||
} catch {
|
||||
commandLine = null;
|
||||
}
|
||||
|
||||
if (commandLine !== null) {
|
||||
processCommandLineCache.set(pid, commandLine);
|
||||
} else {
|
||||
processCommandLineCache.delete(pid);
|
||||
}
|
||||
return commandLine;
|
||||
}
|
||||
|
||||
export function findMpvWindows(targetSocketPath?: string | null): MpvPollResult {
|
||||
const foregroundHwnd = GetForegroundWindow();
|
||||
const matches: MpvWindowMatch[] = [];
|
||||
let hasMinimized = false;
|
||||
let hasFocused = false;
|
||||
const processNameCache = new Map<number, string | null>();
|
||||
const processCommandLineLookupCache = 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;
|
||||
|
||||
let commandLine: string | null = null;
|
||||
if (targetSocketPath) {
|
||||
commandLine = processCommandLineLookupCache.get(pidValue) ?? null;
|
||||
if (!processCommandLineLookupCache.has(pidValue)) {
|
||||
commandLine = getProcessCommandLineByPid(pidValue);
|
||||
processCommandLineLookupCache.set(pidValue, commandLine);
|
||||
}
|
||||
if (!commandLine || !matchesMpvSocketPathInCommandLine(commandLine, targetSocketPath)) {
|
||||
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,
|
||||
commandLine,
|
||||
});
|
||||
|
||||
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 {
|
||||
resetLastError();
|
||||
const result = SetWindowLongPtrW(overlayHwnd, GWLP_HWNDPARENT, mpvHwnd);
|
||||
assertSetWindowLongPtrSucceeded('setOverlayOwner', result);
|
||||
extendOverlayFrameIntoClientArea(overlayHwnd);
|
||||
}
|
||||
|
||||
export function ensureOverlayTransparency(overlayHwnd: number): void {
|
||||
extendOverlayFrameIntoClientArea(overlayHwnd);
|
||||
}
|
||||
|
||||
export function clearOverlayOwner(overlayHwnd: number): void {
|
||||
resetLastError();
|
||||
const result = SetWindowLongPtrW(overlayHwnd, GWLP_HWNDPARENT, 0);
|
||||
assertSetWindowLongPtrSucceeded('clearOverlayOwner', result);
|
||||
}
|
||||
|
||||
export function bindOverlayAboveMpv(overlayHwnd: number, mpvHwnd: number): void {
|
||||
resetLastError();
|
||||
const ownerResult = SetWindowLongPtrW(overlayHwnd, GWLP_HWNDPARENT, mpvHwnd);
|
||||
assertSetWindowLongPtrSucceeded('bindOverlayAboveMpv owner assignment', ownerResult);
|
||||
|
||||
resetLastError();
|
||||
const mpvExStyle = assertGetWindowLongSucceeded(
|
||||
'bindOverlayAboveMpv target window style',
|
||||
GetWindowLongW(mpvHwnd, GWL_EXSTYLE),
|
||||
);
|
||||
const mpvIsTopmost = (mpvExStyle & WS_EX_TOPMOST) !== 0;
|
||||
|
||||
resetLastError();
|
||||
const overlayExStyle = assertGetWindowLongSucceeded(
|
||||
'bindOverlayAboveMpv overlay window style',
|
||||
GetWindowLongW(overlayHwnd, GWL_EXSTYLE),
|
||||
);
|
||||
const overlayIsTopmost = (overlayExStyle & WS_EX_TOPMOST) !== 0;
|
||||
|
||||
if (mpvIsTopmost && !overlayIsTopmost) {
|
||||
resetLastError();
|
||||
const topmostResult = SetWindowPos(overlayHwnd, HWND_TOPMOST, 0, 0, 0, 0, SWP_FLAGS);
|
||||
assertSetWindowPosSucceeded('bindOverlayAboveMpv topmost adjustment', topmostResult);
|
||||
} else if (!mpvIsTopmost && overlayIsTopmost) {
|
||||
resetLastError();
|
||||
const notTopmostResult = SetWindowPos(overlayHwnd, HWND_NOTOPMOST, 0, 0, 0, 0, SWP_FLAGS);
|
||||
assertSetWindowPosSucceeded('bindOverlayAboveMpv notopmost adjustment', notTopmostResult);
|
||||
}
|
||||
|
||||
const windowAboveMpv = GetWindowFn(mpvHwnd, GW_HWNDPREV);
|
||||
if (windowAboveMpv !== 0 && windowAboveMpv === overlayHwnd) return;
|
||||
|
||||
let insertAfter = HWND_TOP;
|
||||
if (windowAboveMpv !== 0) {
|
||||
try {
|
||||
resetLastError();
|
||||
const aboveExStyle = assertGetWindowLongSucceeded(
|
||||
'bindOverlayAboveMpv window above style',
|
||||
GetWindowLongW(windowAboveMpv, GWL_EXSTYLE),
|
||||
);
|
||||
const aboveIsTopmost = (aboveExStyle & WS_EX_TOPMOST) !== 0;
|
||||
if (aboveIsTopmost === mpvIsTopmost) {
|
||||
insertAfter = windowAboveMpv;
|
||||
}
|
||||
} catch {
|
||||
insertAfter = HWND_TOP;
|
||||
}
|
||||
}
|
||||
|
||||
resetLastError();
|
||||
const positionResult = SetWindowPos(overlayHwnd, insertAfter, 0, 0, 0, 0, SWP_FLAGS);
|
||||
assertSetWindowPosSucceeded('bindOverlayAboveMpv z-order adjustment', positionResult);
|
||||
}
|
||||
|
||||
export function lowerOverlay(overlayHwnd: number): void {
|
||||
resetLastError();
|
||||
const notTopmostResult = SetWindowPos(overlayHwnd, HWND_NOTOPMOST, 0, 0, 0, 0, SWP_FLAGS);
|
||||
assertSetWindowPosSucceeded('lowerOverlay notopmost adjustment', notTopmostResult);
|
||||
|
||||
resetLastError();
|
||||
const bottomResult = SetWindowPos(overlayHwnd, HWND_BOTTOM, 0, 0, 0, 0, SWP_FLAGS);
|
||||
assertSetWindowPosSucceeded('lowerOverlay bottom adjustment', bottomResult);
|
||||
}
|
||||
@@ -1,111 +1,60 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
parseWindowTrackerHelperFocusState,
|
||||
parseWindowTrackerHelperOutput,
|
||||
resolveWindowsTrackerHelper,
|
||||
} from './windows-helper';
|
||||
import { findWindowsMpvTargetWindowHandle } from './windows-helper';
|
||||
import type { MpvPollResult } from './win32';
|
||||
|
||||
test('parseWindowTrackerHelperOutput parses helper geometry output', () => {
|
||||
assert.deepEqual(parseWindowTrackerHelperOutput('120,240,1280,720'), {
|
||||
x: 120,
|
||||
y: 240,
|
||||
width: 1280,
|
||||
height: 720,
|
||||
});
|
||||
});
|
||||
|
||||
test('parseWindowTrackerHelperOutput returns null for misses and invalid payloads', () => {
|
||||
assert.equal(parseWindowTrackerHelperOutput('not-found'), null);
|
||||
assert.equal(parseWindowTrackerHelperOutput('1,2,3'), null);
|
||||
assert.equal(parseWindowTrackerHelperOutput('1,2,0,4'), null);
|
||||
});
|
||||
|
||||
test('parseWindowTrackerHelperFocusState parses helper stderr metadata', () => {
|
||||
assert.equal(parseWindowTrackerHelperFocusState('focus=focused'), true);
|
||||
assert.equal(parseWindowTrackerHelperFocusState('focus=not-focused'), false);
|
||||
assert.equal(parseWindowTrackerHelperFocusState('warning\nfocus=focused\nnote'), true);
|
||||
assert.equal(parseWindowTrackerHelperFocusState(''), null);
|
||||
});
|
||||
|
||||
test('resolveWindowsTrackerHelper auto mode prefers native helper when present', () => {
|
||||
const helper = resolveWindowsTrackerHelper({
|
||||
dirname: 'C:\\repo\\dist\\window-trackers',
|
||||
resourcesPath: 'C:\\repo\\resources',
|
||||
existsSync: (candidate) =>
|
||||
candidate === 'C:\\repo\\resources\\scripts\\get-mpv-window-windows.exe',
|
||||
helperModeEnv: 'auto',
|
||||
});
|
||||
|
||||
assert.deepEqual(helper, {
|
||||
kind: 'native',
|
||||
command: 'C:\\repo\\resources\\scripts\\get-mpv-window-windows.exe',
|
||||
args: [],
|
||||
helperPath: 'C:\\repo\\resources\\scripts\\get-mpv-window-windows.exe',
|
||||
});
|
||||
});
|
||||
|
||||
test('resolveWindowsTrackerHelper auto mode falls back to powershell helper', () => {
|
||||
const helper = resolveWindowsTrackerHelper({
|
||||
dirname: 'C:\\repo\\dist\\window-trackers',
|
||||
resourcesPath: 'C:\\repo\\resources',
|
||||
existsSync: (candidate) =>
|
||||
candidate === 'C:\\repo\\resources\\scripts\\get-mpv-window-windows.ps1',
|
||||
helperModeEnv: 'auto',
|
||||
});
|
||||
|
||||
assert.deepEqual(helper, {
|
||||
kind: 'powershell',
|
||||
command: 'powershell.exe',
|
||||
args: [
|
||||
'-NoProfile',
|
||||
'-ExecutionPolicy',
|
||||
'Bypass',
|
||||
'-File',
|
||||
'C:\\repo\\resources\\scripts\\get-mpv-window-windows.ps1',
|
||||
test('findWindowsMpvTargetWindowHandle prefers the focused mpv window', () => {
|
||||
const result: MpvPollResult = {
|
||||
matches: [
|
||||
{
|
||||
hwnd: 111,
|
||||
bounds: { x: 0, y: 0, width: 1280, height: 720 },
|
||||
area: 1280 * 720,
|
||||
isForeground: false,
|
||||
},
|
||||
{
|
||||
hwnd: 222,
|
||||
bounds: { x: 10, y: 10, width: 800, height: 600 },
|
||||
area: 800 * 600,
|
||||
isForeground: true,
|
||||
},
|
||||
],
|
||||
helperPath: 'C:\\repo\\resources\\scripts\\get-mpv-window-windows.ps1',
|
||||
});
|
||||
focusState: true,
|
||||
windowState: 'visible',
|
||||
};
|
||||
|
||||
assert.equal(findWindowsMpvTargetWindowHandle(result), 222);
|
||||
});
|
||||
|
||||
test('resolveWindowsTrackerHelper explicit powershell mode ignores native helper', () => {
|
||||
const helper = resolveWindowsTrackerHelper({
|
||||
dirname: 'C:\\repo\\dist\\window-trackers',
|
||||
resourcesPath: 'C:\\repo\\resources',
|
||||
existsSync: (candidate) =>
|
||||
candidate === 'C:\\repo\\resources\\scripts\\get-mpv-window-windows.exe' ||
|
||||
candidate === 'C:\\repo\\resources\\scripts\\get-mpv-window-windows.ps1',
|
||||
helperModeEnv: 'powershell',
|
||||
});
|
||||
test('findWindowsMpvTargetWindowHandle falls back to the largest visible mpv window', () => {
|
||||
const result: MpvPollResult = {
|
||||
matches: [
|
||||
{
|
||||
hwnd: 111,
|
||||
bounds: { x: 0, y: 0, width: 640, height: 360 },
|
||||
area: 640 * 360,
|
||||
isForeground: false,
|
||||
},
|
||||
{
|
||||
hwnd: 222,
|
||||
bounds: { x: 10, y: 10, width: 1920, height: 1080 },
|
||||
area: 1920 * 1080,
|
||||
isForeground: false,
|
||||
},
|
||||
],
|
||||
focusState: false,
|
||||
windowState: 'visible',
|
||||
};
|
||||
|
||||
assert.equal(helper?.kind, 'powershell');
|
||||
assert.equal(helper?.helperPath, 'C:\\repo\\resources\\scripts\\get-mpv-window-windows.ps1');
|
||||
assert.equal(findWindowsMpvTargetWindowHandle(result), 222);
|
||||
});
|
||||
|
||||
test('resolveWindowsTrackerHelper explicit native mode fails cleanly when helper is missing', () => {
|
||||
const helper = resolveWindowsTrackerHelper({
|
||||
dirname: 'C:\\repo\\dist\\window-trackers',
|
||||
resourcesPath: 'C:\\repo\\resources',
|
||||
existsSync: () => false,
|
||||
helperModeEnv: 'native',
|
||||
});
|
||||
test('findWindowsMpvTargetWindowHandle returns null when no mpv windows are visible', () => {
|
||||
const result: MpvPollResult = {
|
||||
matches: [],
|
||||
focusState: false,
|
||||
windowState: 'not-found',
|
||||
};
|
||||
|
||||
assert.equal(helper, null);
|
||||
});
|
||||
|
||||
test('resolveWindowsTrackerHelper explicit helper path overrides default search', () => {
|
||||
const helper = resolveWindowsTrackerHelper({
|
||||
dirname: 'C:\\repo\\dist\\window-trackers',
|
||||
resourcesPath: 'C:\\repo\\resources',
|
||||
existsSync: (candidate) => candidate === 'D:\\custom\\tracker.ps1',
|
||||
helperModeEnv: 'auto',
|
||||
helperPathEnv: 'D:\\custom\\tracker.ps1',
|
||||
});
|
||||
|
||||
assert.deepEqual(helper, {
|
||||
kind: 'powershell',
|
||||
command: 'powershell.exe',
|
||||
args: ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', 'D:\\custom\\tracker.ps1'],
|
||||
helperPath: 'D:\\custom\\tracker.ps1',
|
||||
});
|
||||
assert.equal(findWindowsMpvTargetWindowHandle(result), null);
|
||||
});
|
||||
|
||||
@@ -16,269 +16,62 @@
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import type { WindowGeometry } from '../types';
|
||||
import { createLogger } from '../logger';
|
||||
import type { MpvPollResult } from './win32';
|
||||
|
||||
const log = createLogger('tracker').child('windows-helper');
|
||||
|
||||
export type WindowsTrackerHelperKind = 'powershell' | 'native';
|
||||
export type WindowsTrackerHelperMode = 'auto' | 'powershell' | 'native';
|
||||
|
||||
export type WindowsTrackerHelperLaunchSpec = {
|
||||
kind: WindowsTrackerHelperKind;
|
||||
command: string;
|
||||
args: string[];
|
||||
helperPath: string;
|
||||
};
|
||||
|
||||
type ResolveWindowsTrackerHelperOptions = {
|
||||
dirname?: string;
|
||||
resourcesPath?: string;
|
||||
helperModeEnv?: string | undefined;
|
||||
helperPathEnv?: string | undefined;
|
||||
existsSync?: (candidate: string) => boolean;
|
||||
mkdirSync?: (candidate: string, options: { recursive: true }) => void;
|
||||
copyFileSync?: (source: string, destination: string) => void;
|
||||
};
|
||||
|
||||
const windowsPath = path.win32;
|
||||
|
||||
function normalizeHelperMode(value: string | undefined): WindowsTrackerHelperMode {
|
||||
const normalized = value?.trim().toLowerCase();
|
||||
if (normalized === 'powershell' || normalized === 'native') {
|
||||
return normalized;
|
||||
}
|
||||
return 'auto';
|
||||
function loadWin32(): typeof import('./win32') {
|
||||
return require('./win32') as typeof import('./win32');
|
||||
}
|
||||
|
||||
function inferHelperKindFromPath(helperPath: string): WindowsTrackerHelperKind | null {
|
||||
const normalized = helperPath.trim().toLowerCase();
|
||||
if (normalized.endsWith('.exe')) return 'native';
|
||||
if (normalized.endsWith('.ps1')) return 'powershell';
|
||||
return null;
|
||||
export function findWindowsMpvTargetWindowHandle(result?: MpvPollResult): number | null {
|
||||
const poll = result ?? loadWin32().findMpvWindows();
|
||||
const focused = poll.matches.find((match) => match.isForeground);
|
||||
const best =
|
||||
focused ?? [...poll.matches].sort((a, b) => b.area - a.area || b.bounds.width - a.bounds.width)[0];
|
||||
return best?.hwnd ?? null;
|
||||
}
|
||||
|
||||
function materializeAsarHelper(
|
||||
sourcePath: string,
|
||||
kind: WindowsTrackerHelperKind,
|
||||
deps: Required<Pick<ResolveWindowsTrackerHelperOptions, 'mkdirSync' | 'copyFileSync'>>,
|
||||
): string | null {
|
||||
if (!sourcePath.includes('.asar')) {
|
||||
return sourcePath;
|
||||
}
|
||||
|
||||
const fileName = kind === 'native' ? 'get-mpv-window-windows.exe' : 'get-mpv-window-windows.ps1';
|
||||
const targetDir = windowsPath.join(os.tmpdir(), 'subminer', 'helpers');
|
||||
const targetPath = windowsPath.join(targetDir, fileName);
|
||||
|
||||
export function setWindowsOverlayOwner(overlayHwnd: number, mpvHwnd: number): boolean {
|
||||
try {
|
||||
deps.mkdirSync(targetDir, { recursive: true });
|
||||
deps.copyFileSync(sourcePath, targetPath);
|
||||
log.info(`Materialized Windows helper from asar: ${targetPath}`);
|
||||
return targetPath;
|
||||
} catch (error) {
|
||||
log.warn(`Failed to materialize Windows helper from asar: ${sourcePath}`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function createLaunchSpec(
|
||||
helperPath: string,
|
||||
kind: WindowsTrackerHelperKind,
|
||||
): WindowsTrackerHelperLaunchSpec {
|
||||
if (kind === 'native') {
|
||||
return {
|
||||
kind,
|
||||
command: helperPath,
|
||||
args: [],
|
||||
helperPath,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
kind,
|
||||
command: 'powershell.exe',
|
||||
args: ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', helperPath],
|
||||
helperPath,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeHelperPathOverride(
|
||||
helperPathEnv: string | undefined,
|
||||
mode: WindowsTrackerHelperMode,
|
||||
): { path: string; kind: WindowsTrackerHelperKind } | null {
|
||||
const helperPath = helperPathEnv?.trim();
|
||||
if (!helperPath) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const inferredKind = inferHelperKindFromPath(helperPath);
|
||||
const kind = mode === 'auto' ? inferredKind : mode;
|
||||
if (!kind) {
|
||||
log.warn(
|
||||
`Ignoring SUBMINER_WINDOWS_TRACKER_HELPER_PATH with unsupported extension: ${helperPath}`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
return { path: helperPath, kind };
|
||||
}
|
||||
|
||||
function getHelperCandidates(
|
||||
dirname: string,
|
||||
resourcesPath: string | undefined,
|
||||
): Array<{
|
||||
path: string;
|
||||
kind: WindowsTrackerHelperKind;
|
||||
}> {
|
||||
const scriptFileBase = 'get-mpv-window-windows';
|
||||
const candidates: Array<{ path: string; kind: WindowsTrackerHelperKind }> = [];
|
||||
|
||||
if (resourcesPath) {
|
||||
candidates.push({
|
||||
path: windowsPath.join(resourcesPath, 'scripts', `${scriptFileBase}.exe`),
|
||||
kind: 'native',
|
||||
});
|
||||
candidates.push({
|
||||
path: windowsPath.join(resourcesPath, 'scripts', `${scriptFileBase}.ps1`),
|
||||
kind: 'powershell',
|
||||
});
|
||||
}
|
||||
|
||||
candidates.push({
|
||||
path: windowsPath.join(dirname, '..', 'scripts', `${scriptFileBase}.exe`),
|
||||
kind: 'native',
|
||||
});
|
||||
candidates.push({
|
||||
path: windowsPath.join(dirname, '..', 'scripts', `${scriptFileBase}.ps1`),
|
||||
kind: 'powershell',
|
||||
});
|
||||
candidates.push({
|
||||
path: windowsPath.join(dirname, '..', '..', 'scripts', `${scriptFileBase}.exe`),
|
||||
kind: 'native',
|
||||
});
|
||||
candidates.push({
|
||||
path: windowsPath.join(dirname, '..', '..', 'scripts', `${scriptFileBase}.ps1`),
|
||||
kind: 'powershell',
|
||||
});
|
||||
|
||||
return candidates;
|
||||
}
|
||||
|
||||
export function parseWindowTrackerHelperOutput(output: string): WindowGeometry | null {
|
||||
const result = output.trim();
|
||||
if (!result || result === 'not-found') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parts = result.split(',');
|
||||
if (parts.length !== 4) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [xText, yText, widthText, heightText] = parts;
|
||||
const x = Number.parseInt(xText!, 10);
|
||||
const y = Number.parseInt(yText!, 10);
|
||||
const width = Number.parseInt(widthText!, 10);
|
||||
const height = Number.parseInt(heightText!, 10);
|
||||
if (
|
||||
!Number.isFinite(x) ||
|
||||
!Number.isFinite(y) ||
|
||||
!Number.isFinite(width) ||
|
||||
!Number.isFinite(height) ||
|
||||
width <= 0 ||
|
||||
height <= 0
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { x, y, width, height };
|
||||
}
|
||||
|
||||
export function parseWindowTrackerHelperFocusState(output: string): boolean | null {
|
||||
const focusLine = output
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.find((line) => line.startsWith('focus='));
|
||||
|
||||
if (!focusLine) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const value = focusLine.slice('focus='.length).trim().toLowerCase();
|
||||
if (value === 'focused') {
|
||||
loadWin32().setOverlayOwner(overlayHwnd, mpvHwnd);
|
||||
return true;
|
||||
}
|
||||
if (value === 'not-focused') {
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function resolveWindowsTrackerHelper(
|
||||
options: ResolveWindowsTrackerHelperOptions = {},
|
||||
): WindowsTrackerHelperLaunchSpec | null {
|
||||
const existsSync = options.existsSync ?? fs.existsSync;
|
||||
const mkdirSync = options.mkdirSync ?? fs.mkdirSync;
|
||||
const copyFileSync = options.copyFileSync ?? fs.copyFileSync;
|
||||
const dirname = options.dirname ?? __dirname;
|
||||
const resourcesPath = options.resourcesPath ?? process.resourcesPath;
|
||||
const mode = normalizeHelperMode(
|
||||
options.helperModeEnv ?? process.env.SUBMINER_WINDOWS_TRACKER_HELPER,
|
||||
);
|
||||
const override = normalizeHelperPathOverride(
|
||||
options.helperPathEnv ?? process.env.SUBMINER_WINDOWS_TRACKER_HELPER_PATH,
|
||||
mode,
|
||||
);
|
||||
|
||||
if (override) {
|
||||
if (!existsSync(override.path)) {
|
||||
log.warn(`Configured Windows tracker helper path does not exist: ${override.path}`);
|
||||
return null;
|
||||
}
|
||||
const helperPath = materializeAsarHelper(override.path, override.kind, {
|
||||
mkdirSync,
|
||||
copyFileSync,
|
||||
});
|
||||
return helperPath ? createLaunchSpec(helperPath, override.kind) : null;
|
||||
export function ensureWindowsOverlayTransparency(overlayHwnd: number): boolean {
|
||||
try {
|
||||
loadWin32().ensureOverlayTransparency(overlayHwnd);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function bindWindowsOverlayAboveMpv(overlayHwnd: number, mpvHwnd: number): boolean {
|
||||
try {
|
||||
const win32 = loadWin32();
|
||||
win32.bindOverlayAboveMpv(overlayHwnd, mpvHwnd);
|
||||
win32.ensureOverlayTransparency(overlayHwnd);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function clearWindowsOverlayOwner(overlayHwnd: number): boolean {
|
||||
try {
|
||||
loadWin32().clearOverlayOwner(overlayHwnd);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function getWindowsForegroundProcessName(): string | null {
|
||||
try {
|
||||
return loadWin32().getForegroundProcessName();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
const candidates = getHelperCandidates(dirname, resourcesPath);
|
||||
const orderedCandidates =
|
||||
mode === 'powershell'
|
||||
? candidates.filter((candidate) => candidate.kind === 'powershell')
|
||||
: mode === 'native'
|
||||
? candidates.filter((candidate) => candidate.kind === 'native')
|
||||
: candidates;
|
||||
|
||||
for (const candidate of orderedCandidates) {
|
||||
if (!existsSync(candidate.path)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const helperPath = materializeAsarHelper(candidate.path, candidate.kind, {
|
||||
mkdirSync,
|
||||
copyFileSync,
|
||||
});
|
||||
if (!helperPath) {
|
||||
continue;
|
||||
}
|
||||
|
||||
log.info(`Using Windows helper (${candidate.kind}): ${helperPath}`);
|
||||
return createLaunchSpec(helperPath, candidate.kind);
|
||||
}
|
||||
|
||||
if (mode === 'native') {
|
||||
log.warn('Windows native tracker helper requested but no helper was found.');
|
||||
} else if (mode === 'powershell') {
|
||||
log.warn('Windows PowerShell tracker helper requested but no helper was found.');
|
||||
} else {
|
||||
log.warn('Windows tracker helper not found.');
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,56 +1,65 @@
|
||||
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 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',
|
||||
};
|
||||
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;
|
||||
let tracker: WindowsWindowTracker;
|
||||
tracker = new WindowsWindowTracker(undefined, {
|
||||
pollMpvWindows: () => {
|
||||
pollCalls += 1;
|
||||
if (pollCalls === 1) {
|
||||
(tracker as unknown as { pollGeometry: () => void }).pollGeometry();
|
||||
}
|
||||
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, 1);
|
||||
});
|
||||
|
||||
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 +70,196 @@ test('WindowsWindowTracker updates geometry from helper output', async () => {
|
||||
assert.equal(tracker.isTargetWindowFocused(), true);
|
||||
});
|
||||
|
||||
test('WindowsWindowTracker clears geometry for helper misses', async () => {
|
||||
test('WindowsWindowTracker preserves an unfocused initial match', () => {
|
||||
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: () => mpvVisible({ x: 10, y: 20, width: 1280, height: 720, focused: false }),
|
||||
});
|
||||
|
||||
(tracker as unknown as { pollGeometry: () => void }).pollGeometry();
|
||||
|
||||
assert.deepEqual(tracker.getGeometry(), {
|
||||
x: 10,
|
||||
y: 20,
|
||||
width: 1280,
|
||||
height: 720,
|
||||
});
|
||||
assert.equal(tracker.isTargetWindowFocused(), false);
|
||||
});
|
||||
|
||||
test('WindowsWindowTracker clears geometry for poll misses', () => {
|
||||
const tracker = new WindowsWindowTracker(undefined, {
|
||||
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 });
|
||||
});
|
||||
|
||||
@@ -16,80 +16,53 @@
|
||||
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(_targetMpvSocketPath?: string | null): MpvPollResult {
|
||||
const win32 = require('./win32') as typeof import('./win32');
|
||||
return win32.findMpvWindows(_targetMpvSocketPath);
|
||||
}
|
||||
|
||||
export class WindowsWindowTracker extends BaseWindowTracker {
|
||||
private pollInterval: ReturnType<typeof setInterval> | null = null;
|
||||
private pollInFlight = false;
|
||||
private helperSpec: WindowsTrackerHelperLaunchSpec | null;
|
||||
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;
|
||||
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 currentTargetWindowHwnd: number | null = null;
|
||||
|
||||
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.targetMpvSocketPath = _targetMpvSocketPath?.trim() || null;
|
||||
this.pollMpvWindows = deps.pollMpvWindows ?? (() => defaultPollMpvWindows(this.targetMpvSocketPath));
|
||||
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 +77,108 @@ 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: '' };
|
||||
}
|
||||
getTargetWindowHandle(): number | null {
|
||||
return this.currentTargetWindowHwnd;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
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;
|
||||
|
||||
return await this.runHelper(this.helperSpec, 'geometry', null);
|
||||
this.lastPollErrorFingerprint = fingerprint;
|
||||
this.lastPollErrorLoggedAtMs = now;
|
||||
log.warn('Windows native poll failed', { error: error.message });
|
||||
}
|
||||
|
||||
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; hwnd: number } | 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,
|
||||
hwnd: best.hwnd,
|
||||
};
|
||||
}
|
||||
|
||||
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.currentTargetWindowHwnd = best.hwnd;
|
||||
this.updateGeometry(best.geometry, best.focused);
|
||||
this.updateTargetWindowFocused(best.focused);
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.windowState === 'minimized') {
|
||||
this.targetWindowMinimized = true;
|
||||
this.currentTargetWindowHwnd = null;
|
||||
this.updateTargetWindowFocused(false);
|
||||
this.registerTrackingMiss(this.minimizedTrackingLossGraceMs);
|
||||
return;
|
||||
}
|
||||
|
||||
this.targetWindowMinimized = false;
|
||||
this.currentTargetWindowHwnd = null;
|
||||
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.currentTargetWindowHwnd = null;
|
||||
this.updateTargetWindowFocused(false);
|
||||
this.registerTrackingMiss();
|
||||
} finally {
|
||||
this.pollInFlight = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user