fix: Game detection improvements
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
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
This commit is contained in:
268
electron/game-detection/active-window.ts
Normal file
268
electron/game-detection/active-window.ts
Normal file
@@ -0,0 +1,268 @@
|
||||
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
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user