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
403 lines
9.3 KiB
TypeScript
403 lines
9.3 KiB
TypeScript
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;
|
|
}
|