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
|
||||
};
|
||||
}
|
||||
402
electron/game-detection/heuristics.ts
Normal file
402
electron/game-detection/heuristics.ts
Normal file
@@ -0,0 +1,402 @@
|
||||
import * as path from 'path';
|
||||
|
||||
/**
|
||||
* Pure scoring/filtering helpers for game detection. Lives in the main process
|
||||
* and is exercised by both the foreground-window detector and the legacy
|
||||
* process-name scanner.
|
||||
*
|
||||
* The goal is to dramatically reduce the false-positive rate compared to the
|
||||
* previous "send every running process name to RAWG" approach by combining
|
||||
* multiple signals (window focus, executable path, engine markers, blacklist,
|
||||
* user-managed ignore list) into a confidence score.
|
||||
*/
|
||||
|
||||
export interface GameCandidateInput {
|
||||
/** Lower-cased base name without extension (e.g. "stardewvalley"). */
|
||||
processName: string;
|
||||
/** Original process name as reported by the OS (for display). */
|
||||
rawProcessName?: string;
|
||||
executablePath?: string;
|
||||
windowTitle?: string;
|
||||
pid?: number;
|
||||
bounds?: { width: number; height: number } | undefined;
|
||||
isFullscreen?: boolean;
|
||||
source: 'foreground' | 'process-scan';
|
||||
/** User-managed ignore list, already lower-cased. */
|
||||
ignoredProcessNames: ReadonlySet<string>;
|
||||
/** True when an engine signature file was found beside the executable. */
|
||||
hasEngineSignature?: boolean;
|
||||
}
|
||||
|
||||
export interface ScoredGameCandidate {
|
||||
processName: string;
|
||||
rawProcessName: string;
|
||||
executablePath?: string;
|
||||
windowTitle?: string;
|
||||
pid?: number;
|
||||
isFullscreen: boolean;
|
||||
bounds?: { width: number; height: number };
|
||||
confidence: number;
|
||||
source: 'foreground' | 'process-scan';
|
||||
reasons: string[];
|
||||
}
|
||||
|
||||
export const MIN_GAME_CONFIDENCE = 55;
|
||||
|
||||
/**
|
||||
* Processes that are commonly misclassified as games. Lower-cased base names.
|
||||
* Note: we deliberately blacklist Electron/Chromium/IDE/launcher/comm apps.
|
||||
*/
|
||||
export const HARDCODED_IGNORED_PROCESSES: ReadonlySet<string> = new Set([
|
||||
'1password',
|
||||
'7zfm',
|
||||
'agent',
|
||||
'audiodg',
|
||||
'bash',
|
||||
'baloo',
|
||||
'baloo_file',
|
||||
'baloorunner',
|
||||
'bluetoothuiservice',
|
||||
'brave',
|
||||
'brave-browser',
|
||||
'chrome',
|
||||
'cmd',
|
||||
'code',
|
||||
'code-insiders',
|
||||
'conhost',
|
||||
'cursor',
|
||||
'csrss',
|
||||
'ctfmon',
|
||||
'dbus',
|
||||
'dbus-daemon',
|
||||
'discord',
|
||||
'discordcanary',
|
||||
'discordptb',
|
||||
'dolphin',
|
||||
'dwm',
|
||||
'electron',
|
||||
'epicgameslauncher',
|
||||
'epicgames',
|
||||
'explorer',
|
||||
'fcitx5',
|
||||
'firefox',
|
||||
'fontdrvhost',
|
||||
'gameoverlayui',
|
||||
'gamemoded',
|
||||
'gamemode-launcher',
|
||||
'gamescopereaper',
|
||||
'gnome-shell',
|
||||
'gnome-software',
|
||||
'gnome-terminal',
|
||||
'init',
|
||||
'java',
|
||||
'javaw',
|
||||
'kdeconnect',
|
||||
'kdeconnectd',
|
||||
'kded5',
|
||||
'kded6',
|
||||
'keepass',
|
||||
'keepassxc',
|
||||
'kernel_task',
|
||||
'krunner',
|
||||
'ksmserver',
|
||||
'lockapp',
|
||||
'logioptionsplus',
|
||||
'logitechg',
|
||||
'login',
|
||||
'metoyou',
|
||||
'msedge',
|
||||
'msedgewebview2',
|
||||
'msteams',
|
||||
'node',
|
||||
'npm',
|
||||
'nvcontainer',
|
||||
'nvidia-broadcast',
|
||||
'nvidia-share',
|
||||
'nvidia-smi',
|
||||
'obs',
|
||||
'obs64',
|
||||
'obs-studio',
|
||||
'pipewire',
|
||||
'plasmashell',
|
||||
'pluma',
|
||||
'powershell',
|
||||
'pwsh',
|
||||
'pulseaudio',
|
||||
'remoteapps-service',
|
||||
'rundll32',
|
||||
'runtimebroker',
|
||||
'screen',
|
||||
'searchapp',
|
||||
'searchhost',
|
||||
'shellexperiencehost',
|
||||
'signal',
|
||||
'slack',
|
||||
'spotify',
|
||||
'spotifywebhelper',
|
||||
'sshd',
|
||||
'startmenuexperiencehost',
|
||||
'steam',
|
||||
'steamservice',
|
||||
'steamwebhelper',
|
||||
'svchost',
|
||||
'system',
|
||||
'systemd',
|
||||
'systemsettings',
|
||||
'systemsoundsservice',
|
||||
'taskhost',
|
||||
'taskhostw',
|
||||
'taskmgr',
|
||||
'teams',
|
||||
'telegram',
|
||||
'telegramdesktop',
|
||||
'textinputhost',
|
||||
'thunderbird',
|
||||
'tracker-miner-fs',
|
||||
'tray',
|
||||
'utilman',
|
||||
'vivaldi',
|
||||
'whatsapp',
|
||||
'wininit',
|
||||
'winlogon',
|
||||
'xdg-desktop-portal',
|
||||
'xorg',
|
||||
'xwayland',
|
||||
'yakuake',
|
||||
'zoom'
|
||||
]);
|
||||
|
||||
const GENERIC_SUFFIX_NAMES = [
|
||||
'agent',
|
||||
'browser',
|
||||
'daemon',
|
||||
'helper',
|
||||
'indexer',
|
||||
'launcher',
|
||||
'monitor',
|
||||
'renderer',
|
||||
'runner',
|
||||
'service',
|
||||
'tray',
|
||||
'updater',
|
||||
'watcher',
|
||||
'worker',
|
||||
'portal',
|
||||
'sync',
|
||||
'broker',
|
||||
'host'
|
||||
].join('|');
|
||||
const IGNORE_NAME_PATTERNS: readonly RegExp[] = [
|
||||
new RegExp(`(^|[-_\\s.])(${GENERIC_SUFFIX_NAMES})([-_\\s.]|$)`, 'iu'),
|
||||
/^kworker/i,
|
||||
/^kthread/i,
|
||||
/^kpipefs/i,
|
||||
/^(at-spi|gvfs|ibus|kded|kglobalaccel|knotify|polkit|pulse|systemd)/i
|
||||
];
|
||||
/** Known game install root markers, case-insensitive substrings of the exe path. */
|
||||
const KNOWN_GAME_PATH_MARKERS: readonly RegExp[] = [
|
||||
/[\\/]steamapps[\\/]common[\\/]/i,
|
||||
/[\\/]steamlibrary[\\/]/i,
|
||||
/[\\/]epic games[\\/]/i,
|
||||
/[\\/]epicgameslauncher[\\/]/i,
|
||||
/[\\/]gog galaxy[\\/]games[\\/]/i,
|
||||
/[\\/]gog\.com[\\/]games[\\/]/i,
|
||||
/[\\/]gog games[\\/]/i,
|
||||
/[\\/]ea games[\\/]/i,
|
||||
/[\\/]origin games[\\/]/i,
|
||||
/[\\/]battle\.net[\\/]/i,
|
||||
/[\\/]ubisoft[\\/]/i,
|
||||
/[\\/]riot games[\\/]/i,
|
||||
/[\\/]itch[\\/]apps[\\/]/i,
|
||||
/[\\/]\.itch[\\/]apps[\\/]/i,
|
||||
/[\\/]heroic[\\/]games[\\/]/i,
|
||||
/[\\/]lutris[\\/]/i,
|
||||
/[\\/]games[\\/]/i,
|
||||
// Proton / Wine prefixes used by Steam/Lutris/Heroic
|
||||
/[\\/]proton[\\/]/i,
|
||||
/[\\/]pfx[\\/]drive_c[\\/]/i,
|
||||
/[\\/]\.wine[\\/]drive_c[\\/]program files[\\/]/i
|
||||
];
|
||||
/** Path segments that strongly indicate the process is NOT a game. */
|
||||
const NON_GAME_PATH_MARKERS: readonly RegExp[] = [
|
||||
/[\\/]appdata[\\/]local[\\/]temp[\\/]/i,
|
||||
/[\\/]temp[\\/]/i,
|
||||
/[\\/]node_modules[\\/]/i,
|
||||
/[\\/]chromium[\\/]/i,
|
||||
/[\\/]appdata[\\/]roaming[\\/]discord[\\/]/i,
|
||||
/[\\/]appdata[\\/]roaming[\\/]spotify[\\/]/i,
|
||||
/[\\/]windows[\\/]system32[\\/]/i,
|
||||
/[\\/]windows[\\/]syswow64[\\/]/i,
|
||||
/[\\/]\.cache[\\/]/i,
|
||||
/[\\/]snap[\\/]firefox[\\/]/i,
|
||||
/[\\/]snap[\\/]spotify[\\/]/i
|
||||
];
|
||||
|
||||
/** File names placed beside a game's executable that reveal its engine. */
|
||||
export const ENGINE_SIGNATURE_FILES: readonly string[] = [
|
||||
'UnityPlayer.dll',
|
||||
'libUnityPlayer.so',
|
||||
'UnityCrashHandler64.exe',
|
||||
'UnityCrashHandler32.exe',
|
||||
// Unreal Engine: foo-Win64-Shipping.exe sits in <Game>/Binaries/Win64/
|
||||
'UnrealEditor.exe',
|
||||
'UE4PrereqSetup_x64.exe',
|
||||
'UE4Game.dll',
|
||||
'UE5Game.dll',
|
||||
// Godot
|
||||
'Godot.exe',
|
||||
'libgodot.so',
|
||||
// Source engine
|
||||
'tier0.dll',
|
||||
'engine.dll',
|
||||
'hl2.exe',
|
||||
// RPG Maker
|
||||
'nw.dll',
|
||||
// CryEngine
|
||||
'CryGameSDK.dll'
|
||||
];
|
||||
|
||||
export function normalizeProcessKey(value: string): string {
|
||||
return path.basename(value.trim())
|
||||
.replace(/\.(exe|bin|app|out)$/iu, '')
|
||||
.replace(/[_-]+/gu, ' ')
|
||||
.replace(/\s+/gu, ' ')
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
export function shouldIgnoreProcess(
|
||||
processName: string,
|
||||
userIgnored: ReadonlySet<string>
|
||||
): boolean {
|
||||
const key = normalizeProcessKey(processName);
|
||||
|
||||
if (!key) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (userIgnored.has(key) || HARDCODED_IGNORED_PROCESSES.has(key)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (/^\d+$/.test(key)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (key.length < 4) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return IGNORE_NAME_PATTERNS.some((pattern) => pattern.test(key));
|
||||
}
|
||||
|
||||
export function pathMatchesKnownGameRoot(executablePath: string | undefined): boolean {
|
||||
if (!executablePath) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return KNOWN_GAME_PATH_MARKERS.some((pattern) => pattern.test(executablePath));
|
||||
}
|
||||
|
||||
export function pathMatchesNonGameRoot(executablePath: string | undefined): boolean {
|
||||
if (!executablePath) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return NON_GAME_PATH_MARKERS.some((pattern) => pattern.test(executablePath));
|
||||
}
|
||||
|
||||
interface ConfidenceScore {
|
||||
confidence: number;
|
||||
reasons: string[];
|
||||
}
|
||||
|
||||
function computeConfidence(input: GameCandidateInput, rawProcessName: string): ConfidenceScore {
|
||||
let confidence = 0;
|
||||
|
||||
const reasons: string[] = [];
|
||||
const add = (points: number, reason: string): void => {
|
||||
confidence += points;
|
||||
reasons.push(reason);
|
||||
};
|
||||
|
||||
if (input.source === 'foreground') {
|
||||
add(35, 'foreground-window');
|
||||
}
|
||||
|
||||
if (pathMatchesKnownGameRoot(input.executablePath)) {
|
||||
add(30, 'known-game-folder');
|
||||
}
|
||||
|
||||
if (input.hasEngineSignature) {
|
||||
add(25, 'engine-signature');
|
||||
}
|
||||
|
||||
if (input.isFullscreen) {
|
||||
add(15, 'fullscreen');
|
||||
}
|
||||
|
||||
const width = input.bounds?.width ?? 0;
|
||||
const height = input.bounds?.height ?? 0;
|
||||
|
||||
if (width >= 800 && height >= 600) {
|
||||
add(5, 'large-window');
|
||||
}
|
||||
|
||||
const title = input.windowTitle?.trim() ?? '';
|
||||
|
||||
if (title.length >= 3 && /[A-Za-z]/u.test(title)) {
|
||||
add(10, 'window-title');
|
||||
}
|
||||
|
||||
if (/[A-Z]/u.test(rawProcessName) && /[a-z]/u.test(rawProcessName)) {
|
||||
add(3, 'mixed-case-name');
|
||||
}
|
||||
|
||||
if (input.executablePath && /\.exe$/iu.test(input.executablePath)) {
|
||||
confidence += 2;
|
||||
}
|
||||
|
||||
return { confidence: Math.min(100, confidence), reasons };
|
||||
}
|
||||
|
||||
export function scoreCandidate(input: GameCandidateInput): ScoredGameCandidate | null {
|
||||
const rawProcessName = input.rawProcessName ?? input.processName;
|
||||
const normalizedKey = normalizeProcessKey(input.processName);
|
||||
|
||||
if (!normalizedKey) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (shouldIgnoreProcess(normalizedKey, input.ignoredProcessNames)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (pathMatchesNonGameRoot(input.executablePath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { confidence, reasons } = computeConfidence(input, rawProcessName);
|
||||
const title = input.windowTitle?.trim() ?? '';
|
||||
|
||||
// Process-scan candidates must clear a higher bar: without a foreground or
|
||||
// path signal the confidence will stay below the threshold, which is the
|
||||
// whole point - no more silent RAWG lookups for arbitrary processes.
|
||||
return {
|
||||
processName: normalizedKey,
|
||||
rawProcessName,
|
||||
executablePath: input.executablePath,
|
||||
windowTitle: title || undefined,
|
||||
pid: input.pid,
|
||||
isFullscreen: !!input.isFullscreen,
|
||||
bounds: input.bounds,
|
||||
confidence,
|
||||
source: input.source,
|
||||
reasons
|
||||
};
|
||||
}
|
||||
|
||||
/** Returns whether a confidence score clears the "report to peers" threshold. */
|
||||
export function meetsGameConfidence(candidate: ScoredGameCandidate | null): boolean {
|
||||
return !!candidate && candidate.confidence >= MIN_GAME_CONFIDENCE;
|
||||
}
|
||||
119
electron/game-detection/index.ts
Normal file
119
electron/game-detection/index.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { detectActiveWindow } from './active-window';
|
||||
import {
|
||||
ENGINE_SIGNATURE_FILES,
|
||||
GameCandidateInput,
|
||||
MIN_GAME_CONFIDENCE,
|
||||
ScoredGameCandidate,
|
||||
scoreCandidate,
|
||||
shouldIgnoreProcess
|
||||
} from './heuristics';
|
||||
import { listRunningProcessNames } from '../process-list';
|
||||
import { readDesktopSettings } from '../desktop-settings';
|
||||
|
||||
/**
|
||||
* Public result of a detection scan. The renderer prefers `candidate` and only
|
||||
* falls back to `fallbackProcessNames` when no focused candidate clears the
|
||||
* minimum confidence threshold. The fallback list is intentionally trimmed and
|
||||
* pre-filtered so the renderer never sees obvious non-games like Spotify.
|
||||
*/
|
||||
export interface GameDetectionResult {
|
||||
candidate: ScoredGameCandidate | null;
|
||||
/**
|
||||
* Filtered list of plausible game process names. Empty when the focused
|
||||
* candidate already crossed the threshold (so the renderer skips fallback
|
||||
* matching). Capped to keep RAWG quota usage predictable.
|
||||
*/
|
||||
fallbackProcessNames: string[];
|
||||
}
|
||||
|
||||
const MAX_FALLBACK_PROCESSES = 8;
|
||||
|
||||
export async function detectActiveGame(): Promise<GameDetectionResult> {
|
||||
const ignoredProcessNames = getUserIgnoredProcesses();
|
||||
const active = await detectActiveWindow();
|
||||
|
||||
let candidate: ScoredGameCandidate | null = null;
|
||||
|
||||
if (active) {
|
||||
const hasEngineSignature = await detectEngineSignature(active.executablePath);
|
||||
const input: GameCandidateInput = {
|
||||
processName: active.processName,
|
||||
rawProcessName: active.processName,
|
||||
executablePath: active.executablePath,
|
||||
windowTitle: active.windowTitle,
|
||||
pid: active.pid,
|
||||
bounds: active.bounds,
|
||||
isFullscreen: active.isFullscreen,
|
||||
source: 'foreground',
|
||||
ignoredProcessNames,
|
||||
hasEngineSignature
|
||||
};
|
||||
|
||||
candidate = scoreCandidate(input);
|
||||
}
|
||||
|
||||
if (candidate && candidate.confidence >= MIN_GAME_CONFIDENCE) {
|
||||
return { candidate, fallbackProcessNames: [] };
|
||||
}
|
||||
|
||||
const fallbackProcessNames = await collectFallbackProcessNames(ignoredProcessNames);
|
||||
|
||||
return { candidate, fallbackProcessNames };
|
||||
}
|
||||
|
||||
async function collectFallbackProcessNames(ignoredProcessNames: ReadonlySet<string>): Promise<string[]> {
|
||||
try {
|
||||
const names = await listRunningProcessNames();
|
||||
const filtered: string[] = [];
|
||||
|
||||
for (const name of names) {
|
||||
if (filtered.length >= MAX_FALLBACK_PROCESSES) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (!shouldIgnoreProcess(name, ignoredProcessNames)) {
|
||||
filtered.push(name);
|
||||
}
|
||||
}
|
||||
|
||||
return filtered;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function detectEngineSignature(executablePath: string | undefined): Promise<boolean> {
|
||||
if (!executablePath) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const directory = path.dirname(executablePath);
|
||||
const entries = await fs.promises.readdir(directory).catch(() => []);
|
||||
const lowerEntries = new Set(entries.map((entry) => entry.toLowerCase()));
|
||||
|
||||
if (ENGINE_SIGNATURE_FILES.some((file) => lowerEntries.has(file.toLowerCase()))) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Unreal Engine ships executables ending in "-Win64-Shipping.exe" or
|
||||
// "-Linux-Shipping" inside <Game>/Binaries/<Platform>/.
|
||||
return entries.some((entry) => /-(win64|win32|linux)-shipping(\.exe)?$/i.test(entry));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function getUserIgnoredProcesses(): ReadonlySet<string> {
|
||||
try {
|
||||
const stored = readDesktopSettings().ignoredGameProcesses ?? [];
|
||||
|
||||
return new Set(stored.map((entry) => entry.trim().toLowerCase()).filter(Boolean));
|
||||
} catch {
|
||||
return new Set();
|
||||
}
|
||||
}
|
||||
|
||||
export type { ScoredGameCandidate } from './heuristics';
|
||||
Reference in New Issue
Block a user