Files
Toju/electron/game-detection/active-window.ts
Myx a173299ad3
Some checks failed
Queue Release Build / prepare (push) Successful in 27s
Deploy Web Apps / deploy (push) Successful in 10m8s
Queue Release Build / finalize (push) Has been cancelled
Queue Release Build / build-windows (push) Has been cancelled
Queue Release Build / build-linux (push) Has been cancelled
fix: Game detection improvements
2026-05-17 17:47:40 +02:00

269 lines
7.1 KiB
TypeScript

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<unknown>) | null = null;
function importEsm<T>(specifier: string): Promise<T> {
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<unknown>;
}
return cachedDynamicImport(specifier) as Promise<T>;
}
interface GetWindowsModule {
activeWindow: (options?: { accessibilityPermission?: boolean; screenRecordingPermission?: boolean }) => Promise<GetWindowsResult | undefined>;
}
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<ActiveWindowSnapshot | null> {
const getWindowsResult = await tryGetWindows();
if (getWindowsResult) {
return getWindowsResult;
}
if (process.platform === 'linux') {
return await detectLinuxActiveWindowViaCompositor();
}
return null;
}
async function tryGetWindows(): Promise<ActiveWindowSnapshot | null> {
try {
const mod = await importEsm<GetWindowsModule>('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<ActiveWindowSnapshot | null> {
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<ActiveWindowSnapshot | null> {
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<ActiveWindowSnapshot | null> {
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<ActiveWindowSnapshot | null> {
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/<pid>/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
};
}