mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-04-10 04:19:25 -07:00
Fix Windows CodeRabbit review follow-ups
This commit is contained in:
4
changes/fix-windows-coderabbit-review-follow-ups.md
Normal file
4
changes/fix-windows-coderabbit-review-follow-ups.md
Normal 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.
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
132
src/main.ts
132
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<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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user