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

This commit is contained in:
2026-05-17 17:47:40 +02:00
parent 8631290c01
commit a173299ad3
14 changed files with 1942 additions and 34 deletions

View 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;
}