/* 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 { execFile } from 'child_process'; import { BaseWindowTracker } from './base-tracker'; type CommandRunner = (command: string, args: string[]) => Promise; export type ScreenToDipPoint = (point: { x: number; y: number }) => { x: number; y: number }; const preservePoint: ScreenToDipPoint = (point) => point; function execFileUtf8(command: string, args: string[]): Promise { return new Promise((resolve, reject) => { execFile(command, args, { encoding: 'utf-8' }, (error, stdout) => { if (error) { reject(error); return; } resolve(stdout); }); }); } export function parseX11WindowGeometry(winInfo: string): { x: number; y: number; 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 widthMatch = winInfo.match(/Width:\s*(\d+)/); const heightMatch = winInfo.match(/Height:\s*(\d+)/); if (!xMatch || !yMatch || !widthMatch || !heightMatch) { return null; } return { x: parseInt(xMatch[1]!, 10), y: parseInt(yMatch[1]!, 10), width: parseInt(widthMatch[1]!, 10), height: parseInt(heightMatch[1]!, 10), }; } export function parseX11WindowPid(raw: string): number | null { const pidMatch = raw.match(/= (\d+)/); if (!pidMatch) { return null; } const pid = Number.parseInt(pidMatch[1]!, 10); return Number.isInteger(pid) ? pid : null; } export function normalizeX11WindowId(raw: string): string | null { const trimmed = raw.trim(); if (!trimmed) { return null; } try { return BigInt(trimmed).toString(); } catch { return null; } } export function parseX11RootActiveWindowId(raw: string): string | null { const match = raw.match(/window id #\s*(\S+)/i); if (!match) { return null; } return normalizeX11WindowId(match[1]!); } export class X11WindowTracker extends BaseWindowTracker { private pollInterval: ReturnType | null = null; private readonly targetMpvSocketPath: string | null; private readonly runCommand: CommandRunner; private readonly screenToDipPoint: ScreenToDipPoint; private targetWindowId: string | null = null; private targetWindowPid: number | null = null; private pollInFlight = false; private currentPollIntervalMs = 750; private readonly stablePollIntervalMs = 250; constructor( targetMpvSocketPath?: string, runCommand: CommandRunner = execFileUtf8, screenToDipPoint: ScreenToDipPoint = preservePoint, ) { super(); this.targetMpvSocketPath = targetMpvSocketPath?.trim() || null; this.runCommand = runCommand; this.screenToDipPoint = screenToDipPoint; } start(): void { this.resetPollInterval(this.currentPollIntervalMs); this.pollGeometry(); } stop(): void { if (this.pollInterval) { clearInterval(this.pollInterval); this.pollInterval = null; } } override getTargetWindowMediaSourceId(): string | null { const normalizedWindowId = this.targetWindowId ? normalizeX11WindowId(this.targetWindowId) : null; return normalizedWindowId ? `window:${normalizedWindowId}:0` : null; } override getTargetWindowNativeId(): string | null { return this.targetWindowId ? normalizeX11WindowId(this.targetWindowId) : null; } override async raiseTargetWindow(): Promise { const targetWindowId = this.targetWindowId; if (!targetWindowId) { return false; } let raised = false; try { await this.runCommand('xdotool', ['windowactivate', targetWindowId]); raised = true; } catch { // Some WMs reject activation but accept a plain restack below. } try { await this.runCommand('xdotool', ['windowraise', targetWindowId]); raised = true; } catch { // Keep any successful activation result. } return raised; } private resetPollInterval(intervalMs: number): void { if (this.pollInterval) { clearInterval(this.pollInterval); this.pollInterval = null; } this.pollInterval = setInterval(() => this.pollGeometry(), intervalMs); } private pollGeometry(): void { if (this.pollInFlight) { return; } this.pollInFlight = true; void this.pollGeometryAsync() .catch(() => { this.updateGeometry(null); }) .finally(() => { this.pollInFlight = false; }); } private async pollGeometryAsync(): Promise { const windowIdsOutput = await this.runCommand('xdotool', [ 'search', '--onlyvisible', '--class', 'mpv', ]); const windowIds = windowIdsOutput.trim(); if (!windowIds) { this.updateGeometry(null); return; } const windowIdList = windowIds.split(/\s+/).filter(Boolean); if (windowIdList.length === 0) { this.updateGeometry(null); return; } const windowId = await this.findTargetWindowId(windowIdList); if (!windowId) { this.targetWindowId = null; this.targetWindowPid = null; this.updateGeometry(null); return; } this.targetWindowId = windowId; const targetPid = this.targetWindowPid ?? (await this.getWindowPid(windowId)); this.targetWindowPid = targetPid; const winInfo = await this.runCommand('xwininfo', ['-id', windowId]); const physicalGeometry = parseX11WindowGeometry(winInfo); if (!physicalGeometry) { this.updateGeometry(null); return; } const topLeft = this.screenToDipPoint({ x: physicalGeometry.x, y: physicalGeometry.y, }); const bottomRight = this.screenToDipPoint({ x: physicalGeometry.x + physicalGeometry.width, y: physicalGeometry.y + physicalGeometry.height, }); const geometry = { x: topLeft.x, y: topLeft.y, width: bottomRight.x - topLeft.x, height: bottomRight.y - topLeft.y, }; const focused = await this.isWindowActive(windowId, targetPid); this.updateGeometry(geometry, focused); this.updateTargetWindowFocused(focused); if (this.pollInterval && this.currentPollIntervalMs !== this.stablePollIntervalMs) { this.currentPollIntervalMs = this.stablePollIntervalMs; this.resetPollInterval(this.currentPollIntervalMs); } } private async findTargetWindowId(windowIds: string[]): Promise { this.targetWindowId = null; this.targetWindowPid = null; if (!this.targetMpvSocketPath) { const windowId = windowIds[0] ?? null; if (windowId) { this.targetWindowPid = await this.getWindowPid(windowId); } return windowId; } for (const windowId of windowIds) { const pid = await this.getTargetSocketWindowPid(windowId); if (pid !== null) { this.targetWindowPid = pid; return windowId; } } return null; } private async getTargetSocketWindowPid(windowId: string): Promise { const pid = await this.getWindowPid(windowId); if (pid === null) { return null; } const commandLine = await this.getWindowCommandLine(pid); if (!commandLine) { return null; } const matchesTargetSocket = commandLine.includes(`--input-ipc-server=${this.targetMpvSocketPath}`) || commandLine.includes(`--input-ipc-server ${this.targetMpvSocketPath}`); return matchesTargetSocket ? pid : null; } private async isWindowActive(windowId: string, targetPid: number | null): Promise { const activeWindowId = await this.getX11ActiveWindowId(); if (!activeWindowId) { return true; } const normalizedTarget = normalizeX11WindowId(windowId); const normalizedActive = normalizeX11WindowId(activeWindowId); if (!normalizedTarget || !normalizedActive) { return true; } if (targetPid !== null) { const activePid = await this.getWindowPid(normalizedActive); if (activePid !== null) { return activePid === targetPid; } } return normalizedTarget === normalizedActive; } private async getX11ActiveWindowId(): Promise { try { const rootActiveWindow = parseX11RootActiveWindowId( await this.runCommand('xprop', ['-root', '_NET_ACTIVE_WINDOW']), ); if (rootActiveWindow) { return rootActiveWindow; } } catch { // Fall back below. Some minimal WMs do not expose _NET_ACTIVE_WINDOW. } try { return normalizeX11WindowId(await this.runCommand('xdotool', ['getactivewindow'])); } catch { return null; } } private async getWindowPid(windowId: string): Promise { let windowPid: string; try { windowPid = await this.runCommand('xprop', ['-id', windowId, '_NET_WM_PID']); } catch { return null; } return parseX11WindowPid(windowPid); } private async getWindowCommandLine(pid: number): Promise { let raw: string; try { raw = await this.runCommand('ps', ['-p', String(pid), '-o', 'args=']); } catch { return null; } const commandLine = raw.trim(); return commandLine || null; } }