Fix Windows CodeRabbit review follow-ups

This commit is contained in:
2026-04-10 02:29:28 -07:00
parent 3e7573c9fc
commit 0cdd79da9a
11 changed files with 482 additions and 51 deletions

View File

@@ -0,0 +1,4 @@
type: fixed
area: overlay
- Addressed the latest CodeRabbit follow-ups on the Windows overlay flow, including exact mpv target resolution, lower-overlay helper arguments, Win32 failure detection, and overlay cleanup on tracker loss.

View File

@@ -1,5 +1,5 @@
param(
[ValidateSet('geometry', 'foreground-process', 'bind-overlay', 'lower-overlay', 'set-owner', 'clear-owner')]
[ValidateSet('geometry', 'foreground-process', 'bind-overlay', 'lower-overlay', 'set-owner', 'clear-owner', 'target-hwnd')]
[string]$Mode = 'geometry',
[string]$SocketPath,
[string]$OverlayWindowHandle
@@ -64,6 +64,9 @@ public static class SubMinerWindowsHelper {
[DllImport("user32.dll", SetLastError = true)]
public static extern IntPtr SetWindowLongPtr(IntPtr hWnd, int nIndex, IntPtr dwNewLong);
[DllImport("kernel32.dll")]
public static extern void SetLastError(uint dwErrCode);
[DllImport("dwmapi.dll")]
public static extern int DwmGetWindowAttribute(IntPtr hwnd, int dwAttribute, out RECT pvAttribute, int cbAttribute);
}
@@ -83,6 +86,38 @@ public static class SubMinerWindowsHelper {
$HWND_TOPMOST = [IntPtr](-1)
$HWND_NOTOPMOST = [IntPtr](-2)
function Assert-SetWindowLongPtrSucceeded {
param(
[IntPtr]$Result,
[string]$Operation
)
if ($Result -ne [IntPtr]::Zero) {
return
}
if ([Runtime.InteropServices.Marshal]::GetLastWin32Error() -eq 0) {
return
}
$lastError = [Runtime.InteropServices.Marshal]::GetLastWin32Error()
throw "$Operation failed ($lastError)"
}
function Assert-SetWindowPosSucceeded {
param(
[bool]$Result,
[string]$Operation
)
if ($Result) {
return
}
$lastError = [Runtime.InteropServices.Marshal]::GetLastWin32Error()
throw "$Operation failed ($lastError)"
}
if ($Mode -eq 'foreground-process') {
$foregroundWindow = [SubMinerWindowsHelper]::GetForegroundWindow()
if ($foregroundWindow -eq [IntPtr]::Zero) {
@@ -115,7 +150,9 @@ public static class SubMinerWindowsHelper {
}
[IntPtr]$overlayWindow = [IntPtr]([int64]$OverlayWindowHandle)
[void][SubMinerWindowsHelper]::SetWindowLongPtr($overlayWindow, $GWLP_HWNDPARENT, [IntPtr]::Zero)
[SubMinerWindowsHelper]::SetLastError(0)
$result = [SubMinerWindowsHelper]::SetWindowLongPtr($overlayWindow, $GWLP_HWNDPARENT, [IntPtr]::Zero)
Assert-SetWindowLongPtrSucceeded -Result $result -Operation 'clear-owner'
Write-Output 'ok'
exit 0
}
@@ -281,6 +318,11 @@ public static class SubMinerWindowsHelper {
$mpvMatches | Sort-Object -Property Area, Width, Height -Descending | Select-Object -First 1
}
if ($Mode -eq 'target-hwnd') {
Write-Output "$($bestMatch.HWnd)"
exit 0
}
if ($Mode -eq 'set-owner') {
if ([string]::IsNullOrWhiteSpace($OverlayWindowHandle)) {
[Console]::Error.WriteLine('overlay-window-handle-required')
@@ -289,7 +331,9 @@ public static class SubMinerWindowsHelper {
[IntPtr]$overlayWindow = [IntPtr]([int64]$OverlayWindowHandle)
$targetWindow = [IntPtr]$bestMatch.HWnd
[void][SubMinerWindowsHelper]::SetWindowLongPtr($overlayWindow, $GWLP_HWNDPARENT, $targetWindow)
[SubMinerWindowsHelper]::SetLastError(0)
$result = [SubMinerWindowsHelper]::SetWindowLongPtr($overlayWindow, $GWLP_HWNDPARENT, $targetWindow)
Assert-SetWindowLongPtrSucceeded -Result $result -Operation 'set-owner'
Write-Output 'ok'
exit 0
}
@@ -302,20 +346,26 @@ public static class SubMinerWindowsHelper {
[IntPtr]$overlayWindow = [IntPtr]([int64]$OverlayWindowHandle)
$targetWindow = [IntPtr]$bestMatch.HWnd
[void][SubMinerWindowsHelper]::SetWindowLongPtr($overlayWindow, $GWLP_HWNDPARENT, $targetWindow)
[SubMinerWindowsHelper]::SetLastError(0)
$result = [SubMinerWindowsHelper]::SetWindowLongPtr($overlayWindow, $GWLP_HWNDPARENT, $targetWindow)
Assert-SetWindowLongPtrSucceeded -Result $result -Operation 'bind-overlay owner assignment'
$targetWindowExStyle = [SubMinerWindowsHelper]::GetWindowLong($targetWindow, $GWL_EXSTYLE)
$targetWindowIsTopmost = ($targetWindowExStyle -band $WS_EX_TOPMOST) -ne 0
$overlayExStyle = [SubMinerWindowsHelper]::GetWindowLong($overlayWindow, $GWL_EXSTYLE)
$overlayIsTopmost = ($overlayExStyle -band $WS_EX_TOPMOST) -ne 0
if ($targetWindowIsTopmost -and -not $overlayIsTopmost) {
[void][SubMinerWindowsHelper]::SetWindowPos(
[SubMinerWindowsHelper]::SetLastError(0)
$result = [SubMinerWindowsHelper]::SetWindowPos(
$overlayWindow, $HWND_TOPMOST, 0, 0, 0, 0, $SWP_FLAGS
)
Assert-SetWindowPosSucceeded -Result $result -Operation 'bind-overlay topmost adjustment'
} elseif (-not $targetWindowIsTopmost -and $overlayIsTopmost) {
[void][SubMinerWindowsHelper]::SetWindowPos(
[SubMinerWindowsHelper]::SetLastError(0)
$result = [SubMinerWindowsHelper]::SetWindowPos(
$overlayWindow, $HWND_NOTOPMOST, 0, 0, 0, 0, $SWP_FLAGS
)
Assert-SetWindowPosSucceeded -Result $result -Operation 'bind-overlay notopmost adjustment'
}
$GW_HWNDPREV = 3
@@ -335,9 +385,11 @@ public static class SubMinerWindowsHelper {
}
}
[void][SubMinerWindowsHelper]::SetWindowPos(
[SubMinerWindowsHelper]::SetLastError(0)
$result = [SubMinerWindowsHelper]::SetWindowPos(
$overlayWindow, $insertAfter, 0, 0, 0, 0, $SWP_FLAGS
)
Assert-SetWindowPosSucceeded -Result $result -Operation 'bind-overlay z-order adjustment'
Write-Output 'ok'
exit 0
}

View File

@@ -547,7 +547,7 @@ test('initializeOverlayRuntime hides overlay windows when tracker loses the targ
assert.deepEqual(calls, ['hide-visible', 'hide-modal', 'sync-shortcuts']);
});
test('initializeOverlayRuntime preserves visible overlay on Windows tracker loss when target is not minimized', () => {
test('initializeOverlayRuntime hides visible overlay on Windows tracker loss when target is not minimized', () => {
const calls: string[] = [];
const tracker = {
onGeometryChange: null as ((...args: unknown[]) => void) | null,
@@ -600,7 +600,7 @@ test('initializeOverlayRuntime preserves visible overlay on Windows tracker loss
calls.length = 0;
tracker.onWindowLost?.();
assert.deepEqual(calls, ['sync-shortcuts']);
assert.deepEqual(calls, ['hide-visible', 'sync-shortcuts']);
});
test('initializeOverlayRuntime restores overlay bounds and visibility when tracker finds the target window again', () => {

View File

@@ -105,15 +105,6 @@ export function initializeOverlayRuntime(options: {
};
windowTracker.onWindowLost = () => {
options.releaseOverlayOwner?.();
if (
process.platform === 'win32' &&
typeof windowTracker.isTargetWindowMinimized === 'function' &&
!windowTracker.isTargetWindowMinimized()
) {
options.syncOverlayShortcuts();
return;
}
for (const window of options.getOverlayWindows()) {
window.hide();
}

View File

@@ -14,7 +14,7 @@ test('overlay window config explicitly disables renderer sandbox for preload com
});
test('Windows visible overlay window config does not start as always-on-top', () => {
const originalPlatform = process.platform;
const originalPlatformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform');
Object.defineProperty(process, 'platform', {
configurable: true,
@@ -29,10 +29,9 @@ test('Windows visible overlay window config does not start as always-on-top', ()
assert.equal(options.alwaysOnTop, false);
} finally {
Object.defineProperty(process, 'platform', {
configurable: true,
value: originalPlatform,
});
if (originalPlatformDescriptor) {
Object.defineProperty(process, 'platform', originalPlatformDescriptor);
}
}
});

View File

@@ -109,11 +109,13 @@ import * as os from 'os';
import * as path from 'path';
import { MecabTokenizer } from './mecab-tokenizer';
import type {
CompiledSessionBinding,
JimakuApiResponse,
KikuFieldGroupingChoice,
MpvSubtitleRenderMetrics,
ResolvedConfig,
RuntimeOptionState,
SessionActionDispatchRequest,
SecondarySubMode,
SubtitleData,
SubtitlePosition,
@@ -136,6 +138,7 @@ import {
ensureWindowsOverlayTransparencyNative,
getWindowsForegroundProcessNameNative,
queryWindowsForegroundProcessName,
queryWindowsTargetWindowHandle,
setWindowsOverlayOwnerNative,
syncWindowsOverlayToMpvZOrder,
} from './window-trackers/windows-helper';
@@ -413,6 +416,8 @@ import { createAnilistRateLimiter } from './core/services/anilist/rate-limiter';
import { createJellyfinTokenStore } from './core/services/jellyfin-token-store';
import { applyRuntimeOptionResultRuntime } from './core/services/runtime-options-ipc';
import { createAnilistTokenStore } from './core/services/anilist/anilist-token-store';
import { buildPluginSessionBindingsArtifact, compileSessionBindings } from './core/services/session-bindings';
import { dispatchSessionAction as dispatchSessionActionCore } from './core/services/session-actions';
import { createBuildOverlayShortcutsRuntimeMainDepsHandler } from './main/runtime/domains/shortcuts';
import { createMainRuntimeRegistry } from './main/runtime/registry';
import {
@@ -449,6 +454,7 @@ import { createOverlayModalRuntimeService } from './main/overlay-runtime';
import { createOverlayModalInputState } from './main/runtime/overlay-modal-input-state';
import { openYoutubeTrackPicker } from './main/runtime/youtube-picker-open';
import { createPlaylistBrowserIpcRuntime } from './main/runtime/playlist-browser-ipc';
import { writeSessionBindingsArtifact } from './main/runtime/session-bindings-artifact';
import { createOverlayShortcutsRuntimeService } from './main/overlay-shortcuts-runtime';
import {
createFrequencyDictionaryRuntimeService,
@@ -1535,6 +1541,9 @@ const buildConfigHotReloadAppliedMainDepsHandler = createBuildConfigHotReloadApp
setKeybindings: (keybindings) => {
appState.keybindings = keybindings;
},
setSessionBindings: (sessionBindings) => {
persistSessionBindings(sessionBindings);
},
refreshGlobalAndOverlayShortcuts: () => {
refreshGlobalAndOverlayShortcuts();
},
@@ -1927,6 +1936,27 @@ function getWindowsNativeWindowHandleNumber(window: BrowserWindow): number {
: handle.readUInt32LE(0);
}
function resolveWindowsOverlayBindTargetHandle(targetMpvSocketPath?: string | null): number | null {
if (process.platform !== 'win32') {
return null;
}
if (targetMpvSocketPath) {
return queryWindowsTargetWindowHandle({
targetMpvSocketPath,
});
}
try {
const win32 = require('./window-trackers/win32') as typeof import('./window-trackers/win32');
const poll = win32.findMpvWindows();
const focused = poll.matches.find((m) => m.isForeground);
return focused?.hwnd ?? poll.matches.sort((a, b) => b.area - a.area)[0]?.hwnd ?? null;
} catch {
return null;
}
}
async function syncWindowsVisibleOverlayToMpvZOrder(): Promise<boolean> {
if (process.platform !== 'win32') {
return false;
@@ -1959,7 +1989,8 @@ async function syncWindowsVisibleOverlayToMpvZOrder(): Promise<boolean> {
}
const overlayHwnd = getWindowsNativeWindowHandleNumber(mainWindow);
if (bindWindowsOverlayAboveMpvNative(overlayHwnd)) {
const targetWindowHwnd = resolveWindowsOverlayBindTargetHandle(appState.mpvSocketPath);
if (targetWindowHwnd !== null && bindWindowsOverlayAboveMpvNative(overlayHwnd, targetWindowHwnd)) {
(mainWindow as BrowserWindow & { setOpacity?: (opacity: number) => void }).setOpacity?.(1);
return true;
}
@@ -3378,6 +3409,7 @@ const { appReadyRuntimeRunner } = composeAppReadyRuntime({
loadSubtitlePosition: () => loadSubtitlePosition(),
resolveKeybindings: () => {
appState.keybindings = resolveKeybindings(getResolvedConfig(), DEFAULT_KEYBINDINGS);
refreshCurrentSessionBindings();
},
createMpvClient: () => {
appState.mpvClient = createMpvClientRuntimeService();
@@ -3520,6 +3552,9 @@ function ensureOverlayStartupPrereqs(): void {
}
if (appState.keybindings.length === 0) {
appState.keybindings = resolveKeybindings(getResolvedConfig(), DEFAULT_KEYBINDINGS);
refreshCurrentSessionBindings();
} else if (appState.sessionBindings.length === 0) {
refreshCurrentSessionBindings();
}
if (!appState.mpvClient) {
appState.mpvClient = createMpvClientRuntimeService();
@@ -4118,6 +4153,53 @@ const {
},
});
function resolveSessionBindingPlatform(): 'darwin' | 'win32' | 'linux' {
if (process.platform === 'darwin') return 'darwin';
if (process.platform === 'win32') return 'win32';
return 'linux';
}
function compileCurrentSessionBindings(): {
bindings: CompiledSessionBinding[];
warnings: ReturnType<typeof compileSessionBindings>['warnings'];
} {
return compileSessionBindings({
keybindings: appState.keybindings,
shortcuts: getConfiguredShortcuts(),
platform: resolveSessionBindingPlatform(),
rawConfig: getResolvedConfig(),
});
}
function persistSessionBindings(
bindings: CompiledSessionBinding[],
warnings: ReturnType<typeof compileSessionBindings>['warnings'] = [],
): void {
appState.sessionBindings = bindings;
writeSessionBindingsArtifact(
CONFIG_DIR,
buildPluginSessionBindingsArtifact({
bindings,
warnings,
numericSelectionTimeoutMs: getConfiguredShortcuts().multiCopyTimeoutMs,
}),
);
if (appState.mpvClient?.connected) {
sendMpvCommandRuntime(appState.mpvClient, [
'script-message',
'subminer-reload-session-bindings',
]);
}
}
function refreshCurrentSessionBindings(): void {
const compiled = compileCurrentSessionBindings();
for (const warning of compiled.warnings) {
logger.warn(`[session-bindings] ${warning.message}`);
}
persistSessionBindings(compiled.bindings, compiled.warnings);
}
const { flushMpvLog, showMpvOsd } = createMpvOsdRuntimeHandlers({
appendToMpvLogMainDeps: {
logPath: DEFAULT_MPV_LOG_PATH,
@@ -4429,6 +4511,39 @@ const shiftSubtitleDelayToAdjacentCueHandler = createShiftSubtitleDelayToAdjacen
showMpvOsd: (text) => showMpvOsd(text),
});
async function dispatchSessionAction(request: SessionActionDispatchRequest): Promise<void> {
await dispatchSessionActionCore(request, {
toggleVisibleOverlay: () => toggleVisibleOverlay(),
copyCurrentSubtitle: () => copyCurrentSubtitle(),
copySubtitleCount: (count) => handleMultiCopyDigit(count),
updateLastCardFromClipboard: () => updateLastCardFromClipboard(),
triggerFieldGrouping: () => triggerFieldGrouping(),
triggerSubsyncFromConfig: () => triggerSubsyncFromConfig(),
mineSentenceCard: () => mineSentenceCard(),
mineSentenceCount: (count) => handleMineSentenceDigit(count),
toggleSecondarySub: () => handleCycleSecondarySubMode(),
markLastCardAsAudioCard: () => markLastCardAsAudioCard(),
openRuntimeOptionsPalette: () => openRuntimeOptionsPalette(),
openJimaku: () => overlayModalRuntime.openJimaku(),
openYoutubeTrackPicker: () => openYoutubeTrackPickerFromPlayback(),
openPlaylistBrowser: () => openPlaylistBrowser(),
replayCurrentSubtitle: () => replayCurrentSubtitleRuntime(appState.mpvClient),
playNextSubtitle: () => playNextSubtitleRuntime(appState.mpvClient),
shiftSubDelayToAdjacentSubtitle: (direction) =>
shiftSubtitleDelayToAdjacentCueHandler(direction),
cycleRuntimeOption: (id, direction) => {
if (!appState.runtimeOptionsManager) {
return { ok: false, error: 'Runtime options manager unavailable' };
}
return applyRuntimeOptionResultRuntime(
appState.runtimeOptionsManager.cycleOption(id, direction),
(text) => showMpvOsd(text),
);
},
showMpvOsd: (text) => showMpvOsd(text),
});
}
const { playlistBrowserMainDeps } = createPlaylistBrowserIpcRuntime(() => appState.mpvClient, {
getPrimarySubtitleLanguages: () => getResolvedConfig().youtube.primarySubLanguages,
getSecondarySubtitleLanguages: () => getResolvedConfig().secondarySub.secondarySubLanguages,
@@ -4586,7 +4701,9 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
saveSubtitlePosition: (position) => saveSubtitlePosition(position),
getMecabTokenizer: () => appState.mecabTokenizer,
getKeybindings: () => appState.keybindings,
getSessionBindings: () => appState.sessionBindings,
getConfiguredShortcuts: () => getConfiguredShortcuts(),
dispatchSessionAction: (request) => dispatchSessionAction(request),
getStatsToggleKey: () => getResolvedConfig().stats.toggleKey,
getMarkWatchedKey: () => getResolvedConfig().stats.markWatchedKey,
getControllerConfig: () => getResolvedConfig().controller,
@@ -4707,6 +4824,7 @@ const { handleCliCommand, handleInitialArgs } = composeCliStartupHandlers({
printHelp: () => printHelp(DEFAULT_TEXTHOOKER_PORT),
stopApp: () => requestAppQuit(),
hasMainWindow: () => Boolean(overlayManager.getMainWindow()),
dispatchSessionAction: (request: SessionActionDispatchRequest) => dispatchSessionAction(request),
getMultiCopyTimeoutMs: () => getConfiguredShortcuts().multiCopyTimeoutMs,
schedule: (fn: () => void, delayMs: number) => setTimeout(fn, delayMs),
logInfo: (message: string) => logger.info(message),
@@ -4973,7 +5091,17 @@ const { initializeOverlayRuntime: initializeOverlayRuntimeHandler } =
const mainWindow = overlayManager.getMainWindow();
if (process.platform !== 'win32' || !mainWindow || mainWindow.isDestroyed()) return;
const overlayHwnd = getWindowsNativeWindowHandleNumber(mainWindow);
if (bindWindowsOverlayAboveMpvNative(overlayHwnd)) {
const targetWindowHwnd = resolveWindowsOverlayBindTargetHandle(appState.mpvSocketPath);
if (targetWindowHwnd !== null && bindWindowsOverlayAboveMpvNative(overlayHwnd, targetWindowHwnd)) {
return;
}
if (appState.mpvSocketPath) {
void syncWindowsOverlayToMpvZOrder({
overlayWindowHandle: getWindowsNativeWindowHandle(mainWindow),
targetMpvSocketPath: appState.mpvSocketPath,
}).catch((error) => {
logger.warn('Failed to bind Windows overlay owner to mpv', error);
});
return;
}
const tracker = appState.windowTracker;

View File

@@ -48,6 +48,8 @@ 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)',
);
@@ -69,12 +71,47 @@ const HWND_TOPMOST = -1;
const HWND_NOTOPMOST = -2;
function extendOverlayFrameIntoClientArea(overlayHwnd: number): void {
DwmExtendFrameIntoClientArea(overlayHwnd, {
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 {
@@ -203,7 +240,9 @@ export function getForegroundProcessName(): string | null {
}
export function setOverlayOwner(overlayHwnd: number, mpvHwnd: number): void {
SetWindowLongPtrW(overlayHwnd, GWLP_HWNDPARENT, mpvHwnd);
resetLastError();
const result = SetWindowLongPtrW(overlayHwnd, GWLP_HWNDPARENT, mpvHwnd);
assertSetWindowLongPtrSucceeded('setOverlayOwner', result);
extendOverlayFrameIntoClientArea(overlayHwnd);
}
@@ -212,21 +251,38 @@ export function ensureOverlayTransparency(overlayHwnd: number): void {
}
export function clearOverlayOwner(overlayHwnd: number): void {
SetWindowLongPtrW(overlayHwnd, GWLP_HWNDPARENT, 0);
resetLastError();
const result = SetWindowLongPtrW(overlayHwnd, GWLP_HWNDPARENT, 0);
assertSetWindowLongPtrSucceeded('clearOverlayOwner', result);
}
export function bindOverlayAboveMpv(overlayHwnd: number, mpvHwnd: number): void {
SetWindowLongPtrW(overlayHwnd, GWLP_HWNDPARENT, mpvHwnd);
const mpvExStyle = GetWindowLongW(mpvHwnd, GWL_EXSTYLE);
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;
const overlayExStyle = GetWindowLongW(overlayHwnd, GWL_EXSTYLE);
resetLastError();
const overlayExStyle = assertGetWindowLongSucceeded(
'bindOverlayAboveMpv overlay window style',
GetWindowLongW(overlayHwnd, GWL_EXSTYLE),
);
const overlayIsTopmost = (overlayExStyle & WS_EX_TOPMOST) !== 0;
if (mpvIsTopmost && !overlayIsTopmost) {
SetWindowPos(overlayHwnd, HWND_TOPMOST, 0, 0, 0, 0, SWP_FLAGS);
resetLastError();
const topmostResult = SetWindowPos(overlayHwnd, HWND_TOPMOST, 0, 0, 0, 0, SWP_FLAGS);
assertSetWindowPosSucceeded('bindOverlayAboveMpv topmost adjustment', topmostResult);
} else if (!mpvIsTopmost && overlayIsTopmost) {
SetWindowPos(overlayHwnd, HWND_NOTOPMOST, 0, 0, 0, 0, SWP_FLAGS);
resetLastError();
const notTopmostResult = SetWindowPos(overlayHwnd, HWND_NOTOPMOST, 0, 0, 0, 0, SWP_FLAGS);
assertSetWindowPosSucceeded('bindOverlayAboveMpv notopmost adjustment', notTopmostResult);
}
const windowAboveMpv = GetWindowFn(mpvHwnd, GW_HWNDPREV);
@@ -241,10 +297,17 @@ export function bindOverlayAboveMpv(overlayHwnd: number, mpvHwnd: number): void
}
}
SetWindowPos(overlayHwnd, insertAfter, 0, 0, 0, 0, SWP_FLAGS);
resetLastError();
const positionResult = SetWindowPos(overlayHwnd, insertAfter, 0, 0, 0, 0, SWP_FLAGS);
assertSetWindowPosSucceeded('bindOverlayAboveMpv z-order adjustment', positionResult);
}
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);
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);
}

View File

@@ -7,6 +7,8 @@ import {
parseWindowTrackerHelperOutput,
parseWindowTrackerHelperState,
queryWindowsForegroundProcessName,
queryWindowsTargetWindowHandle,
queryWindowsTrackerMpvWindows,
resolveWindowsTrackerHelper,
syncWindowsOverlayToMpvZOrder,
} from './windows-helper';
@@ -129,7 +131,77 @@ test('lowerWindowsOverlayInZOrder forwards overlay handle to powershell helper',
assert.equal(lowered, true);
assert.equal(capturedMode, 'lower-overlay');
assert.deepEqual(capturedArgs, ['67890']);
assert.deepEqual(capturedArgs, ['', '67890']);
});
test('queryWindowsTrackerMpvWindows resolves geometry from the powershell helper', () => {
let capturedMode: string | null = null;
let capturedArgs: string[] | null = null;
const result = queryWindowsTrackerMpvWindows({
targetMpvSocketPath: '\\\\.\\pipe\\subminer-socket',
resolveHelper: () => ({
kind: 'powershell',
command: 'powershell.exe',
args: ['-File', 'helper.ps1'],
helperPath: 'helper.ps1',
}),
runHelperSync: (_spec, mode, extraArgs = []) => {
capturedMode = mode;
capturedArgs = extraArgs;
return {
stdout: '120,240,1280,720',
stderr: 'focus=focused\nstate=visible',
};
},
});
assert.deepEqual(result, {
matches: [
{
hwnd: 0,
bounds: {
x: 120,
y: 240,
width: 1280,
height: 720,
},
area: 1280 * 720,
isForeground: true,
},
],
focusState: true,
windowState: 'visible',
});
assert.equal(capturedMode, 'geometry');
assert.deepEqual(capturedArgs, ['\\\\.\\pipe\\subminer-socket']);
});
test('queryWindowsTargetWindowHandle resolves the selected hwnd from the powershell helper', () => {
let capturedMode: string | null = null;
let capturedArgs: string[] | null = null;
const hwnd = queryWindowsTargetWindowHandle({
targetMpvSocketPath: '\\\\.\\pipe\\subminer-socket',
resolveHelper: () => ({
kind: 'powershell',
command: 'powershell.exe',
args: ['-File', 'helper.ps1'],
helperPath: 'helper.ps1',
}),
runHelperSync: (_spec, mode, extraArgs = []) => {
capturedMode = mode;
capturedArgs = extraArgs;
return {
stdout: '12345',
stderr: '',
};
},
});
assert.equal(hwnd, 12345);
assert.equal(capturedMode, 'target-hwnd');
assert.deepEqual(capturedArgs, ['\\\\.\\pipe\\subminer-socket']);
});
test('resolveWindowsTrackerHelper auto mode prefers native helper when present', () => {

View File

@@ -19,8 +19,9 @@
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 { execFile, spawnSync, type ExecFileException } from 'child_process';
import type { WindowGeometry } from '../types';
import type { MpvPollResult } from './win32';
import { createLogger } from '../logger';
const log = createLogger('tracker').child('windows-helper');
@@ -33,7 +34,8 @@ export type WindowsTrackerHelperRunMode =
| 'bind-overlay'
| 'lower-overlay'
| 'set-owner'
| 'clear-owner';
| 'clear-owner'
| 'target-hwnd';
export type WindowsTrackerHelperLaunchSpec = {
kind: WindowsTrackerHelperKind;
@@ -267,6 +269,29 @@ type WindowsTrackerHelperRunnerResult = {
stderr: string;
};
function runWindowsTrackerHelperWithSpawnSync(
spec: WindowsTrackerHelperLaunchSpec,
mode: WindowsTrackerHelperRunMode,
extraArgs: string[] = [],
): WindowsTrackerHelperRunnerResult | null {
const modeArgs = spec.kind === 'native' ? ['--mode', mode] : ['-Mode', mode];
const result = spawnSync(spec.command, [...spec.args, ...modeArgs, ...extraArgs], {
encoding: 'utf-8',
timeout: 1000,
maxBuffer: 1024 * 1024,
windowsHide: true,
});
if (result.error || result.status !== 0) {
return null;
}
return {
stdout: result.stdout ?? '',
stderr: result.stderr ?? '',
};
}
function runWindowsTrackerHelperWithExecFile(
spec: WindowsTrackerHelperLaunchSpec,
mode: WindowsTrackerHelperRunMode,
@@ -316,6 +341,92 @@ export async function queryWindowsForegroundProcessName(deps: {
return parseWindowTrackerHelperForegroundProcess(stdout);
}
export function queryWindowsTrackerMpvWindows(deps: {
targetMpvSocketPath?: string | null;
resolveHelper?: () => WindowsTrackerHelperLaunchSpec | null;
runHelperSync?: (
spec: WindowsTrackerHelperLaunchSpec,
mode: WindowsTrackerHelperRunMode,
extraArgs?: string[],
) => WindowsTrackerHelperRunnerResult | null;
} = {}): MpvPollResult | null {
const targetMpvSocketPath = deps.targetMpvSocketPath?.trim();
if (!targetMpvSocketPath) {
return null;
}
const spec =
deps.resolveHelper?.() ??
resolveWindowsTrackerHelper({
helperModeEnv: 'powershell',
});
if (!spec || spec.kind !== 'powershell') {
return null;
}
const runHelper = deps.runHelperSync ?? runWindowsTrackerHelperWithSpawnSync;
const result = runHelper(spec, 'geometry', [targetMpvSocketPath]);
if (!result) {
return null;
}
const geometry = parseWindowTrackerHelperOutput(result.stdout);
if (!geometry) {
return {
matches: [],
focusState: parseWindowTrackerHelperFocusState(result.stderr) ?? false,
windowState: parseWindowTrackerHelperState(result.stderr) ?? 'not-found',
};
}
const focusState = parseWindowTrackerHelperFocusState(result.stderr) ?? false;
return {
matches: [
{
hwnd: 0,
bounds: geometry,
area: geometry.width * geometry.height,
isForeground: focusState,
},
],
focusState,
windowState: parseWindowTrackerHelperState(result.stderr) ?? 'visible',
};
}
export function queryWindowsTargetWindowHandle(deps: {
targetMpvSocketPath?: string | null;
resolveHelper?: () => WindowsTrackerHelperLaunchSpec | null;
runHelperSync?: (
spec: WindowsTrackerHelperLaunchSpec,
mode: WindowsTrackerHelperRunMode,
extraArgs?: string[],
) => WindowsTrackerHelperRunnerResult | null;
} = {}): number | null {
const targetMpvSocketPath = deps.targetMpvSocketPath?.trim();
if (!targetMpvSocketPath) {
return null;
}
const spec =
deps.resolveHelper?.() ??
resolveWindowsTrackerHelper({
helperModeEnv: 'powershell',
});
if (!spec || spec.kind !== 'powershell') {
return null;
}
const runHelper = deps.runHelperSync ?? runWindowsTrackerHelperWithSpawnSync;
const result = runHelper(spec, 'target-hwnd', [targetMpvSocketPath]);
if (!result) {
return null;
}
const handle = Number.parseInt(result.stdout.trim(), 10);
return Number.isFinite(handle) && handle > 0 ? handle : null;
}
export async function syncWindowsOverlayToMpvZOrder(deps: {
overlayWindowHandle: string;
targetMpvSocketPath?: string | null;
@@ -360,7 +471,7 @@ export async function lowerWindowsOverlayInZOrder(deps: {
}
const runHelper = deps.runHelper ?? runWindowsTrackerHelperWithExecFile;
const { stdout } = await runHelper(spec, 'lower-overlay', [deps.overlayWindowHandle]);
const { stdout } = await runHelper(spec, 'lower-overlay', ['', deps.overlayWindowHandle]);
return stdout.trim() === 'ok';
}
@@ -384,14 +495,10 @@ export function ensureWindowsOverlayTransparencyNative(overlayHwnd: number): boo
}
}
export function bindWindowsOverlayAboveMpvNative(overlayHwnd: number): boolean {
export function bindWindowsOverlayAboveMpvNative(overlayHwnd: number, mpvHwnd: number): boolean {
try {
const win32 = require('./win32') as typeof import('./win32');
const poll = win32.findMpvWindows();
const focused = poll.matches.find((m) => m.isForeground);
const best = focused ?? poll.matches.sort((a, b) => b.area - a.area)[0];
if (!best) return false;
win32.bindOverlayAboveMpv(overlayHwnd, best.hwnd);
win32.bindOverlayAboveMpv(overlayHwnd, mpvHwnd);
win32.ensureOverlayTransparency(overlayHwnd);
return true;
} catch {

View File

@@ -39,16 +39,19 @@ const mpvMinimized: MpvPollResult = {
test('WindowsWindowTracker skips overlapping polls while poll is in flight', () => {
let pollCalls = 0;
const tracker = new WindowsWindowTracker(undefined, {
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(pollCalls, 2);
assert.equal(pollCalls, 1);
});
test('WindowsWindowTracker updates geometry from poll output', () => {

View File

@@ -19,6 +19,7 @@
import { BaseWindowTracker } from './base-tracker';
import type { WindowGeometry } from '../types';
import type { MpvPollResult } from './win32';
import { queryWindowsTrackerMpvWindows } from './windows-helper';
import { createLogger } from '../logger';
const log = createLogger('tracker').child('windows');
@@ -31,7 +32,16 @@ type WindowsTrackerDeps = {
now?: () => number;
};
function defaultPollMpvWindows(): MpvPollResult {
function defaultPollMpvWindows(targetMpvSocketPath?: string | null): MpvPollResult {
if (targetMpvSocketPath) {
const helperResult = queryWindowsTrackerMpvWindows({
targetMpvSocketPath,
});
if (helperResult) {
return helperResult;
}
}
const win32 = require('./win32') as typeof import('./win32');
return win32.findMpvWindows();
}
@@ -49,10 +59,12 @@ export class WindowsWindowTracker extends BaseWindowTracker {
private consecutiveMisses = 0;
private trackingLossStartedAtMs: number | null = null;
private targetWindowMinimized = false;
private readonly targetMpvSocketPath: string | null;
constructor(_targetMpvSocketPath?: string, deps: WindowsTrackerDeps = {}) {
super();
this.pollMpvWindows = deps.pollMpvWindows ?? defaultPollMpvWindows;
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(