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

@@ -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');
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<typeof setInterval> | 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 {

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', () => {
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;

View File

@@ -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<void> {
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);