/* 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 * as net from 'net'; import { execSync } from 'child_process'; import { BaseWindowTracker } from './base-tracker'; import { createLogger } from '../logger'; const log = createLogger('tracker').child('hyprland'); interface HyprlandClient { class: string; at: [number, number]; size: [number, number]; pid?: number; } export class HyprlandWindowTracker extends BaseWindowTracker { private pollInterval: ReturnType | null = null; private eventSocket: net.Socket | null = null; private readonly targetMpvSocketPath: string | null; constructor(targetMpvSocketPath?: string) { super(); this.targetMpvSocketPath = targetMpvSocketPath?.trim() || null; } start(): void { this.pollInterval = setInterval(() => this.pollGeometry(), 250); this.pollGeometry(); this.connectEventSocket(); } stop(): void { if (this.pollInterval) { clearInterval(this.pollInterval); this.pollInterval = null; } if (this.eventSocket) { this.eventSocket.destroy(); this.eventSocket = null; } } private connectEventSocket(): void { const hyprlandSig = process.env.HYPRLAND_INSTANCE_SIGNATURE; if (!hyprlandSig) { log.info('HYPRLAND_INSTANCE_SIGNATURE not set, skipping event socket'); return; } const xdgRuntime = process.env.XDG_RUNTIME_DIR || '/tmp'; const socketPath = `${xdgRuntime}/hypr/${hyprlandSig}/.socket2.sock`; this.eventSocket = new net.Socket(); this.eventSocket.on('connect', () => { log.info('Connected to Hyprland event socket'); }); 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.eventSocket.on('error', (err: Error) => { log.error('Hyprland event socket error:', err.message); }); this.eventSocket.on('close', () => { log.info('Hyprland event socket closed'); }); this.eventSocket.connect(socketPath); } private pollGeometry(): void { try { const output = execSync('hyprctl clients -j', { encoding: 'utf-8' }); const clients: HyprlandClient[] = JSON.parse(output); const mpvWindow = this.findTargetWindow(clients); if (mpvWindow) { this.updateGeometry({ x: mpvWindow.at[0], y: mpvWindow.at[1], width: mpvWindow.size[0], height: mpvWindow.size[1], }); } else { this.updateGeometry(null); } } catch (err) { // hyprctl not available or failed - silent fail } } 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; } private getWindowCommandLine(pid: number): string | null { const commandLine = execSync(`ps -p ${pid} -o args=`, { encoding: 'utf-8', }).trim(); return commandLine || null; } }