mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-11-10 04:46:07 -08:00
Compare commits
2
Commits
0360270e81
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0c1865f4a6
|
||
|
|
6fd55053d4 |
No files matched your search
@@ -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.
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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());
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
@@ -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',
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user