mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-03-20 12:11:28 -07:00
fix(tracker): follow active hyprland and visible x11 windows
This commit is contained in:
72
src/window-trackers/hyprland-tracker.test.ts
Normal file
72
src/window-trackers/hyprland-tracker.test.ts
Normal 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');
|
||||
});
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user