Compare commits

...
2 Commits
21 changed files with 1047 additions and 345 deletions

No files matched your search

+3
View File
@@ -32,6 +32,9 @@ jobs:
- name: Verify native mpv process launch
run: bun test src/main/runtime/mpv-process.test.ts
- name: Verify mpv PID lookup and Windows tracker
run: bun test src/window-trackers/mpv-process-id.test.ts src/window-trackers/windows-tracker.test.ts
- name: Verify POSIX launcher bootstrap
run: bun test src/main/runtime/posix-launcher-bootstrap.test.ts
@@ -0,0 +1,4 @@
type: docs
area: overlay
- Explain how to collect and interpret Windows overlay attachment and readiness warnings without enabling debug logging.
@@ -0,0 +1,6 @@
type: fixed
area: overlay
- Windows overlay attachment now identifies mpv through its IPC process ID, removing the dependency on PowerShell command-line lookups and allowing the IPC socket to be configured in `mpv.conf`.
- Revalidate cached Windows mpv process IDs and expire them after sustained IPC failures so PID reuse cannot keep the overlay attached to an unrelated window.
- Diagnose stalled Windows overlays at the default warn log level, including tracking/PID state, renderer readiness, opacity, native binding errors, and recovery, with repeated warnings rate limited.
+11
View File
@@ -51,6 +51,17 @@ When `connect ENOENT /tmp/subminer-socket` follows a cast in the app log, SubMin
- Confirm SubMiner is running (`SubMiner.AppImage --start`, or check for the process).
- Linux: Hyprland and Sway work natively. Any other compositor needs mpv and SubMiner under X11 or Xwayland, with `xdotool`, `xprop`, and `xwininfo` installed. See [KDE Plasma and other Wayland compositors](#kde-plasma-and-other-wayland-compositors).
- macOS: grant Accessibility permission. See [macOS](#macos).
- Windows: leave the affected mpv window open for at least five seconds, then collect the daily
app log from the directory above. No debug setting is required: a stalled overlay logs
`Windows overlay is not visible after startup or tracking loss` with its PID, native window
handle, tracking state, geometry, opacity, and renderer readiness. The `reason` distinguishes
a missing mpv window, an unready renderer, a hidden window, and a transparent window.
This warning waits until mpv has answered on its IPC socket, so a requested overlay with no
mpv running stays quiet. PID lookup failures and native ownership or transparency errors also
appear at `warn`, and a PID lookup failure is the only trace if mpv never answers.
Repeated visibility/native-operation warnings are limited to one every 30 seconds; recovery
is recorded too. These logs diagnose attachment and readiness, but cannot prove that pixels
were drawn correctly by the graphics driver.
## Overlay is on the wrong monitor or position
+10
View File
@@ -27,6 +27,16 @@ Read when: you need to find the owner module for a behavior or test surface
subtitle suppression while the overlay shows, and the overlay-window IPC handlers (modal
open/close, pointer interaction, content measurement): `src/main/assemblies/overlay.ts`
Overlay-hosted modal openers: `src/main/assemblies/overlay-modals.ts`
Windows window tracking: `src/window-trackers/windows-tracker.ts` queries the target mpv IPC
socket for its PID through `mpv-process-id.ts`, then `win32.ts` matches native windows by PID.
Visible matches are revalidated every 10 seconds; a PID expires after 30 seconds without a
successful IPC response. Missing windows retry at one-second intervals. Custom executable
names remain supported because matching uses the IPC PID.
`src/core/services/overlay-visibility-diagnostics.ts` watches requested Windows overlays:
after five seconds hidden or transparent it logs a warn-level snapshot, repeating at most
every 30 seconds. Intentional hiding, minimization and having no mpv answer on the socket
yet suppress these warnings; an identified mpv whose window never matches does not. PID lookup
failures and native ownership/transparency failures also log at warn, including recovery.
- MPV runtime and protocol: `src/core/services/mpv*.ts`
mpv client creation, mpv event wiring, AniSkip, the autoplay-ready gate that releases mpv's
startup pause, managed local subtitle selection, subtitle prefetch, and subtitle sidebar state:
+39
View File
@@ -114,6 +114,45 @@ test(
},
);
// The other tests read the renderer over CDP, which works even if the window never appears.
// Windows keeps the overlay hidden until the tracker identifies mpv's window.
// Bun's node:test ignores the `skip` option, so this picks `test.skip` instead.
const testOnWindows = process.platform === 'win32' ? test : test.skip;
testOnWindows('visible overlay window appears over mpv', { timeout: TEST_TIMEOUT_MS }, async () => {
const { session } = await useSession();
await session.runAppCommand('--show-visible-overlay');
const mpvPid = await session.mpv.command('get_property', 'pid');
assert.equal(typeof mpvPid, 'number');
const main = await session.mainProcess();
await waitUntil(
() =>
main.evaluate<boolean>(
`(() => {
const electron = process.mainModule.require('electron');
const overlay = electron.BrowserWindow.getAllWindows().find(
(w) => w.webContents.getURL().includes(${JSON.stringify(OVERLAY_PAGE)}));
if (!overlay?.isVisible() || overlay.getOpacity() !== 1 ||
overlay.__subminerOverlayContentReady !== true) return false;
// The inspector's mainModule can be Electron's bootstrap module. Resolve app
// dependencies from the repository instead of that module's search paths.
const appRequire = process.mainModule.require('node:module').createRequire(
process.mainModule.require('node:path').resolve('package.json'));
const koffi = appRequire('koffi');
const getWindow = koffi.load('user32.dll').func(
'intptr __stdcall GetWindow(intptr hwnd, uint uCmd)');
const handle = overlay.getNativeWindowHandle();
const hwnd = handle.length >= 8 ? Number(handle.readBigUInt64LE(0)) : handle.readUInt32LE(0);
const owner = getWindow(hwnd, 4); // GW_OWNER
const win32 = appRequire(
process.mainModule.require('node:path').resolve('dist/window-trackers/win32.js'));
return win32.findMpvWindows(${JSON.stringify(mpvPid)}).matches.some(
(match) => match.hwnd === owner);
})()`,
),
{ description: 'the visible overlay to be fully revealed and owned by the target mpv window' },
);
});
test(
'seeking to another cue replaces the line and deinflects verbs to their headword',
{ timeout: TEST_TIMEOUT_MS },
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "subminer",
"productName": "SubMiner",
"desktopName": "SubMiner.desktop",
"version": "0.22.0",
"version": "0.22.1-beta.1",
"description": "All-in-one sentence mining overlay with AnkiConnect and dictionary integration",
"packageManager": "bun@1.3.5",
"main": "dist/main-entry.js",
+8 -75
View File
@@ -1,88 +1,21 @@
> This is a prerelease build for testing. Stable changelog and docs-site updates remain pending until the final stable release.
<!-- prerelease-version: 0.20.1-beta.2; since: v0.20.1-beta.1 -->
## Changes since v0.20.1-beta.1
- Upgraded the desktop runtime to Electron 43.7.7.
- SubMiner now refuses to start on an unsupported Electron runtime, or after an Electron major or minor downgrade, so your Yomitan profile data stays safe; patch downgrades are still allowed.
- Automatic character-dictionary updates now stop if a Yomitan profile that had dictionaries suddenly reports none.
- Development launches now use a separate `SubMiner-dev` profile unless you explicitly ask for the production profile.
- Sentence mining (single and multi-line) no longer counts each card twice in session and lifetime stats.
- Deleting sessions now updates word and kanji "first seen" and "last seen" dates correctly, and retention pruning keeps historical discovery dates and counts.
- Words hidden by the vocabulary filter are no longer counted in the new-words charts, so the charts now match the vocabulary summary.
- A one-time background repair on next launch fixes stale "last seen" dates and existing chart mismatches.
- Overview totals, charts, the activity calendar, and the vocabulary summary now refresh after you delete sessions, and a failed refresh keeps the dashboard visible with a retry option.
- Subtitle generation now works during Windows YouTube playback and loads the generated subtitles into the playing video.
- On Windows, moving on to a video queued from the YouTube browser window no longer drops the rest of mpv's queue.
- The stats docs now cover the automatic Overview refresh after deletions and how discovery dates are kept through retention pruning.
<!-- prerelease-version: 0.22.1-beta.1 -->
## Highlights
### Added
- **YouTube Browser Window**:
- Open it with `subminer youtube` (or `subminer yt`), or pick **Browse YouTube** from the tray. It stays logged in across restarts.
- Clicking a video plays it in mpv. Middle-click, Shift-click, Ctrl-click, or the right-click menu adds it to mpv's playlist.
- Queued videos get the same Japanese subtitle setup when they start.
- Closing the window quits SubMiner if you started it with `subminer youtube`. If a video is still playing, SubMiner waits until mpv closes.
- **Whisper Subtitles for YouTube**:
- Set `youtube.subtitleSource` to `whisper` to make subtitles with Whisper instead of downloading YouTube's captions.
- The video stays paused while Whisper works, and progress shows in the subtitle generation modal. You can close the modal to keep watching, or cancel.
- The subtitle generation modal (`Ctrl+Shift+G`) now works on YouTube videos, including on Windows.
- **Mouse and Scroll Wheel Bindings**:
- You can now use scroll wheel keys (`WHEEL_UP`, `WHEEL_DOWN`, `WHEEL_LEFT`, `WHEEL_RIGHT`) in `keybindings` and record them in the settings key editor.
- mpv's mouse button and wheel bindings now work while the cursor is over the overlay. This matters most on Hyprland, where these bindings did nothing before.
- When a binding conflicts, SubMiner's own bindings and right-click pause take priority over mpv's.
- **Session Help Commands**: During playback, press `Enter` or double-click a row in session help to run that command. Hovering over a row makes it the target for `Enter`.
### Changed
- **Release Assets**: Releases no longer include package-size JSON reports.
### Fixed
- **Jellyfin Subtitles**:
- The Japanese track is selected, and annotations start, as soon as it downloads. SubMiner no longer waits for the other tracks.
- Japanese and English tracks are now picked automatically on episodes with image-based subtitles (PGS, DVD, DVB).
- If one track fails to download, the other tracks are still selected.
- **Jellyfin Audio Track**: On direct-play streams, timing review previews and mined card audio now use the audio track you're playing.
- **Media Timing Review**:
- Trim handles are now thin lines centered on the clip edges.
- Original subtitle boundaries are now plain orange bars with no labels.
- The preview cursor no longer stutters on macOS with Bluetooth headphones such as AirPods.
- **Hyprland Fullscreen**: The overlay stays visible when mpv enters or leaves fullscreen. With `mpv.backend: x11`, subtitles and the sidebar stay clickable after going fullscreen.
- **mpv Key Bindings in the Overlay**: mpv key bindings, such as `9`/`0` for volume, now work while the overlay has focus, even when the overlay loaded before mpv connected.
- **YouTube Japanese Subtitles**: Some videos also list a machine-translated Japanese track. On those, Japanese subtitles no longer fail with "HTTP 429". If YouTube refuses a direct download, SubMiner retries with yt-dlp.
- **Stats Accuracy**:
- Sentence mining no longer counts each card twice in session and lifetime stats.
- Overview totals, charts, the activity calendar, and the vocabulary summary now update after you delete sessions.
- Word and kanji dates stay correct after deleting sessions. New-word charts now match the vocabulary summary.
- **Dictionary Safety**: Automatic character dictionary changes now stop if a Yomitan profile that had dictionaries suddenly reports none. SubMiner also refuses to load Yomitan data after a downgrade to an older Electron version.
- **AppImage in Sandboxes**: The AppImage now opens its window in sandboxes that mount the image themselves, such as `firejail --appimage`.
- **Ctrl+C Crash**: Stopping the terminal command that started SubMiner with Ctrl+C no longer crashes it with a "write EPIPE" error.
- **Security Updates**: Electron is updated to 43.7.7 and undici to 7.29.1, which fixes known security advisories.
- **Windows Overlay**:
- The overlay now finds mpv more reliably, without relying on PowerShell lookups.
- You can now configure the IPC socket in `mpv.conf`.
- The overlay no longer stays attached to an unrelated window after mpv's process ID is reused.
- Stalled overlays now log diagnostic warnings without debug logging enabled.
### Docs
- **Docs Site Rewrite**: Pages are shorter and easier to scan. The configuration reference now has a short explanation and a table of keys and defaults for each config block. Pages that no longer matched current behavior are fixed.
- **Arch Linux Install**: The MeCab install command now uses the AUR package `mecab-git`. The README now matches the installation guide.
- **Stats**: The docs now cover the Overview refreshing after you delete sessions, and how first-seen dates are kept when old sessions are cleaned up.
- **Windows Overlay Troubleshooting**: Added a guide to collecting and interpreting overlay attachment and readiness warnings without debug logging.
## What's Changed
- fix(storage): protect Yomitan profiles across Electron runtime changes by @ksyasuda in #211
- fix(jellyfin): select subtitles faster and survive failed track downloads by @ksyasuda in #270
- fix(jellyfin): use the current audio track for media generation by @ksyasuda in #271
- feat(youtube): add YouTube browser window and Whisper subtitle source by @ksyasuda in #273
- fix(overlay): thin centered trim handles and label-free subtitle boundaries in timing review by @ksyasuda in #274
- feat(overlay): run session help commands with Enter or double-click by @ksyasuda in #275
- fix(overlay): keep overlay mapped during Hyprland/Sway fullscreen by @ksyasuda in #276
- fix(linux): skip AppImage mount keepalive on sandbox squashfs mounts by @ksyasuda in #277
- fix(overlay): reload imported mpv keys after mpv connects by @ksyasuda in #278
- feat(overlay): forward mpv mouse button and wheel bindings by @ksyasuda in #279
- fix(stats): count mined cards once and keep lexical stats exact after deletes by @ksyasuda in #280
- fix(youtube): keep mpv queue when advancing to a queued video on Windows by @ksyasuda in #281
- fix(subtitles): allow Whisper generation during Windows YouTube stream playback by @ksyasuda in #282
- fix(overlay): identify Windows mpv windows through IPC PID by @ksyasuda in #330
## Installation
@@ -0,0 +1,176 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
createOverlayVisibilityDiagnostics,
readOverlayVisibilitySnapshot,
type OverlayVisibilitySnapshot,
} from './overlay-visibility-diagnostics';
type SnapshotArgs = Parameters<typeof readOverlayVisibilitySnapshot>[0];
function snapshotFor(
options: {
tracking?: boolean;
targetPid?: number | null;
minimized?: boolean;
window?: Record<string, unknown>;
noTracker?: boolean;
} & Pick<SnapshotArgs, 'modalActive' | 'suspendVisibleOverlay'> & {
visibleOverlayVisible?: boolean;
} = {},
) {
const tracker = {
isTracking: () => options.tracking ?? false,
getGeometry: () => null,
isTargetWindowFocused: () => false,
isTargetWindowMinimized: () => options.minimized ?? false,
getDiagnosticState: () => ({ targetPid: options.targetPid ?? null, socketPath: '/tmp/mpv' }),
};
return readOverlayVisibilitySnapshot({
mainWindow: {
isDestroyed: () => false,
isVisible: () => false,
getOpacity: () => 0,
...options.window,
} as unknown as SnapshotArgs['mainWindow'],
windowTracker: options.noTracker ? null : (tracker as unknown as SnapshotArgs['windowTracker']),
visibleOverlayVisible: options.visibleOverlayVisible ?? true,
modalActive: options.modalActive,
suspendVisibleOverlay: options.suspendVisibleOverlay,
});
}
function harness() {
let now = 0;
let callback: (() => void) | null = null;
let delay = 0;
const warnings: Array<{ message: string; details: Record<string, unknown> }> = [];
let snapshot: OverlayVisibilitySnapshot | null = {
expectedVisible: true,
visible: false,
opacity: 0,
contentReady: false,
tracking: false,
targetPid: 4242,
pidLookupPending: false,
};
const diagnostics = createOverlayVisibilityDiagnostics({
now: () => now,
warn: (message, details) => warnings.push({ message, details }),
schedule: (fn, ms) => {
callback = fn;
delay = ms;
return { unref() {} } as ReturnType<typeof setTimeout>;
},
cancel: () => {
callback = null;
},
});
const update = () => diagnostics.update(() => snapshot);
return {
warnings,
update,
setSnapshot: (value: typeof snapshot) => {
snapshot = value;
},
tick: () => {
assert.ok(callback, 'watchdog must be scheduled');
now += delay;
callback();
},
scheduled: () => callback !== null,
};
}
test('visibility watchdog warns without more events and rate limits repeated polls', () => {
const h = harness();
h.update();
assert.equal(h.warnings.length, 0);
for (let i = 0; i < 100; i += 1) h.update();
h.tick();
assert.equal(h.warnings.length, 1);
assert.equal(h.warnings[0]?.details.reason, 'mpv-window-not-tracked');
assert.equal(h.warnings[0]?.details.targetPid, 4242);
assert.equal(h.warnings[0]?.details.elapsedMs, 5_000);
for (let i = 0; i < 100; i += 1) h.update();
assert.equal(h.warnings.length, 1);
h.tick();
assert.equal(h.warnings[1]?.details.elapsedMs, 35_000);
});
test('visibility watchdog distinguishes renderer readiness, hidden windows and opacity', () => {
for (const [contentReady, visible, opacity, reason] of [
[false, false, 0, 'renderer-not-ready'],
[true, false, 0, 'window-hidden'],
[true, true, 0, 'window-transparent'],
] as const) {
const h = harness();
h.setSnapshot({ expectedVisible: true, tracking: true, contentReady, visible, opacity });
h.update();
h.tick();
assert.equal(h.warnings[0]?.details.reason, reason);
}
});
test('visibility watchdog reports recovery only after warning and stops', () => {
const h = harness();
h.update();
h.tick();
h.setSnapshot({
expectedVisible: true,
visible: true,
opacity: 1,
contentReady: true,
tracking: true,
});
h.tick();
assert.match(h.warnings[1]!.message, /recovered/);
assert.equal(h.scheduled(), false);
h.update();
assert.equal(h.warnings.length, 2);
});
test('visibility watchdog stays quiet after fast startup, intentional hide or window destruction', () => {
for (const snapshot of [
{ expectedVisible: true, visible: true, opacity: 1, contentReady: true, tracking: true },
{ expectedVisible: false, visible: false, opacity: 0, contentReady: false, tracking: false },
null,
]) {
const h = harness();
h.update();
h.setSnapshot(snapshot);
h.tick();
assert.deepEqual(h.warnings, []);
assert.equal(h.scheduled(), false);
}
});
test('overlay snapshot expects visibility only once an mpv has been identified', () => {
// Requested before any mpv exists: auto_start_overlay, tray or --show-visible-overlay.
assert.equal(snapshotFor()?.expectedVisible, false);
assert.equal(snapshotFor({ noTracker: true })?.expectedVisible, false);
// mpv answered on the socket but its window never matched, so tracking never started.
assert.equal(snapshotFor({ targetPid: 4242 })?.expectedVisible, true);
// A tracker with no target socket matches windows without ever learning a PID.
assert.equal(snapshotFor({ tracking: true })?.expectedVisible, true);
});
test('overlay snapshot does not expect an intentionally hidden overlay', () => {
const identified = { targetPid: 4242, tracking: true };
assert.equal(snapshotFor(identified)?.expectedVisible, true);
assert.equal(snapshotFor({ ...identified, modalActive: true })?.expectedVisible, false);
assert.equal(snapshotFor({ ...identified, suspendVisibleOverlay: true })?.expectedVisible, false);
assert.equal(snapshotFor({ ...identified, minimized: true })?.expectedVisible, false);
assert.equal(
snapshotFor({ ...identified, visibleOverlayVisible: false })?.expectedVisible,
false,
);
});
test('overlay snapshot carries tracker state, defaults opacity and ends with the window', () => {
const snapshot = snapshotFor({ targetPid: 4242, window: { getOpacity: undefined } });
assert.equal(snapshot?.targetPid, 4242);
assert.equal(snapshot?.socketPath, '/tmp/mpv');
assert.equal(snapshot?.opacity, 1);
assert.equal(snapshotFor({ window: { isDestroyed: () => true } }), null);
});
@@ -0,0 +1,153 @@
import type { BrowserWindow } from 'electron';
import type { BaseWindowTracker } from '../../window-trackers';
import { createLogger } from '../../logger';
import {
OVERLAY_WINDOW_CONTENT_READY_FLAG,
OVERLAY_WINDOW_DOCUMENT_LOADED_FLAG,
} from './overlay-window-flags';
const log = createLogger('overlay:visibility');
export type OverlayVisibilitySnapshot = {
expectedVisible: boolean;
visible: boolean;
opacity: number;
contentReady: boolean;
tracking: boolean;
[key: string]: unknown;
};
// One watchdog per window, independent of further visibility events: a renderer
// that never becomes ready must still leave evidence at the default warn level.
export function createOverlayVisibilityDiagnostics(deps: {
warn: (message: string, details: Record<string, unknown>) => void;
now: () => number;
schedule: (callback: () => void, delayMs: number) => ReturnType<typeof setTimeout>;
cancel: (timer: ReturnType<typeof setTimeout>) => void;
}) {
let timer: ReturnType<typeof setTimeout> | null = null;
let readSnapshot: () => OverlayVisibilitySnapshot | null = () => null;
let stalledAtMs: number | null = null;
let warned = false;
let lastWarningAtMs = 0;
const check = () => {
if (timer !== null) deps.cancel(timer);
timer = null;
const snapshot = readSnapshot();
if (!snapshot?.expectedVisible) {
stalledAtMs = null;
warned = false;
return;
}
if (snapshot.visible && snapshot.opacity > 0 && snapshot.contentReady) {
if (warned) {
deps.warn('Windows overlay visibility recovered', {
...snapshot,
elapsedMs: deps.now() - stalledAtMs!,
});
}
stalledAtMs = null;
warned = false;
return;
}
stalledAtMs ??= deps.now();
const elapsedMs = deps.now() - stalledAtMs;
if (elapsedMs >= 5_000 && (!warned || deps.now() - lastWarningAtMs >= 30_000)) {
const reason = !snapshot.tracking
? 'mpv-window-not-tracked'
: !snapshot.contentReady
? 'renderer-not-ready'
: !snapshot.visible
? 'window-hidden'
: 'window-transparent';
deps.warn('Windows overlay is not visible after startup or tracking loss', {
...snapshot,
reason,
elapsedMs,
});
warned = true;
lastWarningAtMs = deps.now();
}
timer = deps.schedule(
check,
warned ? 30_000 - (deps.now() - lastWarningAtMs) : 5_000 - elapsedMs,
);
timer.unref?.();
};
return {
update(reader: () => OverlayVisibilitySnapshot | null) {
readSnapshot = reader;
// Frequent polls must not postpone a warning or bypass its rate limit.
check();
},
};
}
const diagnosticsByWindow = new WeakMap<
BrowserWindow,
ReturnType<typeof createOverlayVisibilityDiagnostics>
>();
type OverlayVisibilityWatchArgs = {
mainWindow: BrowserWindow;
windowTracker: BaseWindowTracker | null;
visibleOverlayVisible: boolean;
modalActive?: boolean;
suspendVisibleOverlay?: boolean;
};
export function readOverlayVisibilitySnapshot(
args: OverlayVisibilityWatchArgs,
): OverlayVisibilitySnapshot | null {
const window = args.mainWindow;
if (window.isDestroyed()) return null;
const tracker = args.windowTracker as
| (BaseWindowTracker & {
getDiagnosticState?: () => { targetPid?: number | null } & Record<string, unknown>;
})
| null;
const trackerState = tracker?.getDiagnosticState?.();
const flags = window as unknown as Record<string, unknown>;
const minimized = tracker?.isTargetWindowMinimized?.() ?? false;
const tracking = tracker?.isTracking() ?? false;
// The overlay may be requested with no mpv to attach to (auto_start_overlay, tray,
// --show-visible-overlay), so it only counts as stalled once an mpv has answered on the
// socket. That still covers an mpv whose window never matches, which never starts tracking.
const mpvIdentified = tracking || (trackerState?.targetPid ?? null) !== null;
return {
...trackerState,
expectedVisible:
args.visibleOverlayVisible &&
mpvIdentified &&
!args.modalActive &&
!args.suspendVisibleOverlay &&
!minimized,
visible: window.isVisible(),
opacity: window.getOpacity?.() ?? 1,
contentReady: flags[OVERLAY_WINDOW_CONTENT_READY_FLAG] === true,
documentLoaded: flags[OVERLAY_WINDOW_DOCUMENT_LOADED_FLAG] === true,
rendererLoading: window.webContents?.isLoading?.() ?? null,
tracking,
trackerInitialized: tracker !== null,
geometry: tracker?.getGeometry() ?? null,
focused: tracker?.isTargetWindowFocused?.() ?? false,
minimized,
};
}
export function watchWindowsOverlayVisibility(args: OverlayVisibilityWatchArgs): void {
const window = args.mainWindow;
let diagnostics = diagnosticsByWindow.get(window);
if (!diagnostics) {
diagnostics = createOverlayVisibilityDiagnostics({
warn: (message, details) => log.warn(message, details),
now: Date.now,
schedule: setTimeout,
cancel: clearTimeout,
});
diagnosticsByWindow.set(window, diagnostics);
}
diagnostics.update(() => readOverlayVisibilitySnapshot(args));
}
@@ -589,6 +589,34 @@ runScenarios([
},
]);
test('only a live Windows overlay reads visibility diagnostics', () => {
const diagnosticReads = (platform: Platform, overrides: Partial<VisibilityArgs> = {}): number => {
let reads = 0;
createHarness({ platform, tracker: tracked() }).run({
windowTracker: {
isTracking: () => true,
getGeometry: () => GEOMETRY,
getDiagnosticState: () => {
reads += 1;
return { targetPid: 4242 };
},
} as unknown as VisibilityArgs['windowTracker'],
...overrides,
});
return reads;
};
assert.equal(diagnosticReads('windows'), 1);
assert.equal(diagnosticReads('linux'), 0);
assert.equal(diagnosticReads('macos'), 0);
assert.equal(
diagnosticReads('windows', {
mainWindow: { isDestroyed: () => true } as unknown as VisibilityArgs['mainWindow'],
}),
0,
);
});
test('Windows visible overlay restores opacity and rebinds after the deferred reveal delay', async () => {
const h = createHarness({ platform: 'windows', tracker: tracked() });
assertOutcome(h.run(), { opacity: 0, zOrderSyncs: 1 });
+11 -1
View File
@@ -3,6 +3,7 @@ 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';
import { watchWindowsOverlayVisibility } from './overlay-visibility-diagnostics';
const WINDOWS_OVERLAY_REVEAL_DELAY_MS = 48;
const pendingWindowsOverlayRevealTimeoutByWindow = new WeakMap<
@@ -65,7 +66,7 @@ function isOverlayWindowContentReady(window: BrowserWindow): boolean {
);
}
export function updateVisibleOverlayVisibility(args: {
function applyVisibleOverlayVisibility(args: {
visibleOverlayVisible: boolean;
modalActive?: boolean;
forceMousePassthrough?: boolean;
@@ -453,6 +454,15 @@ export function updateVisibleOverlayVisibility(args: {
args.syncOverlayShortcuts();
}
export function updateVisibleOverlayVisibility(
args: Parameters<typeof applyVisibleOverlayVisibility>[0],
): void {
applyVisibleOverlayVisibility(args);
if (args.isWindowsPlatform && args.mainWindow && !args.mainWindow.isDestroyed()) {
watchWindowsOverlayVisibility({ ...args, mainWindow: args.mainWindow });
}
}
export function setVisibleOverlayVisible(options: {
visible: boolean;
setVisibleOverlayVisibleState: (visible: boolean) => void;
@@ -0,0 +1,93 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import net from 'node:net';
import os from 'node:os';
import path from 'node:path';
import { queryMpvProcessId } from './mpv-process-id';
// Runs a stand-in mpv IPC server that hands each request line to `onRequest`.
async function withFakeMpv(
onRequest: (socket: net.Socket, request: string) => void,
run: (socketPath: string) => Promise<void>,
): Promise<void> {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'mpv-pid-'));
const socketPath =
process.platform === 'win32'
? path.win32.join(String.raw`\\.\pipe`, path.basename(dir))
: path.join(dir, 'mpv.sock');
const server = net.createServer((socket) => {
socket.once('data', (data) => onRequest(socket, data.toString()));
});
try {
await new Promise<void>((resolve, reject) => {
server.once('error', reject);
server.listen(socketPath, resolve);
});
await run(socketPath);
} finally {
await new Promise((resolve) => server.close(resolve));
fs.rmSync(dir, { recursive: true, force: true });
}
}
test('queryMpvProcessId asks mpv for pid and returns the answer', async () => {
let request = '';
await withFakeMpv(
(socket, line) => {
request = line;
socket.write('{"data":4242,"request_id":1,"error":"success"}\n');
},
async (socketPath) => {
assert.equal(await queryMpvProcessId(socketPath), 4242);
},
);
assert.deepEqual(JSON.parse(request), { command: ['get_property', 'pid'], request_id: 1 });
});
test('queryMpvProcessId skips events and malformed lines, across split chunks', async () => {
await withFakeMpv(
(socket) => {
socket.write('{"event":"idle"}\nnot json\n{"data":42');
setTimeout(() => socket.write('42,"request_id":1,"error":"success"}\n'), 10);
},
async (socketPath) => {
assert.equal(await queryMpvProcessId(socketPath), 4242);
},
);
});
test('queryMpvProcessId rejects when mpv refuses the property', async () => {
await withFakeMpv(
(socket) => socket.write('{"request_id":1,"error":"property unavailable"}\n'),
async (socketPath) => {
await assert.rejects(queryMpvProcessId(socketPath), /no process ID/);
},
);
});
test('queryMpvProcessId rejects when nothing is listening', async () => {
const missing =
process.platform === 'win32'
? path.win32.join(String.raw`\\.\pipe`, `mpv-pid-missing-${process.pid}`)
: path.join(os.tmpdir(), `mpv-pid-missing-${process.pid}.sock`);
await assert.rejects(queryMpvProcessId(missing));
});
test('queryMpvProcessId rejects when mpv hangs up or never answers', async () => {
await withFakeMpv(
(socket) => socket.end(),
async (socketPath) => {
await assert.rejects(queryMpvProcessId(socketPath), /closed before mpv answered/);
},
);
const held: net.Socket[] = [];
await withFakeMpv(
(socket) => held.push(socket),
async (socketPath) => {
await assert.rejects(queryMpvProcessId(socketPath, 20), /no answer within 20ms/);
held.forEach((socket) => socket.destroy());
},
);
});
+67
View File
@@ -0,0 +1,67 @@
import net from 'node:net';
const PID_REQUEST_ID = 1;
const PID_QUERY_TIMEOUT_MS = 5_000;
// This runs inside a socket callback, where a parse error would be an uncaught exception.
function parseIpcLine(line: string): unknown {
try {
return JSON.parse(line);
} catch {
return null;
}
}
// Asks the mpv listening on `socketPath` for its process ID over a short-lived IPC connection.
export function queryMpvProcessId(
socketPath: string,
timeoutMs = PID_QUERY_TIMEOUT_MS,
): Promise<number> {
return new Promise((resolve, reject) => {
const socket = net.createConnection(socketPath);
let buffered = '';
const fail = (message: string): void => {
socket.destroy();
reject(new Error(message));
};
socket.setEncoding('utf8');
socket.setTimeout(timeoutMs, () => fail(`no answer within ${timeoutMs}ms`));
socket.once('error', (error) => fail(error.message));
socket.once('close', () => fail('connection closed before mpv answered'));
socket.once('connect', () => {
socket.write(
`${JSON.stringify({ command: ['get_property', 'pid'], request_id: PID_REQUEST_ID })}\n`,
);
});
socket.on('data', (chunk: string) => {
buffered += chunk;
const lines = buffered.split('\n');
buffered = lines.pop() ?? '';
// mpv also streams events on this connection, so skip to the line answering the request.
for (const line of lines) {
const message = parseIpcLine(line);
if (
typeof message !== 'object' ||
message === null ||
!('request_id' in message) ||
message.request_id !== PID_REQUEST_ID
) {
continue;
}
const pid = 'data' in message ? message.data : undefined;
if (typeof pid === 'number') {
socket.destroy();
resolve(pid);
} else {
fail(`mpv returned no process ID: ${line}`);
}
return;
}
});
});
}
@@ -1,72 +0,0 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
filterMpvPollResultBySocketPath,
matchesMpvSocketPathInCommandLine,
} from './mpv-socket-match';
import type { MpvPollResult } from './win32';
function createPollResult(commandLines: Array<string | null>): MpvPollResult {
return {
matches: commandLines.map((commandLine, index) => ({
hwnd: index + 1,
bounds: { x: index * 10, y: 0, width: 1280, height: 720 },
area: 1280 * 720,
isForeground: index === 0,
commandLine,
})),
focusState: true,
windowState: 'visible',
};
}
test('matchesMpvSocketPathInCommandLine accepts equals and space-delimited socket flags', () => {
assert.equal(
matchesMpvSocketPathInCommandLine(
'mpv.exe --input-ipc-server=\\\\.\\pipe\\subminer-a video.mkv',
'\\\\.\\pipe\\subminer-a',
),
true,
);
assert.equal(
matchesMpvSocketPathInCommandLine(
'mpv.exe --input-ipc-server "\\\\.\\pipe\\subminer-b" video.mkv',
'\\\\.\\pipe\\subminer-b',
),
true,
);
assert.equal(
matchesMpvSocketPathInCommandLine(
'mpv.exe --input-ipc-server=\\\\.\\pipe\\subminer-a video.mkv',
'\\\\.\\pipe\\subminer-b',
),
false,
);
});
test('filterMpvPollResultBySocketPath keeps only matches for the requested socket path', () => {
const result = filterMpvPollResultBySocketPath(
createPollResult([
'mpv.exe --input-ipc-server=\\\\.\\pipe\\subminer-a video-a.mkv',
'mpv.exe --input-ipc-server=\\\\.\\pipe\\subminer-b video-b.mkv',
null,
]),
'\\\\.\\pipe\\subminer-b',
);
assert.deepEqual(
result.matches.map((match) => match.hwnd),
[2],
);
assert.equal(result.windowState, 'visible');
});
test('matchesMpvSocketPathInCommandLine rejects socket-path prefix matches', () => {
assert.equal(
matchesMpvSocketPathInCommandLine(
'mpv.exe --input-ipc-server=\\\\.\\pipe\\subminer-10 video.mkv',
'\\\\.\\pipe\\subminer-1',
),
false,
);
});
-41
View File
@@ -1,41 +0,0 @@
import type { MpvPollResult } from './win32';
function escapeRegex(text: string): string {
return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
export function matchesMpvSocketPathInCommandLine(
commandLine: string,
targetSocketPath: string,
): boolean {
if (!commandLine || !targetSocketPath) {
return false;
}
const escapedSocketPath = escapeRegex(targetSocketPath);
return new RegExp(
`(?:^|\\s)--input-ipc-server(?:=|\\s+)(?:"${escapedSocketPath}"|${escapedSocketPath})(?=\\s|$)`,
'i',
).test(commandLine);
}
export function filterMpvPollResultBySocketPath(
result: MpvPollResult,
targetSocketPath?: string | null,
): MpvPollResult {
if (!targetSocketPath) {
return result;
}
const matches = result.matches.filter(
(match) =>
typeof match.commandLine === 'string' &&
matchesMpvSocketPathInCommandLine(match.commandLine, targetSocketPath),
);
return {
matches,
focusState: matches.some((match) => match.isForeground),
windowState: matches.length > 0 ? 'visible' : 'not-found',
};
}
+7 -109
View File
@@ -1,6 +1,4 @@
import { execFile } from 'node:child_process';
import koffi from 'koffi';
import { matchesMpvSocketPathInCommandLine } from './mpv-socket-match';
const user32 = koffi.load('user32.dll');
const dwmapi = koffi.load('dwmapi.dll');
@@ -129,7 +127,6 @@ export interface MpvWindowMatch {
bounds: WindowBounds;
area: number;
isForeground: boolean;
commandLine?: string | null;
}
export interface MpvPollResult {
@@ -200,102 +197,9 @@ function pruneExpiredProcessNames(nowMs: number): void {
}
}
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 {
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;
}
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 {
// With a target PID only that process's windows match, whatever its executable is named.
// Without one, any process named mpv matches.
export function findMpvWindows(targetPid?: number | null): MpvPollResult {
const foregroundHwnd = GetForegroundWindow();
const matches: MpvWindowMatch[] = [];
let hasMinimized = false;
@@ -310,15 +214,10 @@ export function findMpvWindows(targetSocketPath?: string | null): MpvPollResult
const pidValue = pid[0]!;
if (pidValue === 0) return true;
const processName = getCachedProcessNameByPid(pidValue);
if (!processName || processName.toLowerCase() !== 'mpv') return true;
let commandLine: string | null = null;
if (targetSocketPath) {
commandLine = getProcessCommandLineByPid(pidValue);
if (!commandLine || !matchesMpvSocketPathInCommandLine(commandLine, targetSocketPath)) {
return true;
}
if (targetPid != null) {
if (pidValue !== targetPid) return true;
} else if (getCachedProcessNameByPid(pidValue)?.toLowerCase() !== 'mpv') {
return true;
}
if (IsIconic(hwnd)) {
@@ -337,7 +236,6 @@ export function findMpvWindows(targetSocketPath?: string | null): MpvPollResult
bounds,
area: bounds.width * bounds.height,
isForeground,
commandLine,
});
return true;
+73 -1
View File
@@ -1,8 +1,80 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { findWindowsMpvTargetWindowHandle } from './windows-helper';
import { createWindowsOverlayOperations, findWindowsMpvTargetWindowHandle } from './windows-helper';
import type { MpvPollResult } from './win32';
function overlayOperationsHarness() {
let nowMs = 0;
let failing = true;
const warnings: Array<{ message: string; operation: unknown }> = [];
const nativeCall = () => {
if (failing) throw new Error('native failure');
};
const operations = createWindowsOverlayOperations({
loadWin32: () => ({
setOverlayOwner: nativeCall,
ensureOverlayTransparency: nativeCall,
bindOverlayAboveMpv: nativeCall,
clearOverlayOwner: nativeCall,
}),
warn: (message, details) => warnings.push({ message, operation: details.operation }),
now: () => nowMs,
});
return {
operations,
warnings,
setNow: (value: number) => {
nowMs = value;
},
setFailing: (value: boolean) => {
failing = value;
},
};
}
for (const [operation, call] of [
['set-owner', (ops) => ops.setOwner(1, 2)],
['transparency', (ops) => ops.ensureTransparency(1)],
['bind-above-mpv', (ops) => ops.bindAboveMpv(1, 2)],
['clear-owner', (ops) => ops.clearOwner(1)],
] as const satisfies ReadonlyArray<
readonly [string, (ops: ReturnType<typeof createWindowsOverlayOperations>) => boolean]
>) {
test(`${operation} reports failure, rate limits warnings and records recovery`, () => {
const h = overlayOperationsHarness();
const failed = { message: 'Windows native overlay operation failed', operation };
assert.equal(call(h.operations), false);
assert.deepEqual(h.warnings, [failed]);
h.setNow(29_999);
assert.equal(call(h.operations), false);
assert.deepEqual(h.warnings, [failed]);
h.setNow(30_000);
assert.equal(call(h.operations), false);
assert.deepEqual(h.warnings, [failed, failed]);
h.setFailing(false);
const recovered = { message: 'Windows native overlay operation recovered', operation };
assert.equal(call(h.operations), true);
assert.deepEqual(h.warnings, [failed, failed, recovered]);
assert.equal(call(h.operations), true);
assert.equal(h.warnings.length, 3);
});
}
test('native overlay operations rate limit independently', () => {
const h = overlayOperationsHarness();
h.operations.setOwner(1, 2);
h.operations.clearOwner(1);
assert.deepEqual(
h.warnings.map((warning) => warning.operation),
['set-owner', 'clear-owner'],
);
});
test('findWindowsMpvTargetWindowHandle prefers the focused mpv window', () => {
const result: MpvPollResult = {
matches: [
+74 -38
View File
@@ -17,11 +17,85 @@
*/
import type { MpvPollResult } from './win32';
import { createLogger } from '../logger';
const log = createLogger('overlay:windows');
type OverlayWin32 = Pick<
typeof import('./win32'),
'setOverlayOwner' | 'ensureOverlayTransparency' | 'bindOverlayAboveMpv' | 'clearOverlayOwner'
>;
// These run on every visibility update, so each operation warns at most every 30 seconds
// while it keeps failing, and once more when it recovers.
export function createWindowsOverlayOperations(deps: {
loadWin32: () => OverlayWin32;
warn: (message: string, details: Record<string, unknown>) => void;
now: () => number;
}) {
const failures = new Map<string, number>();
const run = (
operation: string,
handles: { overlayHwnd: number; mpvHwnd?: number },
action: (win32: OverlayWin32) => void,
): boolean => {
try {
action(deps.loadWin32());
if (failures.delete(operation)) {
deps.warn('Windows native overlay operation recovered', { operation, ...handles });
}
return true;
} catch (error) {
const lastWarning = failures.get(operation);
if (lastWarning === undefined || deps.now() - lastWarning >= 30_000) {
deps.warn('Windows native overlay operation failed', {
operation,
...handles,
error: error instanceof Error ? error.message : String(error),
});
failures.set(operation, deps.now());
}
return false;
}
};
return {
setOwner: (overlayHwnd: number, mpvHwnd: number): boolean =>
run('set-owner', { overlayHwnd, mpvHwnd }, (win32) => {
win32.setOverlayOwner(overlayHwnd, mpvHwnd);
}),
ensureTransparency: (overlayHwnd: number): boolean =>
run('transparency', { overlayHwnd }, (win32) => {
win32.ensureOverlayTransparency(overlayHwnd);
}),
bindAboveMpv: (overlayHwnd: number, mpvHwnd: number): boolean =>
run('bind-above-mpv', { overlayHwnd, mpvHwnd }, (win32) => {
win32.bindOverlayAboveMpv(overlayHwnd, mpvHwnd);
win32.ensureOverlayTransparency(overlayHwnd);
}),
clearOwner: (overlayHwnd: number): boolean =>
run('clear-owner', { overlayHwnd }, (win32) => {
win32.clearOverlayOwner(overlayHwnd);
}),
};
}
function loadWin32(): typeof import('./win32') {
return require('./win32') as typeof import('./win32');
}
const overlayOperations = createWindowsOverlayOperations({
loadWin32,
warn: (message, details) => log.warn(message, details),
now: Date.now,
});
export const setWindowsOverlayOwner = overlayOperations.setOwner;
export const ensureWindowsOverlayTransparency = overlayOperations.ensureTransparency;
export const bindWindowsOverlayAboveMpv = overlayOperations.bindAboveMpv;
export const clearWindowsOverlayOwner = overlayOperations.clearOwner;
export function findWindowsMpvTargetWindowHandle(result?: MpvPollResult): number | null {
const poll = result ?? loadWin32().findMpvWindows();
const focused = poll.matches.find((match) => match.isForeground);
@@ -31,44 +105,6 @@ export function findWindowsMpvTargetWindowHandle(result?: MpvPollResult): number
return best?.hwnd ?? null;
}
export function setWindowsOverlayOwner(overlayHwnd: number, mpvHwnd: number): boolean {
try {
loadWin32().setOverlayOwner(overlayHwnd, mpvHwnd);
return true;
} catch {
return false;
}
}
export function ensureWindowsOverlayTransparency(overlayHwnd: number): boolean {
try {
loadWin32().ensureOverlayTransparency(overlayHwnd);
return true;
} catch {
return false;
}
}
export function bindWindowsOverlayAboveMpv(overlayHwnd: number, mpvHwnd: number): boolean {
try {
const win32 = loadWin32();
win32.bindOverlayAboveMpv(overlayHwnd, mpvHwnd);
win32.ensureOverlayTransparency(overlayHwnd);
return true;
} catch {
return false;
}
}
export function clearWindowsOverlayOwner(overlayHwnd: number): boolean {
try {
loadWin32().clearOverlayOwner(overlayHwnd);
return true;
} catch {
return false;
}
}
export function getWindowsForegroundProcessName(): string | null {
try {
return loadWin32().getForegroundProcessName();
+186
View File
@@ -265,3 +265,189 @@ test('WindowsWindowTracker keeps tracking through repeated transient minimized r
assert.equal(tracker.isTargetWindowMinimized(), false);
assert.deepEqual(tracker.getGeometry(), { x: 10, y: 20, width: 1280, height: 720 });
});
const SOCKET_PATH = String.raw`\\.\pipe\subminer-socket`;
const flushPromises = () => new Promise((resolve) => setImmediate(resolve));
function pollOnce(tracker: WindowsWindowTracker): void {
(tracker as unknown as { pollGeometry: () => void }).pollGeometry();
}
test('WindowsWindowTracker follows the process that answers on the target socket', async () => {
const queriedSockets: string[] = [];
const polledPids: Array<number | null> = [];
const tracker = new WindowsWindowTracker(SOCKET_PATH, {
queryMpvProcessId: async (socketPath) => {
queriedSockets.push(socketPath);
return 4242;
},
pollMpvWindows: (targetPid) => {
polledPids.push(targetPid);
return mpvVisible();
},
});
pollOnce(tracker);
assert.equal(tracker.isTracking(), false);
await flushPromises();
pollOnce(tracker);
assert.equal(tracker.isTracking(), true);
assert.deepEqual(queriedSockets, [SOCKET_PATH]);
assert.deepEqual(polledPids, [4242]);
});
test('WindowsWindowTracker keeps polling while a process lookup is pending', async () => {
let queries = 0;
let answer: (pid: number) => void = () => {};
let now = 1_000;
const tracker = new WindowsWindowTracker(SOCKET_PATH, {
now: () => now,
queryMpvProcessId: () => {
queries += 1;
return new Promise((resolve) => {
answer = resolve;
});
},
pollMpvWindows: () => mpvVisible(),
});
for (let poll = 0; poll < 120; poll += 1) {
pollOnce(tracker);
now += 250;
}
assert.equal(tracker.isTracking(), false);
assert.equal(queries, 1);
answer(4242);
await flushPromises();
pollOnce(tracker);
assert.equal(tracker.isTracking(), true);
});
test('WindowsWindowTracker retries a failed lookup at a limited rate', async () => {
let queries = 0;
let now = 1_000;
const tracker = new WindowsWindowTracker(SOCKET_PATH, {
now: () => now,
queryMpvProcessId: async () => {
queries += 1;
if (queries === 1) throw new Error('connect ENOENT');
return 4242;
},
pollMpvWindows: () => mpvVisible(),
});
pollOnce(tracker);
await flushPromises();
now += 250;
pollOnce(tracker);
await flushPromises();
assert.equal(queries, 1);
now += 1_000;
pollOnce(tracker);
await flushPromises();
pollOnce(tracker);
assert.equal(queries, 2);
assert.equal(tracker.isTracking(), true);
});
test('WindowsWindowTracker moves to the new process when mpv restarts on the same socket', async () => {
let currentPid = 1111;
let now = 1_000;
let queries = 0;
const polledPids: Array<number | null> = [];
const tracker = new WindowsWindowTracker(SOCKET_PATH, {
now: () => now,
queryMpvProcessId: async () => {
queries += 1;
return currentPid;
},
// Only the live process still owns a window.
pollMpvWindows: (targetPid) => {
polledPids.push(targetPid);
return targetPid === currentPid ? mpvVisible() : mpvNotFound;
},
});
pollOnce(tracker);
await flushPromises();
pollOnce(tracker);
assert.equal(tracker.isTracking(), true);
currentPid = 2222;
now += 2_000;
pollOnce(tracker);
await flushPromises();
pollOnce(tracker);
assert.equal(tracker.isTracking(), true);
assert.deepEqual(tracker.getGeometry(), { x: 0, y: 0, width: 1280, height: 720 });
assert.equal(polledPids.at(-1), 2222);
assert.equal(queries, 2);
});
test('WindowsWindowTracker revalidates a PID even while its window remains visible', async () => {
let currentPid = 1111;
let now = 0;
const polledPids: Array<number | null> = [];
const tracker = new WindowsWindowTracker(SOCKET_PATH, {
now: () => now,
queryMpvProcessId: async () => currentPid,
// Simulates PID reuse: the old PID still has a visible, unrelated window.
pollMpvWindows: (pid) => {
polledPids.push(pid);
return mpvVisible();
},
});
pollOnce(tracker);
await flushPromises();
pollOnce(tracker);
currentPid = 2222;
now = 10_000;
pollOnce(tracker);
await flushPromises();
pollOnce(tracker);
assert.equal(polledPids.at(-1), 2222);
});
test('WindowsWindowTracker expires an unvalidated PID and recovers after IPC returns', async () => {
let available = true;
let now = 0;
const polledPids: Array<number | null> = [];
const tracker = new WindowsWindowTracker(SOCKET_PATH, {
now: () => now,
queryMpvProcessId: async () => {
if (!available) throw new Error('IPC unavailable');
return 4242;
},
pollMpvWindows: (pid) => {
polledPids.push(pid);
return mpvVisible();
},
trackingLossGraceMs: 0,
maxConsecutiveMisses: 1,
});
pollOnce(tracker);
await flushPromises();
pollOnce(tracker);
assert.equal(tracker.isTracking(), true);
available = false;
now = 10_000;
pollOnce(tracker);
await flushPromises();
assert.equal(tracker.isTracking(), true);
polledPids.length = 0;
now = 30_000;
pollOnce(tracker);
await flushPromises();
assert.equal(tracker.isTracking(), false);
assert.deepEqual(polledPids, []);
available = true;
now += 1_000;
pollOnce(tracker);
await flushPromises();
pollOnce(tracker);
assert.equal(tracker.isTracking(), true);
assert.equal(polledPids.at(-1), 4242);
});
+97 -7
View File
@@ -19,27 +19,35 @@
import { BaseWindowTracker } from './base-tracker';
import type { WindowGeometry } from '../types';
import type { MpvPollResult } from './win32';
import { queryMpvProcessId } from './mpv-process-id';
import { createLogger } from '../logger';
const log = createLogger('tracker').child('windows');
const MPV_PID_LOOKUP_INTERVAL_MS = 1_000;
const MPV_PID_REVALIDATE_INTERVAL_MS = 10_000;
const MPV_PID_MAX_AGE_MS = 30_000;
const NO_MPV_WINDOW: MpvPollResult = { matches: [], focusState: false, windowState: 'not-found' };
type WindowsTrackerDeps = {
pollMpvWindows?: () => MpvPollResult;
pollMpvWindows?: (targetPid: number | null) => MpvPollResult;
queryMpvProcessId?: (socketPath: string) => Promise<number>;
maxConsecutiveMisses?: number;
trackingLossGraceMs?: number;
minimizedTrackingLossGraceMs?: number;
now?: () => number;
};
function defaultPollMpvWindows(_targetMpvSocketPath?: string | null): MpvPollResult {
function defaultPollMpvWindows(targetPid: number | null): MpvPollResult {
const win32 = require('./win32') as typeof import('./win32');
return win32.findMpvWindows(_targetMpvSocketPath);
return win32.findMpvWindows(targetPid);
}
export class WindowsWindowTracker extends BaseWindowTracker {
private pollInterval: ReturnType<typeof setInterval> | null = null;
private pollInFlight = false;
private readonly pollMpvWindows: () => MpvPollResult;
private readonly pollMpvWindows: (targetPid: number | null) => MpvPollResult;
private readonly queryMpvProcessId: (socketPath: string) => Promise<number>;
private readonly maxConsecutiveMisses: number;
private readonly trackingLossGraceMs: number;
private readonly minimizedTrackingLossGraceMs: number;
@@ -51,12 +59,17 @@ export class WindowsWindowTracker extends BaseWindowTracker {
private targetWindowMinimized = false;
private readonly targetMpvSocketPath: string | null;
private currentTargetWindowHwnd: number | null = null;
private targetMpvPid: number | null = null;
private pidLookupInFlight = false;
private lastPidLookupAtMs: number | null = null;
private lastPidConfirmedAtMs: number | null = null;
private pidLookupFailureLogged = false;
constructor(_targetMpvSocketPath?: string, deps: WindowsTrackerDeps = {}) {
super();
this.targetMpvSocketPath = _targetMpvSocketPath?.trim() || null;
this.pollMpvWindows =
deps.pollMpvWindows ?? (() => defaultPollMpvWindows(this.targetMpvSocketPath));
this.pollMpvWindows = deps.pollMpvWindows ?? defaultPollMpvWindows;
this.queryMpvProcessId = deps.queryMpvProcessId ?? queryMpvProcessId;
this.maxConsecutiveMisses = Math.max(1, Math.floor(deps.maxConsecutiveMisses ?? 2));
this.trackingLossGraceMs = Math.max(0, Math.floor(deps.trackingLossGraceMs ?? 1_500));
this.minimizedTrackingLossGraceMs = Math.max(
@@ -86,6 +99,16 @@ export class WindowsWindowTracker extends BaseWindowTracker {
return this.currentTargetWindowHwnd;
}
getDiagnosticState() {
return {
socketPath: this.targetMpvSocketPath,
targetPid: this.targetMpvPid,
targetHwnd: this.currentTargetWindowHwnd,
pidLookupPending: this.pidLookupInFlight,
pidAgeMs: this.lastPidConfirmedAtMs === null ? null : this.now() - this.lastPidConfirmedAtMs,
};
}
private maybeLogPollError(error: Error): void {
const now = Date.now();
const fingerprint = error.message;
@@ -142,12 +165,79 @@ export class WindowsWindowTracker extends BaseWindowTracker {
};
}
// Asks the mpv on the target socket which process it is. Runs in the background so a slow
// answer never stalls the poll, which shares the main thread with the mouse hook.
private refreshTargetMpvPid(socketPath: string, intervalMs = MPV_PID_LOOKUP_INTERVAL_MS): void {
const nowMs = this.now();
if (
this.pidLookupInFlight ||
(this.lastPidLookupAtMs !== null && nowMs - this.lastPidLookupAtMs < intervalMs)
) {
return;
}
this.pidLookupInFlight = true;
this.lastPidLookupAtMs = nowMs;
this.queryMpvProcessId(socketPath)
.then(
(pid) => {
this.targetMpvPid = pid;
this.lastPidConfirmedAtMs = this.now();
if (this.pidLookupFailureLogged) {
log.warn('Windows mpv PID lookup recovered', this.getDiagnosticState());
}
this.pidLookupFailureLogged = false;
},
(error: unknown) => {
// The overlay stays hidden until mpv is identified, so leave a trace of why it isn't.
if (this.pidLookupFailureLogged) return;
this.pidLookupFailureLogged = true;
log.warn('Windows mpv PID lookup failed; overlay attachment may be unavailable', {
...this.getDiagnosticState(),
error: error instanceof Error ? error.message : String(error),
});
},
)
.finally(() => {
this.pidLookupInFlight = false;
});
}
private pollTargetWindows(): MpvPollResult {
const socketPath = this.targetMpvSocketPath;
if (!socketPath) return this.pollMpvWindows(null);
// Windows can reuse a PID for an unrelated visible window. A cached match must
// remain tied to the process answering on our socket, even while it is visible.
if (
this.lastPidConfirmedAtMs !== null &&
this.now() - this.lastPidConfirmedAtMs >= MPV_PID_MAX_AGE_MS
) {
this.targetMpvPid = null;
}
if (this.targetMpvPid === null) {
this.refreshTargetMpvPid(socketPath);
return NO_MPV_WINDOW;
}
const result = this.pollMpvWindows(this.targetMpvPid);
// A new mpv may have taken over the socket after the known process exited.
this.refreshTargetMpvPid(
socketPath,
result.windowState === 'not-found'
? MPV_PID_LOOKUP_INTERVAL_MS
: MPV_PID_REVALIDATE_INTERVAL_MS,
);
return result;
}
private pollGeometry(): void {
if (this.pollInFlight) return;
this.pollInFlight = true;
try {
const result = this.pollMpvWindows();
const result = this.pollTargetWindows();
const best = this.selectBestMatch(result);
if (best) {