mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-08-16 13:55:51 -07:00
fix(overlay): prevent Windows mouse lag during click-through tracking (#201)
This commit is contained in:
@@ -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.
|
||||||
@@ -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
|
path, empty or stale bounding shapes produced invisible or clipped subtitles even though the
|
||||||
overlay window remained mapped above mpv.
|
overlay window remained mapped above mpv.
|
||||||
- Pointer pass-through should continue to use `setIgnoreMouseEvents(true, { forward: true })` and
|
- 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
|
- 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
|
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
|
`setIgnoreMouseEvents(true, { forward: true })`; otherwise a newly shown Electron overlay can keep
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ import {
|
|||||||
parseSubsyncManualRunRequest,
|
parseSubsyncManualRunRequest,
|
||||||
parseYoutubePickerResolveRequest,
|
parseYoutubePickerResolveRequest,
|
||||||
} from '../../shared/ipc/validators';
|
} from '../../shared/ipc/validators';
|
||||||
|
import { applyOverlayClickThrough } from './overlay-click-through';
|
||||||
|
|
||||||
const { ipcMain } = electron;
|
const { ipcMain } = electron;
|
||||||
|
|
||||||
@@ -442,8 +443,14 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar
|
|||||||
const senderWindow =
|
const senderWindow =
|
||||||
electron.BrowserWindow?.fromWebContents((event as IpcMainEvent).sender) ?? null;
|
electron.BrowserWindow?.fromWebContents((event as IpcMainEvent).sender) ?? null;
|
||||||
if (senderWindow && !senderWindow.isDestroyed()) {
|
if (senderWindow && !senderWindow.isDestroyed()) {
|
||||||
|
// 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);
|
senderWindow.setIgnoreMouseEvents(ignore, parsedOptions);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
deps.onOverlayMouseInteractionChanged?.(!ignore, senderWindow);
|
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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -848,7 +848,7 @@ test('Windows visible overlay stays click-through and binds to mpv while tracked
|
|||||||
} as never);
|
} as never);
|
||||||
|
|
||||||
assert.ok(calls.includes('opacity:0'));
|
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('show-inactive'));
|
||||||
assert.ok(calls.includes('sync-windows-z-order'));
|
assert.ok(calls.includes('sync-windows-z-order'));
|
||||||
assert.ok(!calls.includes('move-top'));
|
assert.ok(!calls.includes('move-top'));
|
||||||
@@ -1060,7 +1060,7 @@ test('tracked Windows overlay refresh rebinds while already visible', () => {
|
|||||||
isWindowsPlatform: true,
|
isWindowsPlatform: true,
|
||||||
} as never);
|
} 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('sync-windows-z-order'));
|
||||||
assert.ok(!calls.includes('move-top'));
|
assert.ok(!calls.includes('move-top'));
|
||||||
assert.ok(!calls.includes('show'));
|
assert.ok(!calls.includes('show'));
|
||||||
@@ -1134,7 +1134,7 @@ test('forced passthrough still reapplies while visible on Windows', () => {
|
|||||||
forceMousePassthrough: true,
|
forceMousePassthrough: true,
|
||||||
} as never);
|
} 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('always-on-top:false'));
|
||||||
assert.ok(!calls.includes('move-top'));
|
assert.ok(!calls.includes('move-top'));
|
||||||
assert.ok(calls.includes('sync-windows-z-order'));
|
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('always-on-top:false'));
|
||||||
assert.ok(!calls.includes('move-top'));
|
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('sync-windows-z-order'));
|
||||||
assert.ok(!calls.includes('ensure-level'));
|
assert.ok(!calls.includes('ensure-level'));
|
||||||
assert.ok(!calls.includes('enforce-order'));
|
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,
|
isWindowsPlatform: true,
|
||||||
} as never);
|
} 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-inactive'));
|
||||||
assert.ok(!calls.includes('show'));
|
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('always-on-top:false'));
|
||||||
assert.ok(!calls.includes('move-top'));
|
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('sync-windows-z-order'));
|
||||||
assert.ok(!calls.includes('ensure-level'));
|
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('show'));
|
||||||
assert.ok(!calls.includes('always-on-top:false'));
|
assert.ok(!calls.includes('always-on-top:false'));
|
||||||
assert.ok(!calls.includes('move-top'));
|
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('sync-windows-z-order'));
|
||||||
assert.ok(!calls.includes('ensure-level'));
|
assert.ok(!calls.includes('ensure-level'));
|
||||||
assert.ok(calls.includes('sync-shortcuts'));
|
assert.ok(calls.includes('sync-shortcuts'));
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { BrowserWindow } from 'electron';
|
import type { BrowserWindow } from 'electron';
|
||||||
import { BaseWindowTracker } from '../../window-trackers';
|
import { BaseWindowTracker } from '../../window-trackers';
|
||||||
import { WindowGeometry } from '../../types';
|
import { WindowGeometry } from '../../types';
|
||||||
|
import { applyOverlayClickThrough } from './overlay-click-through';
|
||||||
import { OVERLAY_WINDOW_CONTENT_READY_FLAG } from './overlay-window-flags';
|
import { OVERLAY_WINDOW_CONTENT_READY_FLAG } from './overlay-window-flags';
|
||||||
|
|
||||||
const WINDOWS_OVERLAY_REVEAL_DELAY_MS = 48;
|
const WINDOWS_OVERLAY_REVEAL_DELAY_MS = 48;
|
||||||
@@ -117,7 +118,7 @@ export function updateVisibleOverlayVisibility(args: {
|
|||||||
clearPendingWindowsOverlayReveal(mainWindow);
|
clearPendingWindowsOverlayReveal(mainWindow);
|
||||||
setOverlayWindowOpacity(mainWindow, 0);
|
setOverlayWindowOpacity(mainWindow, 0);
|
||||||
}
|
}
|
||||||
mainWindow.setIgnoreMouseEvents(true, { forward: true });
|
applyOverlayClickThrough(mainWindow, args.isWindowsPlatform);
|
||||||
releaseOverlayWindowLevel(mainWindow);
|
releaseOverlayWindowLevel(mainWindow);
|
||||||
mainWindow.hide();
|
mainWindow.hide();
|
||||||
args.syncOverlayShortcuts();
|
args.syncOverlayShortcuts();
|
||||||
@@ -215,7 +216,7 @@ export function updateVisibleOverlayVisibility(args: {
|
|||||||
shouldPreserveWindowsOverlayDuringFocusHandoff ||
|
shouldPreserveWindowsOverlayDuringFocusHandoff ||
|
||||||
(hasWindowsForegroundProcessSignal && windowsForegroundProcessName === 'mpv');
|
(hasWindowsForegroundProcessSignal && windowsForegroundProcessName === 'mpv');
|
||||||
if (shouldIgnoreMouseEvents) {
|
if (shouldIgnoreMouseEvents) {
|
||||||
mainWindow.setIgnoreMouseEvents(true, { forward: true });
|
applyOverlayClickThrough(mainWindow, args.isWindowsPlatform);
|
||||||
} else {
|
} else {
|
||||||
mainWindow.setIgnoreMouseEvents(false);
|
mainWindow.setIgnoreMouseEvents(false);
|
||||||
}
|
}
|
||||||
@@ -263,7 +264,7 @@ export function updateVisibleOverlayVisibility(args: {
|
|||||||
if (hasNonNativeInputRegion) {
|
if (hasNonNativeInputRegion) {
|
||||||
mainWindow.setIgnoreMouseEvents(false);
|
mainWindow.setIgnoreMouseEvents(false);
|
||||||
} else {
|
} else {
|
||||||
mainWindow.setIgnoreMouseEvents(true, { forward: true });
|
applyOverlayClickThrough(mainWindow, args.isWindowsPlatform);
|
||||||
}
|
}
|
||||||
if (args.isWindowsPlatform) {
|
if (args.isWindowsPlatform) {
|
||||||
scheduleWindowsOverlayReveal(
|
scheduleWindowsOverlayReveal(
|
||||||
@@ -424,7 +425,7 @@ export function updateVisibleOverlayVisibility(args: {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
args.setTrackerNotReadyWarningShown(false);
|
args.setTrackerNotReadyWarningShown(false);
|
||||||
mainWindow.setIgnoreMouseEvents(true, { forward: true });
|
applyOverlayClickThrough(mainWindow, args.isWindowsPlatform);
|
||||||
releaseOverlayWindowLevel(mainWindow);
|
releaseOverlayWindowLevel(mainWindow);
|
||||||
mainWindow.hide();
|
mainWindow.hide();
|
||||||
args.syncOverlayShortcuts();
|
args.syncOverlayShortcuts();
|
||||||
|
|||||||
+2
-1
@@ -331,6 +331,7 @@ import {
|
|||||||
acquireYoutubeSubtitleTrack,
|
acquireYoutubeSubtitleTrack,
|
||||||
acquireYoutubeSubtitleTracks,
|
acquireYoutubeSubtitleTracks,
|
||||||
} from './core/services/youtube/generate';
|
} from './core/services/youtube/generate';
|
||||||
|
import { applyOverlayClickThrough } from './core/services/overlay-click-through';
|
||||||
import { createYoutubeMediaCacheService } from './core/services/youtube/media-cache';
|
import { createYoutubeMediaCacheService } from './core/services/youtube/media-cache';
|
||||||
import { resolveYoutubePlaybackUrl } from './core/services/youtube/playback-resolve';
|
import { resolveYoutubePlaybackUrl } from './core/services/youtube/playback-resolve';
|
||||||
import { probeYoutubeTracks } from './core/services/youtube/track-probe';
|
import { probeYoutubeTracks } from './core/services/youtube/track-probe';
|
||||||
@@ -5469,7 +5470,7 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
|
|||||||
senderWindow === modalWindow &&
|
senderWindow === modalWindow &&
|
||||||
!senderWindow.isDestroyed()
|
!senderWindow.isDestroyed()
|
||||||
) {
|
) {
|
||||||
senderWindow.setIgnoreMouseEvents(true, { forward: true });
|
applyOverlayClickThrough(senderWindow);
|
||||||
senderWindow.hide();
|
senderWindow.hide();
|
||||||
}
|
}
|
||||||
handleOverlayModalClosedHandler(modal);
|
handleOverlayModalClosedHandler(modal);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { BrowserWindow } from 'electron';
|
|||||||
import type { OverlayHostedModal } from '../shared/ipc/contracts';
|
import type { OverlayHostedModal } from '../shared/ipc/contracts';
|
||||||
import type { WindowGeometry } from '../types';
|
import type { WindowGeometry } from '../types';
|
||||||
import type { HyprlandPlacementStatus } from '../core/services/hyprland-window-placement';
|
import type { HyprlandPlacementStatus } from '../core/services/hyprland-window-placement';
|
||||||
|
import { applyOverlayClickThrough } from '../core/services/overlay-click-through';
|
||||||
import {
|
import {
|
||||||
OVERLAY_WINDOW_CONTENT_READY_FLAG,
|
OVERLAY_WINDOW_CONTENT_READY_FLAG,
|
||||||
OVERLAY_WINDOW_DOCUMENT_LOADED_FLAG,
|
OVERLAY_WINDOW_DOCUMENT_LOADED_FLAG,
|
||||||
@@ -299,7 +300,7 @@ export function createOverlayModalRuntimeService(
|
|||||||
}
|
}
|
||||||
elevateModalWindow(window);
|
elevateModalWindow(window);
|
||||||
if (options.passThroughMouseEvents) {
|
if (options.passThroughMouseEvents) {
|
||||||
window.setIgnoreMouseEvents(true, { forward: true });
|
applyOverlayClickThrough(window, platform === 'win32');
|
||||||
} else {
|
} else {
|
||||||
window.setIgnoreMouseEvents(false);
|
window.setIgnoreMouseEvents(false);
|
||||||
}
|
}
|
||||||
@@ -360,7 +361,7 @@ export function createOverlayModalRuntimeService(
|
|||||||
mainWindowMousePassthroughForcedByModal = false;
|
mainWindowMousePassthroughForcedByModal = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
mainWindow.setIgnoreMouseEvents(true, { forward: true });
|
applyOverlayClickThrough(mainWindow, platform === 'win32');
|
||||||
mainWindowMousePassthroughForcedByModal = true;
|
mainWindowMousePassthroughForcedByModal = true;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -517,7 +518,7 @@ export function createOverlayModalRuntimeService(
|
|||||||
clearPendingModalWindowReveal();
|
clearPendingModalWindowReveal();
|
||||||
if (modalWindow && !modalWindow.isDestroyed()) {
|
if (modalWindow && !modalWindow.isDestroyed()) {
|
||||||
if (reuseModalWindowAfterClose) {
|
if (reuseModalWindowAfterClose) {
|
||||||
modalWindow.setIgnoreMouseEvents(true, { forward: true });
|
applyOverlayClickThrough(modalWindow, false);
|
||||||
modalWindow.hide();
|
modalWindow.hide();
|
||||||
markModalWindowPrimed(modalWindow);
|
markModalWindowPrimed(modalWindow);
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { applyOverlayClickThrough } from '../../core/services/overlay-click-through';
|
||||||
|
|
||||||
type StatsOverlayVisibilityWindow = {
|
type StatsOverlayVisibilityWindow = {
|
||||||
isDestroyed: () => boolean;
|
isDestroyed: () => boolean;
|
||||||
isVisible: () => boolean;
|
isVisible: () => boolean;
|
||||||
@@ -8,7 +10,7 @@ function makeOverlayMousePassive(window: StatsOverlayVisibilityWindow | null): v
|
|||||||
if (!window || window.isDestroyed() || !window.isVisible()) {
|
if (!window || window.isDestroyed() || !window.isVisible()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
window.setIgnoreMouseEvents(true, { forward: true });
|
applyOverlayClickThrough(window);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createStatsOverlayVisibilityChangeHandler(deps: {
|
export function createStatsOverlayVisibilityChangeHandler(deps: {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { type BrowserWindow, screen } from 'electron';
|
import { type BrowserWindow, screen } from 'electron';
|
||||||
import { execFile } from 'node:child_process';
|
import { execFile } from 'node:child_process';
|
||||||
import { startOverlayWindowTracker as startOverlayWindowTrackerCore } from '../../core/services';
|
import { startOverlayWindowTracker as startOverlayWindowTrackerCore } from '../../core/services';
|
||||||
|
import { applyOverlayClickThrough } from '../../core/services/overlay-click-through';
|
||||||
import { isHeadlessInitialCommand, type CliArgs } from '../../cli/args';
|
import { isHeadlessInitialCommand, type CliArgs } from '../../cli/args';
|
||||||
import type { OverlayContentMeasurement, WindowGeometry } from '../../types';
|
import type { OverlayContentMeasurement, WindowGeometry } from '../../types';
|
||||||
import { createWindowTracker as createWindowTrackerCore } from '../../window-trackers';
|
import { createWindowTracker as createWindowTrackerCore } from '../../window-trackers';
|
||||||
@@ -603,7 +604,7 @@ export function createVisibleOverlayInteractionRuntime(deps: VisibleOverlayInter
|
|||||||
if (active) {
|
if (active) {
|
||||||
mainWindow.setIgnoreMouseEvents(false);
|
mainWindow.setIgnoreMouseEvents(false);
|
||||||
} else {
|
} else {
|
||||||
mainWindow.setIgnoreMouseEvents(true, { forward: true });
|
applyOverlayClickThrough(mainWindow);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+102
-30
@@ -1,4 +1,4 @@
|
|||||||
import { execFileSync } from 'node:child_process';
|
import { execFile } from 'node:child_process';
|
||||||
import koffi from 'koffi';
|
import koffi from 'koffi';
|
||||||
import { matchesMpvSocketPathInCommandLine } from './mpv-socket-match';
|
import { matchesMpvSocketPathInCommandLine } from './mpv-socket-match';
|
||||||
|
|
||||||
@@ -173,16 +173,52 @@ 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 getProcessCommandLineByPid(pid: number): string | null {
|
function getCachedProcessNameByPid(pid: number): string | null {
|
||||||
if (processCommandLineCache.has(pid)) {
|
const nowMs = Date.now();
|
||||||
return processCommandLineCache.get(pid) ?? null;
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
let commandLine: string | null = null;
|
function pruneExpiredProcessNames(nowMs: number): void {
|
||||||
try {
|
if (processNameCache.size <= PROCESS_NAME_CACHE_PRUNE_THRESHOLD) return;
|
||||||
const output = execFileSync(
|
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',
|
'powershell.exe',
|
||||||
[
|
[
|
||||||
'-NoProfile',
|
'-NoProfile',
|
||||||
@@ -195,21 +231,67 @@ function getProcessCommandLineByPid(pid: number): string | null {
|
|||||||
{
|
{
|
||||||
encoding: 'utf8',
|
encoding: 'utf8',
|
||||||
windowsHide: true,
|
windowsHide: true,
|
||||||
stdio: ['ignore', 'pipe', 'ignore'],
|
|
||||||
timeout: 1500,
|
timeout: 1500,
|
||||||
},
|
},
|
||||||
).trim();
|
(error, stdout) => {
|
||||||
commandLine = output.length > 0 ? output : null;
|
const output = error ? '' : stdout.trim();
|
||||||
} catch {
|
onResult(output.length > 0 ? output : null);
|
||||||
commandLine = null;
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (commandLine !== null) {
|
// Resolves a process command line via a background PowerShell lookup. Returns null until the
|
||||||
processCommandLineCache.set(pid, commandLine);
|
// first lookup completes; the caller's next poll picks up the cached result. Failures are
|
||||||
} else {
|
// negative-cached with exponential backoff: the synchronous version of this lookup could
|
||||||
processCommandLineCache.delete(pid);
|
// 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 {
|
||||||
|
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 commandLine;
|
return entry.commandLine;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entry?.state === 'pending') return null;
|
||||||
|
if (entry?.state === 'failed' && nowMs < entry.retryAtMs) return null;
|
||||||
|
|
||||||
|
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 {
|
export function findMpvWindows(targetSocketPath?: string | null): MpvPollResult {
|
||||||
@@ -217,8 +299,7 @@ export function findMpvWindows(targetSocketPath?: string | null): MpvPollResult
|
|||||||
const matches: MpvWindowMatch[] = [];
|
const matches: MpvWindowMatch[] = [];
|
||||||
let hasMinimized = false;
|
let hasMinimized = false;
|
||||||
let hasFocused = false;
|
let hasFocused = false;
|
||||||
const processNameCache = new Map<number, string | null>();
|
pruneExpiredProcessNames(Date.now());
|
||||||
const processCommandLineLookupCache = new Map<number, string | null>();
|
|
||||||
|
|
||||||
const cb = koffi.register((hwnd: number, _lParam: number) => {
|
const cb = koffi.register((hwnd: number, _lParam: number) => {
|
||||||
if (!IsWindowVisible(hwnd)) return true;
|
if (!IsWindowVisible(hwnd)) return true;
|
||||||
@@ -228,21 +309,12 @@ export function findMpvWindows(targetSocketPath?: string | null): MpvPollResult
|
|||||||
const pidValue = pid[0]!;
|
const pidValue = pid[0]!;
|
||||||
if (pidValue === 0) return true;
|
if (pidValue === 0) return true;
|
||||||
|
|
||||||
let processName = processNameCache.get(pidValue);
|
const processName = getCachedProcessNameByPid(pidValue);
|
||||||
if (processName === undefined) {
|
|
||||||
processName = getProcessNameByPid(pidValue);
|
|
||||||
processNameCache.set(pidValue, processName);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!processName || processName.toLowerCase() !== 'mpv') return true;
|
if (!processName || processName.toLowerCase() !== 'mpv') return true;
|
||||||
|
|
||||||
let commandLine: string | null = null;
|
let commandLine: string | null = null;
|
||||||
if (targetSocketPath) {
|
if (targetSocketPath) {
|
||||||
commandLine = processCommandLineLookupCache.get(pidValue) ?? null;
|
|
||||||
if (!processCommandLineLookupCache.has(pidValue)) {
|
|
||||||
commandLine = getProcessCommandLineByPid(pidValue);
|
commandLine = getProcessCommandLineByPid(pidValue);
|
||||||
processCommandLineLookupCache.set(pidValue, commandLine);
|
|
||||||
}
|
|
||||||
if (!commandLine || !matchesMpvSocketPathInCommandLine(commandLine, targetSocketPath)) {
|
if (!commandLine || !matchesMpvSocketPathInCommandLine(commandLine, targetSocketPath)) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user