diff --git a/changes/fix-windows-system-mouse-lag.md b/changes/fix-windows-system-mouse-lag.md new file mode 100644 index 00000000..178e4f78 --- /dev/null +++ b/changes/fix-windows-system-mouse-lag.md @@ -0,0 +1,4 @@ +type: fixed +area: overlay + +- Fixed system-wide mouse lag on Windows while SubMiner is running: the overlay no longer installs Electron's global mouse hook for click-through forwarding, and the mpv window tracker no longer blocks the app on repeated PowerShell command-line lookups. diff --git a/docs/architecture/subtitle-overlay-priming.md b/docs/architecture/subtitle-overlay-priming.md index 8e06612a..e5ed4fc9 100644 --- a/docs/architecture/subtitle-overlay-priming.md +++ b/docs/architecture/subtitle-overlay-priming.md @@ -129,7 +129,11 @@ coming and prefetching would otherwise idle for the rest of the cue. path, empty or stale bounding shapes produced invisible or clipped subtitles even though the overlay window remained mapped above mpv. - Pointer pass-through should continue to use `setIgnoreMouseEvents(true, { forward: true })` and - the Linux cursor-poll fallback, not bounding-shape clipping. + the Linux cursor-poll fallback, not bounding-shape clipping. Note that on Windows click-through + must go through `applyOverlayClickThrough()` (`src/core/services/overlay-click-through.ts`), + which omits `forward: true` there: Electron implements forwarding with a global low-level mouse + hook that lags mouse input system-wide whenever the main thread stalls; the Windows cursor poll + handles overlay wake-up instead. - Visible-overlay show/reset marks Linux pointer passthrough state dirty even when the logical interaction state is already inactive. The next cursor-poll tick must still reapply `setIgnoreMouseEvents(true, { forward: true })`; otherwise a newly shown Electron overlay can keep diff --git a/src/core/services/ipc.ts b/src/core/services/ipc.ts index 032ed2b2..2475d215 100644 --- a/src/core/services/ipc.ts +++ b/src/core/services/ipc.ts @@ -34,6 +34,7 @@ import { parseSubsyncManualRunRequest, parseYoutubePickerResolveRequest, } from '../../shared/ipc/validators'; +import { applyOverlayClickThrough } from './overlay-click-through'; const { ipcMain } = electron; @@ -442,7 +443,13 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar const senderWindow = electron.BrowserWindow?.fromWebContents((event as IpcMainEvent).sender) ?? null; if (senderWindow && !senderWindow.isDestroyed()) { - senderWindow.setIgnoreMouseEvents(ignore, parsedOptions); + // Route forwarding requests through the platform-aware helper so Windows never + // installs Electron's global mouse hook (see overlay-click-through.ts). + if (ignore && parsedOptions?.forward) { + applyOverlayClickThrough(senderWindow); + } else { + senderWindow.setIgnoreMouseEvents(ignore, parsedOptions); + } } deps.onOverlayMouseInteractionChanged?.(!ignore, senderWindow); }, diff --git a/src/core/services/overlay-click-through.test.ts b/src/core/services/overlay-click-through.test.ts new file mode 100644 index 00000000..6593e336 --- /dev/null +++ b/src/core/services/overlay-click-through.test.ts @@ -0,0 +1,21 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { applyOverlayClickThrough } from './overlay-click-through'; + +test('applyOverlayClickThrough requests forwarding only off Windows', () => { + const calls: Array<{ ignore: boolean; forward: boolean }> = []; + const window = { + setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => { + calls.push({ ignore, forward: options?.forward === true }); + }, + }; + + applyOverlayClickThrough(window, true); + applyOverlayClickThrough(window, false); + + assert.deepEqual(calls, [ + { ignore: true, forward: false }, + { ignore: true, forward: true }, + ]); +}); diff --git a/src/core/services/overlay-click-through.ts b/src/core/services/overlay-click-through.ts new file mode 100644 index 00000000..64085a24 --- /dev/null +++ b/src/core/services/overlay-click-through.ts @@ -0,0 +1,27 @@ +type ClickThroughWindow = { + setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => void; +}; + +/** + * Puts an overlay window into click-through mode. Forwarded mouse-move ({ forward: true }) is + * what lets renderer hover tracking wake a click-through overlay, but on Windows Electron + * implements it with a global WH_MOUSE_LL hook whose callback runs on the main-process message + * loop, so any main-thread stall delays mouse input system-wide (electron/electron#10183). + * Windows instead wakes the overlay via the main-process cursor poll + * (tickWindowsOverlayPointerInteraction), so no forwarding is requested there. macOS still + * needs forwarding for renderer hover tracking; Linux ignores the flag entirely + * (electron/electron#16777). + * + * Pass isWindowsPlatform when the caller already carries a platform flag (tests simulate + * platforms through it); otherwise the real process.platform decides. + */ +export function applyOverlayClickThrough( + window: ClickThroughWindow, + isWindowsPlatform?: boolean, +): void { + if (isWindowsPlatform ?? process.platform === 'win32') { + window.setIgnoreMouseEvents(true); + } else { + window.setIgnoreMouseEvents(true, { forward: true }); + } +} diff --git a/src/core/services/overlay-visibility.test.ts b/src/core/services/overlay-visibility.test.ts index 6c493de0..6c37c9f2 100644 --- a/src/core/services/overlay-visibility.test.ts +++ b/src/core/services/overlay-visibility.test.ts @@ -848,7 +848,7 @@ test('Windows visible overlay stays click-through and binds to mpv while tracked } as never); assert.ok(calls.includes('opacity:0')); - assert.ok(calls.includes('mouse-ignore:true:forward')); + assert.ok(calls.includes('mouse-ignore:true:plain')); assert.ok(calls.includes('show-inactive')); assert.ok(calls.includes('sync-windows-z-order')); assert.ok(!calls.includes('move-top')); @@ -1060,7 +1060,7 @@ test('tracked Windows overlay refresh rebinds while already visible', () => { isWindowsPlatform: true, } as never); - assert.ok(calls.includes('mouse-ignore:true:forward')); + assert.ok(calls.includes('mouse-ignore:true:plain')); assert.ok(calls.includes('sync-windows-z-order')); assert.ok(!calls.includes('move-top')); assert.ok(!calls.includes('show')); @@ -1134,7 +1134,7 @@ test('forced passthrough still reapplies while visible on Windows', () => { forceMousePassthrough: true, } as never); - assert.ok(calls.includes('mouse-ignore:true:forward')); + assert.ok(calls.includes('mouse-ignore:true:plain')); assert.ok(!calls.includes('always-on-top:false')); assert.ok(!calls.includes('move-top')); assert.ok(calls.includes('sync-windows-z-order')); @@ -1339,7 +1339,7 @@ test('tracked Windows overlay rebinds without hiding when tracker focus changes' assert.ok(!calls.includes('always-on-top:false')); assert.ok(!calls.includes('move-top')); - assert.ok(calls.includes('mouse-ignore:true:forward')); + assert.ok(calls.includes('mouse-ignore:true:plain')); assert.ok(calls.includes('sync-windows-z-order')); assert.ok(!calls.includes('ensure-level')); assert.ok(!calls.includes('enforce-order')); @@ -1489,7 +1489,7 @@ test('tracked Windows overlay reshows click-through even if focus state is stale isWindowsPlatform: true, } as never); - assert.ok(calls.includes('mouse-ignore:true:forward')); + assert.ok(calls.includes('mouse-ignore:true:plain')); assert.ok(calls.includes('show-inactive')); assert.ok(!calls.includes('show')); }); @@ -1532,7 +1532,7 @@ test('tracked Windows overlay binds above mpv even when tracker focus lags', () assert.ok(!calls.includes('always-on-top:false')); assert.ok(!calls.includes('move-top')); - assert.ok(calls.includes('mouse-ignore:true:forward')); + assert.ok(calls.includes('mouse-ignore:true:plain')); assert.ok(calls.includes('sync-windows-z-order')); assert.ok(!calls.includes('ensure-level')); }); @@ -2193,7 +2193,7 @@ test('Windows preserves visible overlay and rebinds to mpv while tracker transie assert.ok(!calls.includes('show')); assert.ok(!calls.includes('always-on-top:false')); assert.ok(!calls.includes('move-top')); - assert.ok(calls.includes('mouse-ignore:true:forward')); + assert.ok(calls.includes('mouse-ignore:true:plain')); assert.ok(calls.includes('sync-windows-z-order')); assert.ok(!calls.includes('ensure-level')); assert.ok(calls.includes('sync-shortcuts')); diff --git a/src/core/services/overlay-visibility.ts b/src/core/services/overlay-visibility.ts index 6888bb67..49a3316b 100644 --- a/src/core/services/overlay-visibility.ts +++ b/src/core/services/overlay-visibility.ts @@ -1,6 +1,7 @@ import type { BrowserWindow } from 'electron'; import { BaseWindowTracker } from '../../window-trackers'; import { WindowGeometry } from '../../types'; +import { applyOverlayClickThrough } from './overlay-click-through'; import { OVERLAY_WINDOW_CONTENT_READY_FLAG } from './overlay-window-flags'; const WINDOWS_OVERLAY_REVEAL_DELAY_MS = 48; @@ -117,7 +118,7 @@ export function updateVisibleOverlayVisibility(args: { clearPendingWindowsOverlayReveal(mainWindow); setOverlayWindowOpacity(mainWindow, 0); } - mainWindow.setIgnoreMouseEvents(true, { forward: true }); + applyOverlayClickThrough(mainWindow, args.isWindowsPlatform); releaseOverlayWindowLevel(mainWindow); mainWindow.hide(); args.syncOverlayShortcuts(); @@ -215,7 +216,7 @@ export function updateVisibleOverlayVisibility(args: { shouldPreserveWindowsOverlayDuringFocusHandoff || (hasWindowsForegroundProcessSignal && windowsForegroundProcessName === 'mpv'); if (shouldIgnoreMouseEvents) { - mainWindow.setIgnoreMouseEvents(true, { forward: true }); + applyOverlayClickThrough(mainWindow, args.isWindowsPlatform); } else { mainWindow.setIgnoreMouseEvents(false); } @@ -263,7 +264,7 @@ export function updateVisibleOverlayVisibility(args: { if (hasNonNativeInputRegion) { mainWindow.setIgnoreMouseEvents(false); } else { - mainWindow.setIgnoreMouseEvents(true, { forward: true }); + applyOverlayClickThrough(mainWindow, args.isWindowsPlatform); } if (args.isWindowsPlatform) { scheduleWindowsOverlayReveal( @@ -424,7 +425,7 @@ export function updateVisibleOverlayVisibility(args: { return; } args.setTrackerNotReadyWarningShown(false); - mainWindow.setIgnoreMouseEvents(true, { forward: true }); + applyOverlayClickThrough(mainWindow, args.isWindowsPlatform); releaseOverlayWindowLevel(mainWindow); mainWindow.hide(); args.syncOverlayShortcuts(); diff --git a/src/main.ts b/src/main.ts index 990e9f23..7655d20d 100644 --- a/src/main.ts +++ b/src/main.ts @@ -331,6 +331,7 @@ import { acquireYoutubeSubtitleTrack, acquireYoutubeSubtitleTracks, } from './core/services/youtube/generate'; +import { applyOverlayClickThrough } from './core/services/overlay-click-through'; import { createYoutubeMediaCacheService } from './core/services/youtube/media-cache'; import { resolveYoutubePlaybackUrl } from './core/services/youtube/playback-resolve'; import { probeYoutubeTracks } from './core/services/youtube/track-probe'; @@ -5469,7 +5470,7 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({ senderWindow === modalWindow && !senderWindow.isDestroyed() ) { - senderWindow.setIgnoreMouseEvents(true, { forward: true }); + applyOverlayClickThrough(senderWindow); senderWindow.hide(); } handleOverlayModalClosedHandler(modal); diff --git a/src/main/overlay-runtime.ts b/src/main/overlay-runtime.ts index 5f3a3562..98ce57d0 100644 --- a/src/main/overlay-runtime.ts +++ b/src/main/overlay-runtime.ts @@ -2,6 +2,7 @@ import type { BrowserWindow } from 'electron'; import type { OverlayHostedModal } from '../shared/ipc/contracts'; import type { WindowGeometry } from '../types'; import type { HyprlandPlacementStatus } from '../core/services/hyprland-window-placement'; +import { applyOverlayClickThrough } from '../core/services/overlay-click-through'; import { OVERLAY_WINDOW_CONTENT_READY_FLAG, OVERLAY_WINDOW_DOCUMENT_LOADED_FLAG, @@ -299,7 +300,7 @@ export function createOverlayModalRuntimeService( } elevateModalWindow(window); if (options.passThroughMouseEvents) { - window.setIgnoreMouseEvents(true, { forward: true }); + applyOverlayClickThrough(window, platform === 'win32'); } else { window.setIgnoreMouseEvents(false); } @@ -360,7 +361,7 @@ export function createOverlayModalRuntimeService( mainWindowMousePassthroughForcedByModal = false; return; } - mainWindow.setIgnoreMouseEvents(true, { forward: true }); + applyOverlayClickThrough(mainWindow, platform === 'win32'); mainWindowMousePassthroughForcedByModal = true; return; } @@ -517,7 +518,7 @@ export function createOverlayModalRuntimeService( clearPendingModalWindowReveal(); if (modalWindow && !modalWindow.isDestroyed()) { if (reuseModalWindowAfterClose) { - modalWindow.setIgnoreMouseEvents(true, { forward: true }); + applyOverlayClickThrough(modalWindow, false); modalWindow.hide(); markModalWindowPrimed(modalWindow); } else { diff --git a/src/main/runtime/stats-overlay-visibility.ts b/src/main/runtime/stats-overlay-visibility.ts index c7a76b26..ced08ddc 100644 --- a/src/main/runtime/stats-overlay-visibility.ts +++ b/src/main/runtime/stats-overlay-visibility.ts @@ -1,3 +1,5 @@ +import { applyOverlayClickThrough } from '../../core/services/overlay-click-through'; + type StatsOverlayVisibilityWindow = { isDestroyed: () => boolean; isVisible: () => boolean; @@ -8,7 +10,7 @@ function makeOverlayMousePassive(window: StatsOverlayVisibilityWindow | null): v if (!window || window.isDestroyed() || !window.isVisible()) { return; } - window.setIgnoreMouseEvents(true, { forward: true }); + applyOverlayClickThrough(window); } export function createStatsOverlayVisibilityChangeHandler(deps: { diff --git a/src/main/runtime/visible-overlay-interaction-runtime.ts b/src/main/runtime/visible-overlay-interaction-runtime.ts index c5ca1d9d..1dd58edd 100644 --- a/src/main/runtime/visible-overlay-interaction-runtime.ts +++ b/src/main/runtime/visible-overlay-interaction-runtime.ts @@ -1,6 +1,7 @@ import { type BrowserWindow, screen } from 'electron'; import { execFile } from 'node:child_process'; import { startOverlayWindowTracker as startOverlayWindowTrackerCore } from '../../core/services'; +import { applyOverlayClickThrough } from '../../core/services/overlay-click-through'; import { isHeadlessInitialCommand, type CliArgs } from '../../cli/args'; import type { OverlayContentMeasurement, WindowGeometry } from '../../types'; import { createWindowTracker as createWindowTrackerCore } from '../../window-trackers'; @@ -603,7 +604,7 @@ export function createVisibleOverlayInteractionRuntime(deps: VisibleOverlayInter if (active) { mainWindow.setIgnoreMouseEvents(false); } else { - mainWindow.setIgnoreMouseEvents(true, { forward: true }); + applyOverlayClickThrough(mainWindow); } } diff --git a/src/window-trackers/win32.ts b/src/window-trackers/win32.ts index 8767b55f..2ae25d4b 100644 --- a/src/window-trackers/win32.ts +++ b/src/window-trackers/win32.ts @@ -1,4 +1,4 @@ -import { execFileSync } from 'node:child_process'; +import { execFile } from 'node:child_process'; import koffi from 'koffi'; import { matchesMpvSocketPathInCommandLine } from './mpv-socket-match'; @@ -173,43 +173,125 @@ function getProcessNameByPid(pid: number): string | null { } } -const processCommandLineCache = new Map(); +// Short-lived cache so the 250ms poll doesn't re-query every top-level window's process +// on each pass. The TTL bounds staleness from PID reuse. +const PROCESS_NAME_CACHE_TTL_MS = 5_000; +const PROCESS_NAME_CACHE_PRUNE_THRESHOLD = 512; +const processNameCache = new Map(); +function getCachedProcessNameByPid(pid: number): string | null { + const nowMs = Date.now(); + const cached = processNameCache.get(pid); + if (cached && cached.expiresAtMs > nowMs) { + return cached.name; + } + const name = getProcessNameByPid(pid); + processNameCache.set(pid, { name, expiresAtMs: nowMs + PROCESS_NAME_CACHE_TTL_MS }); + return name; +} + +function pruneExpiredProcessNames(nowMs: number): void { + if (processNameCache.size <= PROCESS_NAME_CACHE_PRUNE_THRESHOLD) return; + for (const [pid, entry] of processNameCache) { + if (entry.expiresAtMs <= nowMs) { + processNameCache.delete(pid); + } + } +} + +type ProcessCommandLineCacheEntry = + | { state: 'resolved'; commandLine: string; expiresAtMs: number; refreshInFlight: boolean } + | { state: 'pending' } + | { state: 'failed'; retryAtMs: number; backoffMs: number }; + +const COMMAND_LINE_RETRY_INITIAL_BACKOFF_MS = 2_000; +const COMMAND_LINE_RETRY_MAX_BACKOFF_MS = 30_000; +// A process command line never changes, so a resolved entry only has to expire to survive +// Windows PID reuse (a dead mpv's PID handed to a new instance, whose stale socket path would +// otherwise match the wrong window forever). Longer than the process-name TTL because each +// refresh costs a PowerShell spawn, and the cached value keeps being served while the refresh +// runs, so expiry never interrupts window matching. +const COMMAND_LINE_CACHE_TTL_MS = 60_000; +const processCommandLineCache = new Map(); + +function queryProcessCommandLine( + pid: number, + onResult: (commandLine: string | null) => void, +): void { + execFile( + 'powershell.exe', + [ + '-NoProfile', + '-NonInteractive', + '-ExecutionPolicy', + 'Bypass', + '-Command', + `$process = Get-CimInstance Win32_Process -Filter "ProcessId = ${pid}"; if ($process -and $process.CommandLine) { [Console]::Out.Write($process.CommandLine) }`, + ], + { + encoding: 'utf8', + windowsHide: true, + timeout: 1500, + }, + (error, stdout) => { + const output = error ? '' : stdout.trim(); + onResult(output.length > 0 ? output : null); + }, + ); +} + +// Resolves a process command line via a background PowerShell lookup. Returns null until the +// first lookup completes; the caller's next poll picks up the cached result. Failures are +// negative-cached with exponential backoff: the synchronous version of this lookup could +// block the main thread for its full 1.5s timeout on every 250ms poll, which (combined with +// the forward:true mouse hook) stalled mouse input system-wide. function getProcessCommandLineByPid(pid: number): string | null { - if (processCommandLineCache.has(pid)) { - return processCommandLineCache.get(pid) ?? null; + const entry = processCommandLineCache.get(pid); + const nowMs = Date.now(); + + if (entry?.state === 'resolved') { + if (nowMs >= entry.expiresAtMs && !entry.refreshInFlight) { + entry.refreshInFlight = true; + queryProcessCommandLine(pid, (commandLine) => { + processCommandLineCache.set(pid, { + state: 'resolved', + // A failed refresh is usually a transient query error rather than a dead process + // (a gone process owns no window, so it is never looked up again). Keep the last + // known command line and re-check after the next TTL. + commandLine: commandLine ?? entry.commandLine, + expiresAtMs: Date.now() + COMMAND_LINE_CACHE_TTL_MS, + refreshInFlight: false, + }); + }); + } + return entry.commandLine; } - let commandLine: string | null = null; - try { - const output = execFileSync( - 'powershell.exe', - [ - '-NoProfile', - '-NonInteractive', - '-ExecutionPolicy', - 'Bypass', - '-Command', - `$process = Get-CimInstance Win32_Process -Filter "ProcessId = ${pid}"; if ($process -and $process.CommandLine) { [Console]::Out.Write($process.CommandLine) }`, - ], - { - encoding: 'utf8', - windowsHide: true, - stdio: ['ignore', 'pipe', 'ignore'], - timeout: 1500, - }, - ).trim(); - commandLine = output.length > 0 ? output : null; - } catch { - commandLine = null; - } + if (entry?.state === 'pending') return null; + if (entry?.state === 'failed' && nowMs < entry.retryAtMs) return null; - if (commandLine !== null) { - processCommandLineCache.set(pid, commandLine); - } else { - processCommandLineCache.delete(pid); - } - return commandLine; + const nextBackoffMs = + entry?.state === 'failed' + ? Math.min(entry.backoffMs * 2, COMMAND_LINE_RETRY_MAX_BACKOFF_MS) + : COMMAND_LINE_RETRY_INITIAL_BACKOFF_MS; + processCommandLineCache.set(pid, { state: 'pending' }); + queryProcessCommandLine(pid, (commandLine) => { + if (commandLine !== null) { + processCommandLineCache.set(pid, { + state: 'resolved', + commandLine, + expiresAtMs: Date.now() + COMMAND_LINE_CACHE_TTL_MS, + refreshInFlight: false, + }); + } else { + processCommandLineCache.set(pid, { + state: 'failed', + retryAtMs: Date.now() + nextBackoffMs, + backoffMs: nextBackoffMs, + }); + } + }); + return null; } export function findMpvWindows(targetSocketPath?: string | null): MpvPollResult { @@ -217,8 +299,7 @@ export function findMpvWindows(targetSocketPath?: string | null): MpvPollResult const matches: MpvWindowMatch[] = []; let hasMinimized = false; let hasFocused = false; - const processNameCache = new Map(); - const processCommandLineLookupCache = new Map(); + pruneExpiredProcessNames(Date.now()); const cb = koffi.register((hwnd: number, _lParam: number) => { if (!IsWindowVisible(hwnd)) return true; @@ -228,21 +309,12 @@ export function findMpvWindows(targetSocketPath?: string | null): MpvPollResult const pidValue = pid[0]!; if (pidValue === 0) return true; - let processName = processNameCache.get(pidValue); - if (processName === undefined) { - processName = getProcessNameByPid(pidValue); - processNameCache.set(pidValue, processName); - } - + const processName = getCachedProcessNameByPid(pidValue); if (!processName || processName.toLowerCase() !== 'mpv') return true; let commandLine: string | null = null; if (targetSocketPath) { - commandLine = processCommandLineLookupCache.get(pidValue) ?? null; - if (!processCommandLineLookupCache.has(pidValue)) { - commandLine = getProcessCommandLineByPid(pidValue); - processCommandLineLookupCache.set(pidValue, commandLine); - } + commandLine = getProcessCommandLineByPid(pidValue); if (!commandLine || !matchesMpvSocketPathInCommandLine(commandLine, targetSocketPath)) { return true; }