diff --git a/changes/fix-windows-coderabbit-review-follow-ups.md b/changes/fix-windows-coderabbit-review-follow-ups.md new file mode 100644 index 00000000..46691491 --- /dev/null +++ b/changes/fix-windows-coderabbit-review-follow-ups.md @@ -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. diff --git a/scripts/get-mpv-window-windows.ps1 b/scripts/get-mpv-window-windows.ps1 index 9d7faf5b..e0a041a5 100644 --- a/scripts/get-mpv-window-windows.ps1 +++ b/scripts/get-mpv-window-windows.ps1 @@ -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 } diff --git a/src/core/services/overlay-runtime-init.test.ts b/src/core/services/overlay-runtime-init.test.ts index 42727056..cb0d68d9 100644 --- a/src/core/services/overlay-runtime-init.test.ts +++ b/src/core/services/overlay-runtime-init.test.ts @@ -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', () => { diff --git a/src/core/services/overlay-runtime-init.ts b/src/core/services/overlay-runtime-init.ts index c4924df8..36ac4d95 100644 --- a/src/core/services/overlay-runtime-init.ts +++ b/src/core/services/overlay-runtime-init.ts @@ -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(); } diff --git a/src/core/services/overlay-window-config.test.ts b/src/core/services/overlay-window-config.test.ts index 6d275e41..fda73465 100644 --- a/src/core/services/overlay-window-config.test.ts +++ b/src/core/services/overlay-window-config.test.ts @@ -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); + } } }); diff --git a/src/main.ts b/src/main.ts index eb96c851..facebc05 100644 --- a/src/main.ts +++ b/src/main.ts @@ -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 { if (process.platform !== 'win32') { return false; @@ -1959,7 +1989,8 @@ async function syncWindowsVisibleOverlayToMpvZOrder(): Promise { } 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['warnings']; +} { + return compileSessionBindings({ + keybindings: appState.keybindings, + shortcuts: getConfiguredShortcuts(), + platform: resolveSessionBindingPlatform(), + rawConfig: getResolvedConfig(), + }); +} + +function persistSessionBindings( + bindings: CompiledSessionBinding[], + warnings: ReturnType['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 { + 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; diff --git a/src/window-trackers/win32.ts b/src/window-trackers/win32.ts index 750720a8..e3739565 100644 --- a/src/window-trackers/win32.ts +++ b/src/window-trackers/win32.ts @@ -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); } diff --git a/src/window-trackers/windows-helper.test.ts b/src/window-trackers/windows-helper.test.ts index 55686b1a..b690d0d2 100644 --- a/src/window-trackers/windows-helper.test.ts +++ b/src/window-trackers/windows-helper.test.ts @@ -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', () => { diff --git a/src/window-trackers/windows-helper.ts b/src/window-trackers/windows-helper.ts index dbe16d15..50340c1d 100644 --- a/src/window-trackers/windows-helper.ts +++ b/src/window-trackers/windows-helper.ts @@ -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 { diff --git a/src/window-trackers/windows-tracker.test.ts b/src/window-trackers/windows-tracker.test.ts index 31d8bc7e..0751a94d 100644 --- a/src/window-trackers/windows-tracker.test.ts +++ b/src/window-trackers/windows-tracker.test.ts @@ -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', () => { diff --git a/src/window-trackers/windows-tracker.ts b/src/window-trackers/windows-tracker.ts index fe03b082..ad341a39 100644 --- a/src/window-trackers/windows-tracker.ts +++ b/src/window-trackers/windows-tracker.ts @@ -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(