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( 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]$Mode = 'geometry',
[string]$SocketPath, [string]$SocketPath,
[string]$OverlayWindowHandle [string]$OverlayWindowHandle
@@ -64,6 +64,9 @@ public static class SubMinerWindowsHelper {
[DllImport("user32.dll", SetLastError = true)] [DllImport("user32.dll", SetLastError = true)]
public static extern IntPtr SetWindowLongPtr(IntPtr hWnd, int nIndex, IntPtr dwNewLong); public static extern IntPtr SetWindowLongPtr(IntPtr hWnd, int nIndex, IntPtr dwNewLong);
[DllImport("kernel32.dll")]
public static extern void SetLastError(uint dwErrCode);
[DllImport("dwmapi.dll")] [DllImport("dwmapi.dll")]
public static extern int DwmGetWindowAttribute(IntPtr hwnd, int dwAttribute, out RECT pvAttribute, int cbAttribute); 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_TOPMOST = [IntPtr](-1)
$HWND_NOTOPMOST = [IntPtr](-2) $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') { if ($Mode -eq 'foreground-process') {
$foregroundWindow = [SubMinerWindowsHelper]::GetForegroundWindow() $foregroundWindow = [SubMinerWindowsHelper]::GetForegroundWindow()
if ($foregroundWindow -eq [IntPtr]::Zero) { if ($foregroundWindow -eq [IntPtr]::Zero) {
@@ -115,7 +150,9 @@ public static class SubMinerWindowsHelper {
} }
[IntPtr]$overlayWindow = [IntPtr]([int64]$OverlayWindowHandle) [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' Write-Output 'ok'
exit 0 exit 0
} }
@@ -281,6 +318,11 @@ public static class SubMinerWindowsHelper {
$mpvMatches | Sort-Object -Property Area, Width, Height -Descending | Select-Object -First 1 $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 ($Mode -eq 'set-owner') {
if ([string]::IsNullOrWhiteSpace($OverlayWindowHandle)) { if ([string]::IsNullOrWhiteSpace($OverlayWindowHandle)) {
[Console]::Error.WriteLine('overlay-window-handle-required') [Console]::Error.WriteLine('overlay-window-handle-required')
@@ -289,7 +331,9 @@ public static class SubMinerWindowsHelper {
[IntPtr]$overlayWindow = [IntPtr]([int64]$OverlayWindowHandle) [IntPtr]$overlayWindow = [IntPtr]([int64]$OverlayWindowHandle)
$targetWindow = [IntPtr]$bestMatch.HWnd $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' Write-Output 'ok'
exit 0 exit 0
} }
@@ -302,20 +346,26 @@ public static class SubMinerWindowsHelper {
[IntPtr]$overlayWindow = [IntPtr]([int64]$OverlayWindowHandle) [IntPtr]$overlayWindow = [IntPtr]([int64]$OverlayWindowHandle)
$targetWindow = [IntPtr]$bestMatch.HWnd $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) $targetWindowExStyle = [SubMinerWindowsHelper]::GetWindowLong($targetWindow, $GWL_EXSTYLE)
$targetWindowIsTopmost = ($targetWindowExStyle -band $WS_EX_TOPMOST) -ne 0 $targetWindowIsTopmost = ($targetWindowExStyle -band $WS_EX_TOPMOST) -ne 0
$overlayExStyle = [SubMinerWindowsHelper]::GetWindowLong($overlayWindow, $GWL_EXSTYLE) $overlayExStyle = [SubMinerWindowsHelper]::GetWindowLong($overlayWindow, $GWL_EXSTYLE)
$overlayIsTopmost = ($overlayExStyle -band $WS_EX_TOPMOST) -ne 0 $overlayIsTopmost = ($overlayExStyle -band $WS_EX_TOPMOST) -ne 0
if ($targetWindowIsTopmost -and -not $overlayIsTopmost) { if ($targetWindowIsTopmost -and -not $overlayIsTopmost) {
[void][SubMinerWindowsHelper]::SetWindowPos( [SubMinerWindowsHelper]::SetLastError(0)
$result = [SubMinerWindowsHelper]::SetWindowPos(
$overlayWindow, $HWND_TOPMOST, 0, 0, 0, 0, $SWP_FLAGS $overlayWindow, $HWND_TOPMOST, 0, 0, 0, 0, $SWP_FLAGS
) )
Assert-SetWindowPosSucceeded -Result $result -Operation 'bind-overlay topmost adjustment'
} elseif (-not $targetWindowIsTopmost -and $overlayIsTopmost) { } elseif (-not $targetWindowIsTopmost -and $overlayIsTopmost) {
[void][SubMinerWindowsHelper]::SetWindowPos( [SubMinerWindowsHelper]::SetLastError(0)
$result = [SubMinerWindowsHelper]::SetWindowPos(
$overlayWindow, $HWND_NOTOPMOST, 0, 0, 0, 0, $SWP_FLAGS $overlayWindow, $HWND_NOTOPMOST, 0, 0, 0, 0, $SWP_FLAGS
) )
Assert-SetWindowPosSucceeded -Result $result -Operation 'bind-overlay notopmost adjustment'
} }
$GW_HWNDPREV = 3 $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 $overlayWindow, $insertAfter, 0, 0, 0, 0, $SWP_FLAGS
) )
Assert-SetWindowPosSucceeded -Result $result -Operation 'bind-overlay z-order adjustment'
Write-Output 'ok' Write-Output 'ok'
exit 0 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']); 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 calls: string[] = [];
const tracker = { const tracker = {
onGeometryChange: null as ((...args: unknown[]) => void) | null, onGeometryChange: null as ((...args: unknown[]) => void) | null,
@@ -600,7 +600,7 @@ test('initializeOverlayRuntime preserves visible overlay on Windows tracker loss
calls.length = 0; calls.length = 0;
tracker.onWindowLost?.(); 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', () => { 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 = () => { windowTracker.onWindowLost = () => {
options.releaseOverlayOwner?.(); options.releaseOverlayOwner?.();
if (
process.platform === 'win32' &&
typeof windowTracker.isTargetWindowMinimized === 'function' &&
!windowTracker.isTargetWindowMinimized()
) {
options.syncOverlayShortcuts();
return;
}
for (const window of options.getOverlayWindows()) { for (const window of options.getOverlayWindows()) {
window.hide(); 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', () => { 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', { Object.defineProperty(process, 'platform', {
configurable: true, configurable: true,
@@ -29,10 +29,9 @@ test('Windows visible overlay window config does not start as always-on-top', ()
assert.equal(options.alwaysOnTop, false); assert.equal(options.alwaysOnTop, false);
} finally { } finally {
Object.defineProperty(process, 'platform', { if (originalPlatformDescriptor) {
configurable: true, Object.defineProperty(process, 'platform', originalPlatformDescriptor);
value: originalPlatform, }
});
} }
}); });

View File

@@ -109,11 +109,13 @@ import * as os from 'os';
import * as path from 'path'; import * as path from 'path';
import { MecabTokenizer } from './mecab-tokenizer'; import { MecabTokenizer } from './mecab-tokenizer';
import type { import type {
CompiledSessionBinding,
JimakuApiResponse, JimakuApiResponse,
KikuFieldGroupingChoice, KikuFieldGroupingChoice,
MpvSubtitleRenderMetrics, MpvSubtitleRenderMetrics,
ResolvedConfig, ResolvedConfig,
RuntimeOptionState, RuntimeOptionState,
SessionActionDispatchRequest,
SecondarySubMode, SecondarySubMode,
SubtitleData, SubtitleData,
SubtitlePosition, SubtitlePosition,
@@ -136,6 +138,7 @@ import {
ensureWindowsOverlayTransparencyNative, ensureWindowsOverlayTransparencyNative,
getWindowsForegroundProcessNameNative, getWindowsForegroundProcessNameNative,
queryWindowsForegroundProcessName, queryWindowsForegroundProcessName,
queryWindowsTargetWindowHandle,
setWindowsOverlayOwnerNative, setWindowsOverlayOwnerNative,
syncWindowsOverlayToMpvZOrder, syncWindowsOverlayToMpvZOrder,
} from './window-trackers/windows-helper'; } 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 { createJellyfinTokenStore } from './core/services/jellyfin-token-store';
import { applyRuntimeOptionResultRuntime } from './core/services/runtime-options-ipc'; import { applyRuntimeOptionResultRuntime } from './core/services/runtime-options-ipc';
import { createAnilistTokenStore } from './core/services/anilist/anilist-token-store'; 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 { createBuildOverlayShortcutsRuntimeMainDepsHandler } from './main/runtime/domains/shortcuts';
import { createMainRuntimeRegistry } from './main/runtime/registry'; import { createMainRuntimeRegistry } from './main/runtime/registry';
import { import {
@@ -449,6 +454,7 @@ import { createOverlayModalRuntimeService } from './main/overlay-runtime';
import { createOverlayModalInputState } from './main/runtime/overlay-modal-input-state'; import { createOverlayModalInputState } from './main/runtime/overlay-modal-input-state';
import { openYoutubeTrackPicker } from './main/runtime/youtube-picker-open'; import { openYoutubeTrackPicker } from './main/runtime/youtube-picker-open';
import { createPlaylistBrowserIpcRuntime } from './main/runtime/playlist-browser-ipc'; import { createPlaylistBrowserIpcRuntime } from './main/runtime/playlist-browser-ipc';
import { writeSessionBindingsArtifact } from './main/runtime/session-bindings-artifact';
import { createOverlayShortcutsRuntimeService } from './main/overlay-shortcuts-runtime'; import { createOverlayShortcutsRuntimeService } from './main/overlay-shortcuts-runtime';
import { import {
createFrequencyDictionaryRuntimeService, createFrequencyDictionaryRuntimeService,
@@ -1535,6 +1541,9 @@ const buildConfigHotReloadAppliedMainDepsHandler = createBuildConfigHotReloadApp
setKeybindings: (keybindings) => { setKeybindings: (keybindings) => {
appState.keybindings = keybindings; appState.keybindings = keybindings;
}, },
setSessionBindings: (sessionBindings) => {
persistSessionBindings(sessionBindings);
},
refreshGlobalAndOverlayShortcuts: () => { refreshGlobalAndOverlayShortcuts: () => {
refreshGlobalAndOverlayShortcuts(); refreshGlobalAndOverlayShortcuts();
}, },
@@ -1927,6 +1936,27 @@ function getWindowsNativeWindowHandleNumber(window: BrowserWindow): number {
: handle.readUInt32LE(0); : 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> { async function syncWindowsVisibleOverlayToMpvZOrder(): Promise<boolean> {
if (process.platform !== 'win32') { if (process.platform !== 'win32') {
return false; return false;
@@ -1959,7 +1989,8 @@ async function syncWindowsVisibleOverlayToMpvZOrder(): Promise<boolean> {
} }
const overlayHwnd = getWindowsNativeWindowHandleNumber(mainWindow); 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); (mainWindow as BrowserWindow & { setOpacity?: (opacity: number) => void }).setOpacity?.(1);
return true; return true;
} }
@@ -3378,6 +3409,7 @@ const { appReadyRuntimeRunner } = composeAppReadyRuntime({
loadSubtitlePosition: () => loadSubtitlePosition(), loadSubtitlePosition: () => loadSubtitlePosition(),
resolveKeybindings: () => { resolveKeybindings: () => {
appState.keybindings = resolveKeybindings(getResolvedConfig(), DEFAULT_KEYBINDINGS); appState.keybindings = resolveKeybindings(getResolvedConfig(), DEFAULT_KEYBINDINGS);
refreshCurrentSessionBindings();
}, },
createMpvClient: () => { createMpvClient: () => {
appState.mpvClient = createMpvClientRuntimeService(); appState.mpvClient = createMpvClientRuntimeService();
@@ -3520,6 +3552,9 @@ function ensureOverlayStartupPrereqs(): void {
} }
if (appState.keybindings.length === 0) { if (appState.keybindings.length === 0) {
appState.keybindings = resolveKeybindings(getResolvedConfig(), DEFAULT_KEYBINDINGS); appState.keybindings = resolveKeybindings(getResolvedConfig(), DEFAULT_KEYBINDINGS);
refreshCurrentSessionBindings();
} else if (appState.sessionBindings.length === 0) {
refreshCurrentSessionBindings();
} }
if (!appState.mpvClient) { if (!appState.mpvClient) {
appState.mpvClient = createMpvClientRuntimeService(); 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({ const { flushMpvLog, showMpvOsd } = createMpvOsdRuntimeHandlers({
appendToMpvLogMainDeps: { appendToMpvLogMainDeps: {
logPath: DEFAULT_MPV_LOG_PATH, logPath: DEFAULT_MPV_LOG_PATH,
@@ -4429,6 +4511,39 @@ const shiftSubtitleDelayToAdjacentCueHandler = createShiftSubtitleDelayToAdjacen
showMpvOsd: (text) => showMpvOsd(text), 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, { const { playlistBrowserMainDeps } = createPlaylistBrowserIpcRuntime(() => appState.mpvClient, {
getPrimarySubtitleLanguages: () => getResolvedConfig().youtube.primarySubLanguages, getPrimarySubtitleLanguages: () => getResolvedConfig().youtube.primarySubLanguages,
getSecondarySubtitleLanguages: () => getResolvedConfig().secondarySub.secondarySubLanguages, getSecondarySubtitleLanguages: () => getResolvedConfig().secondarySub.secondarySubLanguages,
@@ -4586,7 +4701,9 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
saveSubtitlePosition: (position) => saveSubtitlePosition(position), saveSubtitlePosition: (position) => saveSubtitlePosition(position),
getMecabTokenizer: () => appState.mecabTokenizer, getMecabTokenizer: () => appState.mecabTokenizer,
getKeybindings: () => appState.keybindings, getKeybindings: () => appState.keybindings,
getSessionBindings: () => appState.sessionBindings,
getConfiguredShortcuts: () => getConfiguredShortcuts(), getConfiguredShortcuts: () => getConfiguredShortcuts(),
dispatchSessionAction: (request) => dispatchSessionAction(request),
getStatsToggleKey: () => getResolvedConfig().stats.toggleKey, getStatsToggleKey: () => getResolvedConfig().stats.toggleKey,
getMarkWatchedKey: () => getResolvedConfig().stats.markWatchedKey, getMarkWatchedKey: () => getResolvedConfig().stats.markWatchedKey,
getControllerConfig: () => getResolvedConfig().controller, getControllerConfig: () => getResolvedConfig().controller,
@@ -4707,6 +4824,7 @@ const { handleCliCommand, handleInitialArgs } = composeCliStartupHandlers({
printHelp: () => printHelp(DEFAULT_TEXTHOOKER_PORT), printHelp: () => printHelp(DEFAULT_TEXTHOOKER_PORT),
stopApp: () => requestAppQuit(), stopApp: () => requestAppQuit(),
hasMainWindow: () => Boolean(overlayManager.getMainWindow()), hasMainWindow: () => Boolean(overlayManager.getMainWindow()),
dispatchSessionAction: (request: SessionActionDispatchRequest) => dispatchSessionAction(request),
getMultiCopyTimeoutMs: () => getConfiguredShortcuts().multiCopyTimeoutMs, getMultiCopyTimeoutMs: () => getConfiguredShortcuts().multiCopyTimeoutMs,
schedule: (fn: () => void, delayMs: number) => setTimeout(fn, delayMs), schedule: (fn: () => void, delayMs: number) => setTimeout(fn, delayMs),
logInfo: (message: string) => logger.info(message), logInfo: (message: string) => logger.info(message),
@@ -4973,7 +5091,17 @@ const { initializeOverlayRuntime: initializeOverlayRuntimeHandler } =
const mainWindow = overlayManager.getMainWindow(); const mainWindow = overlayManager.getMainWindow();
if (process.platform !== 'win32' || !mainWindow || mainWindow.isDestroyed()) return; if (process.platform !== 'win32' || !mainWindow || mainWindow.isDestroyed()) return;
const overlayHwnd = getWindowsNativeWindowHandleNumber(mainWindow); 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; return;
} }
const tracker = appState.windowTracker; const tracker = appState.windowTracker;

View File

@@ -48,6 +48,8 @@ const OpenProcess = kernel32.func(
'intptr __stdcall OpenProcess(uint dwDesiredAccess, bool bInheritHandle, uint dwProcessId)', 'intptr __stdcall OpenProcess(uint dwDesiredAccess, bool bInheritHandle, uint dwProcessId)',
); );
const CloseHandle = kernel32.func('bool __stdcall CloseHandle(intptr hObject)'); 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( const QueryFullProcessImageNameW = kernel32.func(
'bool __stdcall QueryFullProcessImageNameW(intptr hProcess, uint dwFlags, _Out_ uint16 *lpExeName, _Inout_ uint *lpdwSize)', '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; const HWND_NOTOPMOST = -2;
function extendOverlayFrameIntoClientArea(overlayHwnd: number): void { function extendOverlayFrameIntoClientArea(overlayHwnd: number): void {
DwmExtendFrameIntoClientArea(overlayHwnd, { const hr = DwmExtendFrameIntoClientArea(overlayHwnd, {
cxLeftWidth: -1, cxLeftWidth: -1,
cxRightWidth: -1, cxRightWidth: -1,
cyTopHeight: -1, cyTopHeight: -1,
cyBottomHeight: -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 { export interface WindowBounds {
@@ -203,7 +240,9 @@ export function getForegroundProcessName(): string | null {
} }
export function setOverlayOwner(overlayHwnd: number, mpvHwnd: number): void { 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); extendOverlayFrameIntoClientArea(overlayHwnd);
} }
@@ -212,21 +251,38 @@ export function ensureOverlayTransparency(overlayHwnd: number): void {
} }
export function clearOverlayOwner(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 { export function bindOverlayAboveMpv(overlayHwnd: number, mpvHwnd: number): void {
SetWindowLongPtrW(overlayHwnd, GWLP_HWNDPARENT, mpvHwnd); resetLastError();
const mpvExStyle = GetWindowLongW(mpvHwnd, GWL_EXSTYLE); 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 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; const overlayIsTopmost = (overlayExStyle & WS_EX_TOPMOST) !== 0;
if (mpvIsTopmost && !overlayIsTopmost) { 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) { } 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); 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 { export function lowerOverlay(overlayHwnd: number): void {
SetWindowPos(overlayHwnd, HWND_NOTOPMOST, 0, 0, 0, 0, SWP_FLAGS); resetLastError();
SetWindowPos(overlayHwnd, HWND_BOTTOM, 0, 0, 0, 0, SWP_FLAGS); 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, parseWindowTrackerHelperOutput,
parseWindowTrackerHelperState, parseWindowTrackerHelperState,
queryWindowsForegroundProcessName, queryWindowsForegroundProcessName,
queryWindowsTargetWindowHandle,
queryWindowsTrackerMpvWindows,
resolveWindowsTrackerHelper, resolveWindowsTrackerHelper,
syncWindowsOverlayToMpvZOrder, syncWindowsOverlayToMpvZOrder,
} from './windows-helper'; } from './windows-helper';
@@ -129,7 +131,77 @@ test('lowerWindowsOverlayInZOrder forwards overlay handle to powershell helper',
assert.equal(lowered, true); assert.equal(lowered, true);
assert.equal(capturedMode, 'lower-overlay'); 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', () => { test('resolveWindowsTrackerHelper auto mode prefers native helper when present', () => {

View File

@@ -19,8 +19,9 @@
import * as fs from 'node:fs'; import * as fs from 'node:fs';
import * as os from 'node:os'; import * as os from 'node:os';
import * as path from 'node:path'; 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 { WindowGeometry } from '../types';
import type { MpvPollResult } from './win32';
import { createLogger } from '../logger'; import { createLogger } from '../logger';
const log = createLogger('tracker').child('windows-helper'); const log = createLogger('tracker').child('windows-helper');
@@ -33,7 +34,8 @@ export type WindowsTrackerHelperRunMode =
| 'bind-overlay' | 'bind-overlay'
| 'lower-overlay' | 'lower-overlay'
| 'set-owner' | 'set-owner'
| 'clear-owner'; | 'clear-owner'
| 'target-hwnd';
export type WindowsTrackerHelperLaunchSpec = { export type WindowsTrackerHelperLaunchSpec = {
kind: WindowsTrackerHelperKind; kind: WindowsTrackerHelperKind;
@@ -267,6 +269,29 @@ type WindowsTrackerHelperRunnerResult = {
stderr: string; 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( function runWindowsTrackerHelperWithExecFile(
spec: WindowsTrackerHelperLaunchSpec, spec: WindowsTrackerHelperLaunchSpec,
mode: WindowsTrackerHelperRunMode, mode: WindowsTrackerHelperRunMode,
@@ -316,6 +341,92 @@ export async function queryWindowsForegroundProcessName(deps: {
return parseWindowTrackerHelperForegroundProcess(stdout); 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: { export async function syncWindowsOverlayToMpvZOrder(deps: {
overlayWindowHandle: string; overlayWindowHandle: string;
targetMpvSocketPath?: string | null; targetMpvSocketPath?: string | null;
@@ -360,7 +471,7 @@ export async function lowerWindowsOverlayInZOrder(deps: {
} }
const runHelper = deps.runHelper ?? runWindowsTrackerHelperWithExecFile; 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'; 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 { try {
const win32 = require('./win32') as typeof import('./win32'); const win32 = require('./win32') as typeof import('./win32');
const poll = win32.findMpvWindows(); win32.bindOverlayAboveMpv(overlayHwnd, mpvHwnd);
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.ensureOverlayTransparency(overlayHwnd); win32.ensureOverlayTransparency(overlayHwnd);
return true; return true;
} catch { } catch {

View File

@@ -39,16 +39,19 @@ const mpvMinimized: MpvPollResult = {
test('WindowsWindowTracker skips overlapping polls while poll is in flight', () => { test('WindowsWindowTracker skips overlapping polls while poll is in flight', () => {
let pollCalls = 0; let pollCalls = 0;
const tracker = new WindowsWindowTracker(undefined, { let tracker: WindowsWindowTracker;
tracker = new WindowsWindowTracker(undefined, {
pollMpvWindows: () => { pollMpvWindows: () => {
pollCalls += 1; pollCalls += 1;
if (pollCalls === 1) {
(tracker as unknown as { pollGeometry: () => void }).pollGeometry();
}
return mpvVisible(); return mpvVisible();
}, },
}); });
(tracker as unknown as { pollGeometry: () => void }).pollGeometry(); (tracker as unknown as { pollGeometry: () => void }).pollGeometry();
(tracker as unknown as { pollGeometry: () => void }).pollGeometry(); assert.equal(pollCalls, 1);
assert.equal(pollCalls, 2);
}); });
test('WindowsWindowTracker updates geometry from poll output', () => { test('WindowsWindowTracker updates geometry from poll output', () => {

View File

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