fix(tracker): follow active hyprland and visible x11 windows

This commit is contained in:
2026-03-08 23:03:55 -07:00
parent 2127f759ca
commit a0521aeeaf
5 changed files with 222 additions and 39 deletions

View File

@@ -30,8 +30,8 @@
"test:plugin:src": "lua scripts/test-plugin-start-gate.lua && lua scripts/test-plugin-binary-windows.lua", "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: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: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: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/x11-tracker.test.js dist/window-trackers/windows-helper.test.js dist/window-trackers/windows-tracker.test.js", "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: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: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", "test:subtitle:src": "bun test src/core/services/subsync.test.ts src/subsync/utils.test.ts",

View File

@@ -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> = {}): 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<string, string>([
['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');
});

View File

@@ -23,17 +23,77 @@ import { createLogger } from '../logger';
const log = createLogger('tracker').child('hyprland'); const log = createLogger('tracker').child('hyprland');
interface HyprlandClient { export interface HyprlandClient {
address?: string;
class: string; class: string;
at: [number, number]; at: [number, number];
size: [number, number]; size: [number, number];
pid?: 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 { export class HyprlandWindowTracker extends BaseWindowTracker {
private pollInterval: ReturnType<typeof setInterval> | null = null; private pollInterval: ReturnType<typeof setInterval> | null = null;
private eventSocket: net.Socket | null = null; private eventSocket: net.Socket | null = null;
private readonly targetMpvSocketPath: string | null; private readonly targetMpvSocketPath: string | null;
private activeWindowAddress: string | null = null;
constructor(targetMpvSocketPath?: string) { constructor(targetMpvSocketPath?: string) {
super(); super();
@@ -75,15 +135,7 @@ export class HyprlandWindowTracker extends BaseWindowTracker {
this.eventSocket.on('data', (data: Buffer) => { this.eventSocket.on('data', (data: Buffer) => {
const events = data.toString().split('\n'); const events = data.toString().split('\n');
for (const event of events) { for (const event of events) {
if ( this.handleSocketEvent(event);
event.includes('movewindow') ||
event.includes('windowtitle') ||
event.includes('openwindow') ||
event.includes('closewindow') ||
event.includes('fullscreen')
) {
this.pollGeometry();
}
} }
}); });
@@ -98,6 +150,39 @@ export class HyprlandWindowTracker extends BaseWindowTracker {
this.eventSocket.connect(socketPath); 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 { private pollGeometry(): void {
try { try {
const output = execSync('hyprctl clients -j', { encoding: 'utf-8' }); const output = execSync('hyprctl clients -j', { encoding: 'utf-8' });
@@ -120,30 +205,11 @@ export class HyprlandWindowTracker extends BaseWindowTracker {
} }
private findTargetWindow(clients: HyprlandClient[]): HyprlandClient | null { private findTargetWindow(clients: HyprlandClient[]): HyprlandClient | null {
const mpvWindows = clients.filter((client) => client.class === 'mpv'); return selectHyprlandMpvWindow(clients, {
if (!this.targetMpvSocketPath) { targetMpvSocketPath: this.targetMpvSocketPath,
return mpvWindows[0] || null; activeWindowAddress: this.activeWindowAddress,
} getWindowCommandLine: (pid) => this.getWindowCommandLine(pid),
});
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;
} }
private getWindowCommandLine(pid: number): string | null { private getWindowCommandLine(pid: number): string | null {

View File

@@ -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', () => { test('parseX11WindowPid parses xprop output', () => {
assert.equal(parseX11WindowPid('_NET_WM_PID(CARDINAL) = 4242'), 4242); assert.equal(parseX11WindowPid('_NET_WM_PID(CARDINAL) = 4242'), 4242);
assert.equal(parseX11WindowPid('_NET_WM_PID(CARDINAL) = not-a-number'), null); 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 () => { test('X11WindowTracker skips overlapping polls while one command is in flight', async () => {
let commandCalls = 0; let commandCalls = 0;
let release: (() => void) | undefined; let release: (() => void) | undefined;

View File

@@ -39,8 +39,8 @@ export function parseX11WindowGeometry(winInfo: string): {
width: number; width: number;
height: number; height: number;
} | null { } | null {
const xMatch = winInfo.match(/Absolute upper-left X:\s*(\d+)/); const xMatch = winInfo.match(/Absolute upper-left X:\s*(-?\d+)/);
const yMatch = winInfo.match(/Absolute upper-left Y:\s*(\d+)/); const yMatch = winInfo.match(/Absolute upper-left Y:\s*(-?\d+)/);
const widthMatch = winInfo.match(/Width:\s*(\d+)/); const widthMatch = winInfo.match(/Width:\s*(\d+)/);
const heightMatch = winInfo.match(/Height:\s*(\d+)/); const heightMatch = winInfo.match(/Height:\s*(\d+)/);
if (!xMatch || !yMatch || !widthMatch || !heightMatch) { if (!xMatch || !yMatch || !widthMatch || !heightMatch) {
@@ -112,7 +112,12 @@ export class X11WindowTracker extends BaseWindowTracker {
} }
private async pollGeometryAsync(): Promise<void> { private async pollGeometryAsync(): Promise<void> {
const windowIdsOutput = await this.runCommand('xdotool', ['search', '--class', 'mpv']); const windowIdsOutput = await this.runCommand('xdotool', [
'search',
'--onlyvisible',
'--class',
'mpv',
]);
const windowIds = windowIdsOutput.trim(); const windowIds = windowIdsOutput.trim();
if (!windowIds) { if (!windowIds) {
this.updateGeometry(null); this.updateGeometry(null);