import { execFile } from 'child_process'; import * as fs from 'fs'; import * as path from 'path'; import { promisify } from 'util'; const execFileAsync = promisify(execFile); /** * Structured snapshot of the currently focused window. Returned by * detectActiveWindow() and consumed by the game-detection orchestrator. * * Field availability varies by platform/compositor; consumers must treat all * optional fields as best-effort. `processName` is required because the * heuristic engine refuses to score a candidate without it. */ export interface ActiveWindowSnapshot { processName: string; executablePath?: string; windowTitle?: string; pid?: number; bounds?: { width: number; height: number }; isFullscreen?: boolean; /** Where the snapshot came from, for diagnostics. */ source: 'get-windows' | 'hyprctl' | 'swaymsg' | 'xprop'; } let cachedDynamicImport: ((specifier: string) => Promise) | null = null; function importEsm(specifier: string): Promise { if (!cachedDynamicImport) { // Built via the Function constructor so the TypeScript compiler does not // down-level the `import()` call to `require()` under module: commonjs. cachedDynamicImport = new Function('s', 'return import(s)') as (specifier: string) => Promise; } return cachedDynamicImport(specifier) as Promise; } interface GetWindowsModule { activeWindow: (options?: { accessibilityPermission?: boolean; screenRecordingPermission?: boolean }) => Promise; } interface GetWindowsResult { platform: 'macos' | 'linux' | 'windows'; title: string; id: number; bounds: { x: number; y: number; width: number; height: number }; owner: { name: string; processId: number; path: string }; } export async function detectActiveWindow(): Promise { const getWindowsResult = await tryGetWindows(); if (getWindowsResult) { return getWindowsResult; } if (process.platform === 'linux') { return await detectLinuxActiveWindowViaCompositor(); } return null; } async function tryGetWindows(): Promise { try { const mod = await importEsm('get-windows'); const result = await mod.activeWindow({ accessibilityPermission: false, screenRecordingPermission: false }); if (!result || !result.owner?.name) { return null; } return { processName: result.owner.name, executablePath: result.owner.path || undefined, windowTitle: result.title || undefined, pid: result.owner.processId, bounds: result.bounds ? { width: result.bounds.width, height: result.bounds.height } : undefined, isFullscreen: isFullscreenFromBounds(result.bounds), source: 'get-windows' }; } catch { return null; } } function isFullscreenFromBounds(bounds: { x?: number; y?: number; width?: number; height?: number } | undefined): boolean { if (!bounds || typeof bounds.width !== 'number' || typeof bounds.height !== 'number') { return false; } // Cheap proxy: anything ≥1920x1080 is treated as fullscreen-ish. This is // intentionally loose because we already gate on focus and exe path. return bounds.width >= 1920 && bounds.height >= 1080; } async function detectLinuxActiveWindowViaCompositor(): Promise { const hypr = await tryHyprctl(); if (hypr) { return hypr; } const sway = await trySwaymsg(); if (sway) { return sway; } return null; } interface HyprlandActiveWindow { address?: string; pid?: number; title?: string; class?: string; initialClass?: string; fullscreen?: number | boolean; fullscreenClient?: number | boolean; size?: [number, number]; } async function tryHyprctl(): Promise { if (!process.env.HYPRLAND_INSTANCE_SIGNATURE) { return null; } try { const { stdout } = await execFileAsync('hyprctl', ['activewindow', '-j'], { timeout: 2_000, maxBuffer: 256 * 1024 }); const parsed = JSON.parse(stdout) as HyprlandActiveWindow; if (!parsed?.pid) { return null; } return await snapshotFromPid(parsed.pid, { windowTitle: parsed.title, processNameHint: parsed.class || parsed.initialClass, bounds: parsed.size ? { width: parsed.size[0], height: parsed.size[1] } : undefined, isFullscreen: !!parsed.fullscreen || !!parsed.fullscreenClient, source: 'hyprctl' }); } catch { return null; } } interface SwayTreeNode { focused?: boolean; pid?: number; name?: string; app_id?: string; window_properties?: { class?: string }; fullscreen_mode?: number; rect?: { width?: number; height?: number }; nodes?: SwayTreeNode[]; floating_nodes?: SwayTreeNode[]; } async function trySwaymsg(): Promise { if (!process.env.SWAYSOCK && !process.env.I3SOCK) { return null; } try { const { stdout } = await execFileAsync('swaymsg', ['-t', 'get_tree'], { timeout: 2_000, maxBuffer: 2 * 1024 * 1024 }); const tree = JSON.parse(stdout) as SwayTreeNode; const focused = findFocusedSwayNode(tree); if (!focused?.pid) { return null; } return await snapshotFromPid(focused.pid, { windowTitle: focused.name, processNameHint: focused.app_id || focused.window_properties?.class, bounds: focused.rect ? { width: focused.rect.width ?? 0, height: focused.rect.height ?? 0 } : undefined, isFullscreen: (focused.fullscreen_mode ?? 0) > 0, source: 'swaymsg' }); } catch { return null; } } function findFocusedSwayNode(node: SwayTreeNode): SwayTreeNode | null { if (node.focused && node.pid) { return node; } for (const child of node.nodes ?? []) { const found = findFocusedSwayNode(child); if (found) { return found; } } for (const child of node.floating_nodes ?? []) { const found = findFocusedSwayNode(child); if (found) { return found; } } return null; } interface SnapshotFromPidOptions { windowTitle?: string; processNameHint?: string; bounds?: { width: number; height: number }; isFullscreen?: boolean; source: ActiveWindowSnapshot['source']; } async function snapshotFromPid(pid: number, options: SnapshotFromPidOptions): Promise { let executablePath: string | undefined; let processName = options.processNameHint?.trim() || ''; try { executablePath = await fs.promises.readlink(`/proc/${pid}/exe`); if (!processName) { processName = path.basename(executablePath); } } catch { /* /proc//exe is restricted for foreign-uid processes; that's fine. */ } if (!processName) { try { processName = (await fs.promises.readFile(`/proc/${pid}/comm`, 'utf8')).trim(); } catch { /* ignored */ } } if (!processName) { return null; } return { processName, executablePath, windowTitle: options.windowTitle?.trim() || undefined, pid, bounds: options.bounds, isFullscreen: options.isFullscreen, source: options.source }; }