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
269 lines
7.1 KiB
TypeScript
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
|
|
};
|
|
}
|