fix(overlay): prevent Windows mouse lag during click-through tracking (#201)

This commit is contained in:
2026-08-16 01:34:51 -07:00
committed by GitHub
parent 82f6b4705a
commit 2938e7a32a
12 changed files with 206 additions and 65 deletions
+8 -1
View File
@@ -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);
},
@@ -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 },
]);
});
@@ -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 });
}
}
+7 -7
View File
@@ -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'));
+5 -4
View File
@@ -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();
+2 -1
View File
@@ -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);
+4 -3
View File
@@ -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 {
+3 -1
View File
@@ -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: {
@@ -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);
}
}
+118 -46
View File
@@ -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<number, string>();
// 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<number, { name: string | null; expiresAtMs: number }>();
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<number, ProcessCommandLineCacheEntry>();
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<number, string | null>();
const processCommandLineLookupCache = new Map<number, string | null>();
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;
}