diff --git a/package.json b/package.json index c876864..ccf6735 100644 --- a/package.json +++ b/package.json @@ -30,8 +30,8 @@ "test:plugin:src": "lua scripts/test-plugin-start-gate.lua && lua scripts/test-plugin-binary-windows.lua", "test:launcher:smoke:src": "bun test launcher/smoke.e2e.test.ts", "test:launcher:src": "bun test launcher/config.test.ts launcher/config-domain-parsers.test.ts launcher/mpv.test.ts launcher/parse-args.test.ts launcher/main.test.ts launcher/commands/command-modules.test.ts launcher/smoke.e2e.test.ts && bun run test:plugin:src", - "test:core:src": "bun test src/cli/args.test.ts src/cli/help.test.ts src/shared/setup-state.test.ts src/core/services/cli-command.test.ts src/core/services/field-grouping-overlay.test.ts src/core/services/numeric-shortcut-session.test.ts src/core/services/secondary-subtitle.test.ts src/core/services/mpv-render-metrics.test.ts src/core/services/overlay-content-measurement.test.ts src/core/services/mpv-control.test.ts src/core/services/mpv.test.ts src/core/services/runtime-options-ipc.test.ts src/core/services/runtime-config.test.ts src/core/services/yomitan-extension-paths.test.ts src/core/services/config-hot-reload.test.ts src/core/services/discord-presence.test.ts src/core/services/tokenizer.test.ts src/core/services/tokenizer/annotation-stage.test.ts src/core/services/tokenizer/parser-selection-stage.test.ts src/core/services/tokenizer/parser-enrichment-stage.test.ts src/core/services/subsync.test.ts src/core/services/overlay-bridge.test.ts src/core/services/overlay-shortcut-handler.test.ts src/core/services/mining.test.ts src/core/services/anki-jimaku.test.ts src/core/services/jimaku-download-path.test.ts src/core/services/jellyfin.test.ts src/core/services/jellyfin-remote.test.ts src/core/services/immersion-tracker-service.test.ts src/core/services/overlay-runtime-init.test.ts src/core/services/app-ready.test.ts src/core/services/startup-bootstrap.test.ts src/core/services/subtitle-processing-controller.test.ts src/core/services/anilist/anilist-update-queue.test.ts src/core/utils/shortcut-config.test.ts src/main/runtime/first-run-setup-plugin.test.ts src/main/runtime/first-run-setup-service.test.ts src/main/runtime/first-run-setup-window.test.ts src/main/runtime/tray-runtime.test.ts src/main/runtime/tray-main-actions.test.ts src/main/runtime/tray-main-deps.test.ts src/main/runtime/tray-runtime-handlers.test.ts src/main/runtime/cli-command-context-main-deps.test.ts src/main/runtime/app-ready-main-deps.test.ts src/renderer/error-recovery.test.ts src/renderer/subtitle-render.test.ts src/renderer/handlers/mouse.test.ts src/renderer/handlers/keyboard.test.ts src/renderer/modals/jimaku.test.ts src/subsync/utils.test.ts src/main/anilist-url-guard.test.ts src/window-trackers/x11-tracker.test.ts src/window-trackers/windows-helper.test.ts src/window-trackers/windows-tracker.test.ts launcher/config.test.ts launcher/config-domain-parsers.test.ts launcher/parse-args.test.ts launcher/main.test.ts launcher/commands/command-modules.test.ts launcher/setup-gate.test.ts", - "test:core:dist": "bun test dist/cli/args.test.js dist/cli/help.test.js dist/core/services/cli-command.test.js dist/core/services/ipc.test.js dist/core/services/anki-jimaku-ipc.test.js dist/core/services/field-grouping-overlay.test.js dist/core/services/numeric-shortcut-session.test.js dist/core/services/secondary-subtitle.test.js dist/core/services/mpv-render-metrics.test.js dist/core/services/overlay-content-measurement.test.js dist/core/services/mpv-control.test.js dist/core/services/mpv.test.js dist/core/services/runtime-options-ipc.test.js dist/core/services/runtime-config.test.js dist/core/services/yomitan-extension-paths.test.js dist/core/services/config-hot-reload.test.js dist/core/services/discord-presence.test.js dist/core/services/tokenizer.test.js dist/core/services/tokenizer/annotation-stage.test.js dist/core/services/tokenizer/parser-selection-stage.test.js dist/core/services/tokenizer/parser-enrichment-stage.test.js dist/core/services/subsync.test.js dist/core/services/overlay-bridge.test.js dist/core/services/overlay-manager.test.js dist/core/services/overlay-shortcut-handler.test.js dist/core/services/mining.test.js dist/core/services/anki-jimaku.test.js dist/core/services/jimaku-download-path.test.js dist/core/services/jellyfin.test.js dist/core/services/jellyfin-remote.test.js dist/core/services/immersion-tracker-service.test.js dist/core/services/overlay-runtime-init.test.js dist/core/services/app-ready.test.js dist/core/services/startup-bootstrap.test.js dist/core/services/subtitle-processing-controller.test.js dist/core/services/anilist/anilist-token-store.test.js dist/core/services/anilist/anilist-update-queue.test.js dist/renderer/error-recovery.test.js dist/renderer/subtitle-render.test.js dist/renderer/handlers/mouse.test.js dist/renderer/handlers/keyboard.test.js dist/renderer/modals/jimaku.test.js dist/subsync/utils.test.js dist/main/anilist-url-guard.test.js dist/window-trackers/x11-tracker.test.js dist/window-trackers/windows-helper.test.js dist/window-trackers/windows-tracker.test.js", + "test:core:src": "bun test src/cli/args.test.ts src/cli/help.test.ts src/shared/setup-state.test.ts src/core/services/cli-command.test.ts src/core/services/field-grouping-overlay.test.ts src/core/services/numeric-shortcut-session.test.ts src/core/services/secondary-subtitle.test.ts src/core/services/mpv-render-metrics.test.ts src/core/services/overlay-content-measurement.test.ts src/core/services/mpv-control.test.ts src/core/services/mpv.test.ts src/core/services/runtime-options-ipc.test.ts src/core/services/runtime-config.test.ts src/core/services/yomitan-extension-paths.test.ts src/core/services/config-hot-reload.test.ts src/core/services/discord-presence.test.ts src/core/services/tokenizer.test.ts src/core/services/tokenizer/annotation-stage.test.ts src/core/services/tokenizer/parser-selection-stage.test.ts src/core/services/tokenizer/parser-enrichment-stage.test.ts src/core/services/subsync.test.ts src/core/services/overlay-bridge.test.ts src/core/services/overlay-shortcut-handler.test.ts src/core/services/mining.test.ts src/core/services/anki-jimaku.test.ts src/core/services/jimaku-download-path.test.ts src/core/services/jellyfin.test.ts src/core/services/jellyfin-remote.test.ts src/core/services/immersion-tracker-service.test.ts src/core/services/overlay-runtime-init.test.ts src/core/services/app-ready.test.ts src/core/services/startup-bootstrap.test.ts src/core/services/subtitle-processing-controller.test.ts src/core/services/anilist/anilist-update-queue.test.ts src/core/utils/shortcut-config.test.ts src/main/runtime/first-run-setup-plugin.test.ts src/main/runtime/first-run-setup-service.test.ts src/main/runtime/first-run-setup-window.test.ts src/main/runtime/tray-runtime.test.ts src/main/runtime/tray-main-actions.test.ts src/main/runtime/tray-main-deps.test.ts src/main/runtime/tray-runtime-handlers.test.ts src/main/runtime/cli-command-context-main-deps.test.ts src/main/runtime/app-ready-main-deps.test.ts src/renderer/error-recovery.test.ts src/renderer/subtitle-render.test.ts src/renderer/handlers/mouse.test.ts src/renderer/handlers/keyboard.test.ts src/renderer/modals/jimaku.test.ts src/subsync/utils.test.ts src/main/anilist-url-guard.test.ts src/window-trackers/hyprland-tracker.test.ts src/window-trackers/x11-tracker.test.ts src/window-trackers/windows-helper.test.ts src/window-trackers/windows-tracker.test.ts launcher/config.test.ts launcher/config-domain-parsers.test.ts launcher/parse-args.test.ts launcher/main.test.ts launcher/commands/command-modules.test.ts launcher/setup-gate.test.ts", + "test:core:dist": "bun test dist/cli/args.test.js dist/cli/help.test.js dist/core/services/cli-command.test.js dist/core/services/ipc.test.js dist/core/services/anki-jimaku-ipc.test.js dist/core/services/field-grouping-overlay.test.js dist/core/services/numeric-shortcut-session.test.js dist/core/services/secondary-subtitle.test.js dist/core/services/mpv-render-metrics.test.js dist/core/services/overlay-content-measurement.test.js dist/core/services/mpv-control.test.js dist/core/services/mpv.test.js dist/core/services/runtime-options-ipc.test.js dist/core/services/runtime-config.test.js dist/core/services/yomitan-extension-paths.test.js dist/core/services/config-hot-reload.test.js dist/core/services/discord-presence.test.js dist/core/services/tokenizer.test.js dist/core/services/tokenizer/annotation-stage.test.js dist/core/services/tokenizer/parser-selection-stage.test.js dist/core/services/tokenizer/parser-enrichment-stage.test.js dist/core/services/subsync.test.js dist/core/services/overlay-bridge.test.js dist/core/services/overlay-manager.test.js dist/core/services/overlay-shortcut-handler.test.js dist/core/services/mining.test.js dist/core/services/anki-jimaku.test.js dist/core/services/jimaku-download-path.test.js dist/core/services/jellyfin.test.js dist/core/services/jellyfin-remote.test.js dist/core/services/immersion-tracker-service.test.js dist/core/services/overlay-runtime-init.test.js dist/core/services/app-ready.test.js dist/core/services/startup-bootstrap.test.js dist/core/services/subtitle-processing-controller.test.js dist/core/services/anilist/anilist-token-store.test.js dist/core/services/anilist/anilist-update-queue.test.js dist/renderer/error-recovery.test.js dist/renderer/subtitle-render.test.js dist/renderer/handlers/mouse.test.js dist/renderer/handlers/keyboard.test.js dist/renderer/modals/jimaku.test.js dist/subsync/utils.test.js dist/main/anilist-url-guard.test.js dist/window-trackers/hyprland-tracker.test.js dist/window-trackers/x11-tracker.test.js dist/window-trackers/windows-helper.test.js dist/window-trackers/windows-tracker.test.js", "test:core:smoke:dist": "bun test dist/cli/help.test.js dist/core/services/runtime-config.test.js dist/core/services/ipc.test.js dist/core/services/overlay-manager.test.js dist/core/services/anilist/anilist-token-store.test.js dist/core/services/startup-bootstrap.test.js dist/renderer/error-recovery.test.js dist/main/anilist-url-guard.test.js dist/window-trackers/x11-tracker.test.js", "test:smoke:dist": "bun run test:config:smoke:dist && bun run test:core:smoke:dist", "test:subtitle:src": "bun test src/core/services/subsync.test.ts src/subsync/utils.test.ts", diff --git a/src/window-trackers/hyprland-tracker.test.ts b/src/window-trackers/hyprland-tracker.test.ts new file mode 100644 index 0000000..bc59306 --- /dev/null +++ b/src/window-trackers/hyprland-tracker.test.ts @@ -0,0 +1,72 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + selectHyprlandMpvWindow, + type HyprlandClient, +} from './hyprland-tracker'; + +function makeClient(overrides: Partial = {}): HyprlandClient { + return { + address: '0x1', + class: 'mpv', + at: [0, 0], + size: [1280, 720], + mapped: true, + hidden: false, + ...overrides, + }; +} + +test('selectHyprlandMpvWindow ignores hidden and unmapped mpv clients', () => { + const selected = selectHyprlandMpvWindow( + [ + makeClient({ + address: '0xhidden', + hidden: true, + }), + makeClient({ + address: '0xunmapped', + mapped: false, + }), + makeClient({ + address: '0xvisible', + at: [100, 200], + size: [1920, 1080], + }), + ], + { + targetMpvSocketPath: null, + activeWindowAddress: null, + getWindowCommandLine: () => null, + }, + ); + + assert.equal(selected?.address, '0xvisible'); +}); + +test('selectHyprlandMpvWindow prefers active visible window among socket matches', () => { + const commandLines = new Map([ + ['10', 'mpv --input-ipc-server=/tmp/subminer.sock first.mkv'], + ['20', 'mpv --input-ipc-server=/tmp/subminer.sock second.mkv'], + ]); + + const selected = selectHyprlandMpvWindow( + [ + makeClient({ + address: '0xfirst', + pid: 10, + }), + makeClient({ + address: '0xsecond', + pid: 20, + }), + ], + { + targetMpvSocketPath: '/tmp/subminer.sock', + activeWindowAddress: '0xsecond', + getWindowCommandLine: (pid) => commandLines.get(String(pid)) ?? null, + }, + ); + + assert.equal(selected?.address, '0xsecond'); +}); diff --git a/src/window-trackers/hyprland-tracker.ts b/src/window-trackers/hyprland-tracker.ts index e8ef904..91af296 100644 --- a/src/window-trackers/hyprland-tracker.ts +++ b/src/window-trackers/hyprland-tracker.ts @@ -23,17 +23,77 @@ import { createLogger } from '../logger'; const log = createLogger('tracker').child('hyprland'); -interface HyprlandClient { +export interface HyprlandClient { + address?: string; class: string; at: [number, number]; size: [number, number]; pid?: number; + mapped?: boolean; + hidden?: boolean; +} + +interface SelectHyprlandMpvWindowOptions { + targetMpvSocketPath: string | null; + activeWindowAddress: string | null; + getWindowCommandLine: (pid: number) => string | null; +} + +function matchesTargetSocket(commandLine: string, targetMpvSocketPath: string): boolean { + return ( + commandLine.includes(`--input-ipc-server=${targetMpvSocketPath}`) || + commandLine.includes(`--input-ipc-server ${targetMpvSocketPath}`) + ); +} + +function preferActiveHyprlandWindow( + clients: HyprlandClient[], + activeWindowAddress: string | null, +): HyprlandClient | null { + if (activeWindowAddress) { + const activeClient = clients.find((client) => client.address === activeWindowAddress); + if (activeClient) { + return activeClient; + } + } + + return clients[0] ?? null; +} + +export function selectHyprlandMpvWindow( + clients: HyprlandClient[], + options: SelectHyprlandMpvWindowOptions, +): HyprlandClient | null { + const visibleMpvWindows = clients.filter( + (client) => client.class === 'mpv' && client.mapped !== false && client.hidden !== true, + ); + + if (!options.targetMpvSocketPath) { + return preferActiveHyprlandWindow(visibleMpvWindows, options.activeWindowAddress); + } + const targetMpvSocketPath = options.targetMpvSocketPath; + + const matchingWindows = visibleMpvWindows.filter((client) => { + if (!client.pid) { + return false; + } + + const commandLine = options.getWindowCommandLine(client.pid); + if (!commandLine) { + return false; + } + + return matchesTargetSocket(commandLine, targetMpvSocketPath); + }); + + return preferActiveHyprlandWindow(matchingWindows, options.activeWindowAddress); } export class HyprlandWindowTracker extends BaseWindowTracker { private pollInterval: ReturnType | null = null; private eventSocket: net.Socket | null = null; private readonly targetMpvSocketPath: string | null; + private activeWindowAddress: string | null = null; constructor(targetMpvSocketPath?: string) { super(); @@ -75,15 +135,7 @@ export class HyprlandWindowTracker extends BaseWindowTracker { this.eventSocket.on('data', (data: Buffer) => { const events = data.toString().split('\n'); for (const event of events) { - if ( - event.includes('movewindow') || - event.includes('windowtitle') || - event.includes('openwindow') || - event.includes('closewindow') || - event.includes('fullscreen') - ) { - this.pollGeometry(); - } + this.handleSocketEvent(event); } }); @@ -98,6 +150,39 @@ export class HyprlandWindowTracker extends BaseWindowTracker { this.eventSocket.connect(socketPath); } + private handleSocketEvent(event: string): void { + const trimmedEvent = event.trim(); + if (!trimmedEvent) { + return; + } + + const [name, rawData = ''] = trimmedEvent.split('>>', 2); + const data = rawData.trim(); + + if (name === 'activewindowv2') { + this.activeWindowAddress = data || null; + this.pollGeometry(); + return; + } + + if (name === 'closewindow' && data === this.activeWindowAddress) { + this.activeWindowAddress = null; + } + + if ( + name === 'movewindow' || + name === 'movewindowv2' || + name === 'windowtitle' || + name === 'windowtitlev2' || + name === 'openwindow' || + name === 'closewindow' || + name === 'fullscreen' || + name === 'changefloatingmode' + ) { + this.pollGeometry(); + } + } + private pollGeometry(): void { try { const output = execSync('hyprctl clients -j', { encoding: 'utf-8' }); @@ -120,30 +205,11 @@ export class HyprlandWindowTracker extends BaseWindowTracker { } private findTargetWindow(clients: HyprlandClient[]): HyprlandClient | null { - const mpvWindows = clients.filter((client) => client.class === 'mpv'); - if (!this.targetMpvSocketPath) { - return mpvWindows[0] || null; - } - - for (const mpvWindow of mpvWindows) { - if (!mpvWindow.pid) { - continue; - } - - const commandLine = this.getWindowCommandLine(mpvWindow.pid); - if (!commandLine) { - continue; - } - - if ( - commandLine.includes(`--input-ipc-server=${this.targetMpvSocketPath}`) || - commandLine.includes(`--input-ipc-server ${this.targetMpvSocketPath}`) - ) { - return mpvWindow; - } - } - - return null; + return selectHyprlandMpvWindow(clients, { + targetMpvSocketPath: this.targetMpvSocketPath, + activeWindowAddress: this.activeWindowAddress, + getWindowCommandLine: (pid) => this.getWindowCommandLine(pid), + }); } private getWindowCommandLine(pid: number): string | null { diff --git a/src/window-trackers/x11-tracker.test.ts b/src/window-trackers/x11-tracker.test.ts index 855e278..9ce907d 100644 --- a/src/window-trackers/x11-tracker.test.ts +++ b/src/window-trackers/x11-tracker.test.ts @@ -18,11 +18,51 @@ Height: 720 }); }); +test('parseX11WindowGeometry preserves negative coordinates', () => { + const geometry = parseX11WindowGeometry(` +Absolute upper-left X: -1920 +Absolute upper-left Y: -24 +Width: 1920 +Height: 1080 +`); + assert.deepEqual(geometry, { + x: -1920, + y: -24, + width: 1920, + height: 1080, + }); +}); + test('parseX11WindowPid parses xprop output', () => { assert.equal(parseX11WindowPid('_NET_WM_PID(CARDINAL) = 4242'), 4242); assert.equal(parseX11WindowPid('_NET_WM_PID(CARDINAL) = not-a-number'), null); }); +test('X11WindowTracker searches only visible mpv windows', async () => { + const commands: Array<{ command: string; args: string[] }> = []; + const tracker = new X11WindowTracker(undefined, async (command, args) => { + commands.push({ command, args }); + if (command === 'xdotool') { + return '123'; + } + if (command === 'xwininfo') { + return `Absolute upper-left X: 0 +Absolute upper-left Y: 0 +Width: 640 +Height: 360`; + } + return ''; + }); + + (tracker as unknown as { pollGeometry: () => void }).pollGeometry(); + await new Promise((resolve) => setTimeout(resolve, 0)); + + assert.deepEqual(commands[0], { + command: 'xdotool', + args: ['search', '--onlyvisible', '--class', 'mpv'], + }); +}); + test('X11WindowTracker skips overlapping polls while one command is in flight', async () => { let commandCalls = 0; let release: (() => void) | undefined; diff --git a/src/window-trackers/x11-tracker.ts b/src/window-trackers/x11-tracker.ts index 43274d0..3b1eda3 100644 --- a/src/window-trackers/x11-tracker.ts +++ b/src/window-trackers/x11-tracker.ts @@ -39,8 +39,8 @@ export function parseX11WindowGeometry(winInfo: string): { width: number; height: number; } | null { - const xMatch = winInfo.match(/Absolute upper-left X:\s*(\d+)/); - const yMatch = winInfo.match(/Absolute upper-left Y:\s*(\d+)/); + const xMatch = winInfo.match(/Absolute upper-left X:\s*(-?\d+)/); + const yMatch = winInfo.match(/Absolute upper-left Y:\s*(-?\d+)/); const widthMatch = winInfo.match(/Width:\s*(\d+)/); const heightMatch = winInfo.match(/Height:\s*(\d+)/); if (!xMatch || !yMatch || !widthMatch || !heightMatch) { @@ -112,7 +112,12 @@ export class X11WindowTracker extends BaseWindowTracker { } private async pollGeometryAsync(): Promise { - const windowIdsOutput = await this.runCommand('xdotool', ['search', '--class', 'mpv']); + const windowIdsOutput = await this.runCommand('xdotool', [ + 'search', + '--onlyvisible', + '--class', + 'mpv', + ]); const windowIds = windowIdsOutput.trim(); if (!windowIds) { this.updateGeometry(null);