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(
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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', () => {
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
}
|
||||||
});
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
132
src/main.ts
132
src/main.ts
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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', () => {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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', () => {
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
Reference in New Issue
Block a user