/*
SubMiner - All-in-one sentence mining overlay
Copyright (C) 2024 sudacode
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
import { BaseWindowTracker } from './base-tracker';
import {
createCoalescedTask,
execFileWithTimeout,
ProcessCommandLineCache,
type CommandRunner,
} from './linux-poll';
interface SwayRect {
x: number;
y: number;
width: number;
height: number;
}
interface SwayNode {
pid?: number;
app_id?: string;
window_properties?: { class?: string };
rect?: SwayRect;
nodes?: SwayNode[];
floating_nodes?: SwayNode[];
}
export class SwayWindowTracker extends BaseWindowTracker {
private pollInterval: ReturnType | null = null;
private readonly targetMpvSocketPath: string | null;
private readonly runCommand: CommandRunner;
private readonly commandLines: ProcessCommandLineCache;
private stopped = false;
private readonly pollGeometry = createCoalescedTask(() => this.pollGeometryOnce());
constructor(targetMpvSocketPath?: string, runCommand: CommandRunner = execFileWithTimeout) {
super();
this.targetMpvSocketPath = targetMpvSocketPath?.trim() || null;
this.runCommand = runCommand;
this.commandLines = new ProcessCommandLineCache(runCommand);
}
start(): void {
this.stopped = false;
this.pollInterval = setInterval(() => void this.pollGeometry(), 250);
void this.pollGeometry();
}
stop(): void {
this.stopped = true;
if (this.pollInterval) {
clearInterval(this.pollInterval);
this.pollInterval = null;
}
}
override refreshNow(): Promise {
return this.pollGeometry();
}
private collectMpvWindows(node: SwayNode): SwayNode[] {
const windows: SwayNode[] = [];
if (node.app_id === 'mpv' || node.window_properties?.class === 'mpv') {
windows.push(node);
}
if (node.nodes) {
for (const child of node.nodes) {
windows.push(...this.collectMpvWindows(child));
}
}
if (node.floating_nodes) {
for (const child of node.floating_nodes) {
windows.push(...this.collectMpvWindows(child));
}
}
return windows;
}
private async findTargetSocketWindow(node: SwayNode): Promise {
const windows = this.collectMpvWindows(node);
if (!this.targetMpvSocketPath) {
return windows[0] || null;
}
const commandLines = await this.commandLines.lookup(
windows.flatMap((window) => (window.pid ? [window.pid] : [])),
);
return (
windows.find((candidate) => {
const commandLine = candidate.pid ? commandLines.get(candidate.pid) : undefined;
return commandLine !== undefined && this.matchesTargetSocket(commandLine);
}) || null
);
}
private matchesTargetSocket(commandLine: string): boolean {
return (
commandLine.includes(`--input-ipc-server=${this.targetMpvSocketPath}`) ||
commandLine.includes(`--input-ipc-server ${this.targetMpvSocketPath}`)
);
}
private async pollGeometryOnce(): Promise {
if (this.stopped) {
return;
}
let mpvWindow: SwayNode | null;
try {
const tree = JSON.parse(await this.runCommand('swaymsg', ['-t', 'get_tree'])) as SwayNode;
mpvWindow = await this.findTargetSocketWindow(tree);
} catch {
// swaymsg/ps not available, timed out, or failed - keep the last geometry
return;
}
if (this.stopped) {
return;
}
if (mpvWindow && mpvWindow.rect) {
this.updateGeometry({
x: mpvWindow.rect.x,
y: mpvWindow.rect.y,
width: mpvWindow.rect.width,
height: mpvWindow.rect.height,
});
} else {
this.updateGeometry(null);
}
}
}