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

@@ -16,7 +16,8 @@ Electron main-process package for MetoYou / Toju. This directory owns desktop bo
| --- | --- |
| `main.ts` | Electron app bootstrap and process entry point |
| `preload.ts` | Typed renderer-facing preload bridge |
| `process-list.ts` | Linux/Windows process-name scan used by now-playing game detection |
| `process-list.ts` | Linux/Windows process-name scan used as a fallback when foreground detection is unavailable |
| `game-detection/` | Foreground-window detection (`get-windows` + Hyprland/Sway fallbacks) plus pure heuristics scoring and ignore-list filtering |
| `app/` | App lifecycle and startup composition |
| `ipc/` | Renderer-invoked IPC handlers |
| `cqrs/` | Local database command/query handlers and mappings |

View File

@@ -18,6 +18,7 @@ export interface DesktopSettings {
autoStart: boolean;
closeToTray: boolean;
hardwareAcceleration: boolean;
ignoredGameProcesses: string[];
localApi: LocalApiSettings;
manifestUrls: string[];
preferredVersion: string | null;
@@ -42,6 +43,7 @@ const DEFAULT_DESKTOP_SETTINGS: DesktopSettings = {
autoStart: true,
closeToTray: true,
hardwareAcceleration: true,
ignoredGameProcesses: [],
localApi: { ...DEFAULT_LOCAL_API_SETTINGS },
manifestUrls: [],
preferredVersion: null,
@@ -80,6 +82,31 @@ function normalizeManifestUrls(value: unknown): string[] {
return manifestUrls;
}
function normalizeIgnoredGameProcesses(value: unknown): string[] {
if (!Array.isArray(value)) {
return [];
}
const ignored: string[] = [];
for (const entry of value) {
if (typeof entry !== 'string') {
continue;
}
const trimmed = entry.trim().toLowerCase()
.replace(/\.(exe|bin|app|out)$/iu, '');
if (!trimmed || trimmed.length > 96 || ignored.includes(trimmed)) {
continue;
}
ignored.push(trimmed);
}
return ignored.sort();
}
function normalizePort(value: unknown, fallback: number): number {
if (typeof value !== 'number' || !Number.isFinite(value)) {
return fallback;
@@ -171,6 +198,7 @@ export function readDesktopSettings(): DesktopSettings {
hardwareAcceleration: typeof parsed.hardwareAcceleration === 'boolean'
? parsed.hardwareAcceleration
: DEFAULT_DESKTOP_SETTINGS.hardwareAcceleration,
ignoredGameProcesses: normalizeIgnoredGameProcesses(parsed.ignoredGameProcesses),
localApi: normalizeLocalApiSettings(parsed.localApi),
manifestUrls: normalizeManifestUrls(parsed.manifestUrls),
preferredVersion: normalizePreferredVersion(parsed.preferredVersion)
@@ -200,6 +228,7 @@ export function updateDesktopSettings(patch: Partial<DesktopSettings>): DesktopS
hardwareAcceleration: typeof mergedSettings.hardwareAcceleration === 'boolean'
? mergedSettings.hardwareAcceleration
: DEFAULT_DESKTOP_SETTINGS.hardwareAcceleration,
ignoredGameProcesses: normalizeIgnoredGameProcesses(mergedSettings.ignoredGameProcesses),
localApi: normalizeLocalApiSettings(mergedSettings.localApi),
manifestUrls: normalizeManifestUrls(mergedSettings.manifestUrls),
preferredVersion: normalizePreferredVersion(mergedSettings.preferredVersion),

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

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

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

View File

@@ -15,6 +15,7 @@ import * as path from 'path';
import { fileURLToPath, pathToFileURL } from 'url';
import {
getDesktopSettingsSnapshot,
readDesktopSettings,
updateDesktopSettings,
type DesktopSettings
} from '../desktop-settings';
@@ -58,6 +59,7 @@ import {
openCurrentDataFolder
} from '../data-management';
import { listRunningProcessNames } from '../process-list';
import { detectActiveGame } from '../game-detection';
const DEFAULT_MIME_TYPE = 'application/octet-stream';
const FILE_CLIPBOARD_FORMATS = [
@@ -325,6 +327,18 @@ export function setupSystemHandlers(): void {
ipcMain.handle('get-running-process-names', async () => await listRunningProcessNames());
ipcMain.handle('get-active-game-candidate', async () => await detectActiveGame());
ipcMain.handle('get-ignored-game-processes', () => {
return readDesktopSettings().ignoredGameProcesses;
});
ipcMain.handle('set-ignored-game-processes', (_event, list: unknown) => {
const snapshot = updateDesktopSettings({ ignoredGameProcesses: Array.isArray(list) ? list : [] });
return snapshot.ignoredGameProcesses;
});
ipcMain.handle('prepare-linux-screen-share-audio-routing', async () => {
return await prepareLinuxScreenShareAudioRouting();
});

View File

@@ -203,6 +203,24 @@ export interface ContextMenuParams {
};
}
export interface ActiveGameCandidate {
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 interface ActiveGameCandidateResult {
candidate: ActiveGameCandidate | null;
fallbackProcessNames: string[];
}
export interface ElectronAPI {
linuxDisplayServer: string;
minimizeWindow: () => void;
@@ -212,6 +230,9 @@ export interface ElectronAPI {
openExternal: (url: string) => Promise<boolean>;
getSources: () => Promise<{ id: string; name: string; thumbnail: string }[]>;
getRunningProcessNames: () => Promise<string[]>;
getActiveGameCandidate: () => Promise<ActiveGameCandidateResult>;
getIgnoredGameProcesses: () => Promise<string[]>;
setIgnoredGameProcesses: (list: string[]) => Promise<string[]>;
prepareLinuxScreenShareAudioRouting: () => Promise<LinuxScreenShareAudioRoutingInfo>;
activateLinuxScreenShareAudioRouting: () => Promise<LinuxScreenShareAudioRoutingInfo>;
deactivateLinuxScreenShareAudioRouting: () => Promise<boolean>;
@@ -314,6 +335,9 @@ const electronAPI: ElectronAPI = {
openExternal: (url) => ipcRenderer.invoke('open-external', url),
getSources: () => ipcRenderer.invoke('get-sources'),
getRunningProcessNames: () => ipcRenderer.invoke('get-running-process-names'),
getActiveGameCandidate: () => ipcRenderer.invoke('get-active-game-candidate'),
getIgnoredGameProcesses: () => ipcRenderer.invoke('get-ignored-game-processes'),
setIgnoredGameProcesses: (list) => ipcRenderer.invoke('set-ignored-game-processes', list),
prepareLinuxScreenShareAudioRouting: () => ipcRenderer.invoke('prepare-linux-screen-share-audio-routing'),
activateLinuxScreenShareAudioRouting: () => ipcRenderer.invoke('activate-linux-screen-share-audio-routing'),
deactivateLinuxScreenShareAudioRouting: () => ipcRenderer.invoke('deactivate-linux-screen-share-audio-routing'),

937
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -90,6 +90,7 @@
"codemirror": "^6.0.2",
"cytoscape": "^3.33.1",
"electron-updater": "^6.6.2",
"get-windows": "^9.3.0",
"mermaid": "^11.12.3",
"ngx-remark": "^0.2.2",
"prismjs": "^1.30.0",
@@ -155,8 +156,13 @@
"!node_modules",
"dist/client/**/*",
"dist/electron/**/*",
"node_modules/{ansi-regex,ansi-styles,ansis,app-root-path,applescript,argparse,auto-launch,available-typed-arrays,balanced-match,base64-js,brace-expansion,buffer,builder-util-runtime,call-bind,call-bind-apply-helpers,call-bound,cliui,concat-map,cross-spawn,dayjs,debug,dedent,define-data-property,dotenv,dunder-proto,electron-updater,emoji-regex,es-define-property,es-errors,es-object-atoms,escalade,for-each,foreground-child,fs-extra,function-bind,get-caller-file,get-east-asian-width,get-intrinsic,get-proto,glob,gopd,graceful-fs,has-property-descriptors,has-symbols,has-tostringtag,hasown,ieee754,inherits,is-callable,is-fullwidth-code-point,is-typed-array,isarray,isexe,jackspeak,js-yaml,jsonfile,lazy-val,lodash.escaperegexp,lodash.isequal,lru-cache,math-intrinsics,minimatch,minimist,minipass,mkdirp,ms,package-json-from-dist,path-is-absolute,path-key,path-scurry,possible-typed-array-names,reflect-metadata,safe-buffer,sax,semver,set-function-length,sha.js,shebang-command,shebang-regex,signal-exit,sql-highlight,sql.js,string-width,string-width-cjs,strip-ansi,strip-ansi-cjs,tiny-typed-emitter,to-buffer,tslib,typed-array-buffer,typeorm,universalify,untildify,uuid,which,which-typed-array,winreg,wrap-ansi,wrap-ansi-cjs,y18n,yallist,yargs,yargs-parser}/**/*",
"node_modules/{abbrev,agent-base,ansi-regex,ansi-styles,ansis,app-root-path,applescript,aproba,are-we-there-yet,argparse,auto-launch,available-typed-arrays,balanced-match,base64-js,brace-expansion,buffer,builder-util-runtime,cacache,call-bind,call-bind-apply-helpers,call-bound,chownr,cliui,color-support,concat-map,console-control-strings,cross-spawn,dayjs,debug,dedent,define-data-property,delegates,detect-libc,dotenv,dunder-proto,electron-updater,emoji-regex,env-paths,es-define-property,es-errors,es-object-atoms,escalade,exponential-backoff,fdir,for-each,foreground-child,fs-extra,fs-minipass,function-bind,gauge,get-caller-file,get-east-asian-width,get-intrinsic,get-proto,get-windows,glob,gopd,graceful-fs,has-property-descriptors,has-symbols,has-tostringtag,has-unicode,hasown,http-cache-semantics,http-proxy-agent,https-proxy-agent,iconv-lite,ieee754,imurmurhash,inherits,ip-address,is-callable,is-fullwidth-code-point,is-typed-array,isarray,isexe,jackspeak,js-yaml,jsonfile,lazy-val,lodash.escaperegexp,lodash.isequal,lru-cache,make-dir,make-fetch-happen,math-intrinsics,minimatch,minimist,minipass,minipass-collect,minipass-fetch,minipass-flush,minipass-pipeline,minipass-sized,minizlib,mkdirp,ms,negotiator,node-addon-api,node-fetch,node-gyp,nopt,npmlog,object-assign,p-map,package-json-from-dist,path-is-absolute,path-key,path-scurry,picomatch,pify,possible-typed-array-names,proc-log,readable-stream,reflect-metadata,retry,rimraf,safe-buffer,safer-buffer,sax,semver,set-blocking,set-function-length,sha.js,shebang-command,shebang-regex,signal-exit,smart-buffer,socks,socks-proxy-agent,sql-highlight,sql.js,ssri,string-width,string-width-cjs,string_decoder,strip-ansi,strip-ansi-cjs,tar,tiny-typed-emitter,tinyglobby,to-buffer,tr46,tslib,typed-array-buffer,typeorm,unique-filename,unique-slug,universalify,untildify,util-deprecate,uuid,webidl-conversions,whatwg-url,which,which-typed-array,wide-align,winreg,wrap-ansi,wrap-ansi-cjs,y18n,yallist,yargs,yargs-parser}/**/*",
"node_modules/@gar/promise-retry/**/*",
"node_modules/@isaacs/cliui/**/*",
"node_modules/@isaacs/fs-minipass/**/*",
"node_modules/@mapbox/node-pre-gyp/**/*",
"node_modules/@npmcli/agent/**/*",
"node_modules/@npmcli/fs/**/*",
"node_modules/@pkgjs/parseargs/**/*",
"node_modules/@sqltools/formatter/**/*",
"!node_modules/**/test/**/*",

View File

@@ -215,6 +215,24 @@ export interface ContextMenuParams {
};
}
export interface ActiveGameCandidate {
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 interface ActiveGameCandidateResult {
candidate: ActiveGameCandidate | null;
fallbackProcessNames: string[];
}
export interface ElectronApi {
linuxDisplayServer: string;
minimizeWindow: () => void;
@@ -223,6 +241,9 @@ export interface ElectronApi {
openExternal: (url: string) => Promise<boolean>;
getSources: () => Promise<{ id: string; name: string; thumbnail: string }[]>;
getRunningProcessNames: () => Promise<string[]>;
getActiveGameCandidate?: () => Promise<ActiveGameCandidateResult>;
getIgnoredGameProcesses?: () => Promise<string[]>;
setIgnoredGameProcesses?: (list: string[]) => Promise<string[]>;
prepareLinuxScreenShareAudioRouting: () => Promise<LinuxScreenShareAudioRoutingInfo>;
activateLinuxScreenShareAudioRouting: () => Promise<LinuxScreenShareAudioRoutingInfo>;
deactivateLinuxScreenShareAudioRouting: () => Promise<boolean>;

View File

@@ -15,7 +15,7 @@ infrastructure adapters and UI.
| **direct-message** | One-to-one WebRTC messages, offline queueing, delivery state, and friends | `DirectMessageService`, `FriendService` |
| **direct-call** | Direct and small-group private calls initiated from people cards and direct messages | `DirectCallService` |
| **experimental-media** | Optional media playback experiments kept isolated from the default attachment path | `ExperimentalMediaSettingsService` |
| **game-activity** | Local game detection, server metadata matching, P2P now-playing sync, and elapsed playtime formatting | `GameActivityService`, `formatGameActivityElapsed()` |
| **game-activity** | Foreground-window-first game detection with confidence scoring (`MIN_GAME_CONFIDENCE`), server metadata matching, P2P now-playing sync, and elapsed playtime formatting | `GameActivityService`, `formatGameActivityElapsed()` |
| **notifications** | Notification preferences, unread tracking, desktop alert orchestration | `NotificationsFacade` |
| **plugins** | Client-only plugin manifests, load ordering, registry state, and signal-server support metadata | `PluginHostService`, `PluginRegistryService` |
| **profile-avatar** | Profile picture upload, crop/zoom editing, processing, local persistence, and P2P avatar sync | `ProfileAvatarFacade` |

View File

@@ -120,7 +120,7 @@ export class GameActivityService implements OnDestroy {
const api = this.electron.getApi();
if (!api?.getRunningProcessNames) {
if (!api?.getRunningProcessNames && !api?.getActiveGameCandidate) {
return;
}
@@ -154,14 +154,33 @@ export class GameActivityService implements OnDestroy {
const api = this.electron.getApi();
if (!api?.getRunningProcessNames) {
if (!api?.getRunningProcessNames && !api?.getActiveGameCandidate) {
return;
}
this.scanInFlight = true;
try {
const processNames = (await api.getRunningProcessNames()).slice(0, MAX_PROCESS_NAMES_PER_REQUEST);
const candidateResult = api.getActiveGameCandidate
? await api.getActiveGameCandidate().catch(() => null)
: null;
let processNames: string[];
let preferredProcessName: string | undefined;
if (candidateResult?.candidate) {
// Main process already scored & filtered this; trust it.
preferredProcessName = candidateResult.candidate.rawProcessName ?? candidateResult.candidate.processName;
processNames = [preferredProcessName];
} else if (candidateResult && candidateResult.fallbackProcessNames.length > 0) {
processNames = candidateResult.fallbackProcessNames.slice(0, MAX_PROCESS_NAMES_PER_REQUEST);
} else if (!candidateResult && api.getRunningProcessNames) {
// Old preload without the new API: fall back to legacy whole-system scan.
processNames = (await api.getRunningProcessNames()).slice(0, MAX_PROCESS_NAMES_PER_REQUEST);
} else {
processNames = [];
}
const processHash = this.buildProcessHash(processNames);
if (processHash === this.lastProcessHash) {
@@ -170,6 +189,12 @@ export class GameActivityService implements OnDestroy {
this.lastProcessHash = processHash;
if (processNames.length === 0) {
this.ngZone.run(() => this.applyMatchedGame(null));
return;
}
const matchedGame = await this.matchRunningGame(processNames);
this.ngZone.run(() => this.applyMatchedGame(matchedGame));

View File

@@ -139,4 +139,61 @@
</div>
</div>
</section>
@if (isElectron) {
<section>
<div class="flex items-center gap-2 mb-3">
<h4 class="text-sm font-semibold text-foreground">Game detection</h4>
</div>
<div class="rounded-lg border border-border bg-secondary/20 p-4 space-y-3">
<p class="text-xs text-muted-foreground">
MetoYou prefers the currently focused window when detecting your game. Add process names here to permanently hide
apps that get mistakenly identified as games (e.g. "spotify", "obs64"). Entries are matched case-insensitively
against the executable name without its extension.
</p>
<div class="flex items-center gap-2">
<input
type="text"
class="flex-1 rounded-md border border-border bg-background px-3 py-1.5 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
placeholder="Process name (e.g. spotify)"
[value]="ignoredProcessDraft()"
(input)="onIgnoredProcessDraftChange($event)"
(keydown.enter)="addIgnoredProcess()"
aria-label="Process name to ignore"
/>
<button
type="button"
class="rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
[disabled]="savingIgnoredGameProcesses() || !ignoredProcessDraft().trim()"
(click)="addIgnoredProcess()"
>
Add
</button>
</div>
@if (ignoredGameProcesses().length === 0) {
<p class="text-xs text-muted-foreground italic">No ignored processes yet.</p>
} @else {
<ul class="flex flex-wrap gap-2">
@for (entry of ignoredGameProcesses(); track entry) {
<li class="inline-flex items-center gap-1 rounded-md bg-secondary/40 px-2 py-1 text-xs text-foreground">
<span>{{ entry }}</span>
<button
type="button"
class="text-muted-foreground hover:text-foreground"
[disabled]="savingIgnoredGameProcesses()"
(click)="removeIgnoredProcess(entry)"
[attr.aria-label]="'Remove ' + entry + ' from ignore list'"
>
×
</button>
</li>
}
</ul>
}
</div>
</section>
}
</div>

View File

@@ -36,12 +36,16 @@ export class GeneralSettingsComponent {
closeToTray = signal(true);
savingAutoStart = signal(false);
savingCloseToTray = signal(false);
ignoredGameProcesses = signal<string[]>([]);
ignoredProcessDraft = signal('');
savingIgnoredGameProcesses = signal(false);
constructor() {
this.loadGeneralSettings();
if (this.isElectron) {
void this.loadDesktopSettings();
void this.loadIgnoredGameProcesses();
}
}
@@ -131,4 +135,61 @@ export class GeneralSettingsComponent {
this.autoStart.set(snapshot.autoStart);
this.closeToTray.set(snapshot.closeToTray);
}
onIgnoredProcessDraftChange(event: Event): void {
const input = event.target as HTMLInputElement;
this.ignoredProcessDraft.set(input.value);
}
async addIgnoredProcess(): Promise<void> {
const draft = this.ignoredProcessDraft().trim();
if (!draft) {
return;
}
const next = Array.from(new Set([...this.ignoredGameProcesses(), draft]));
await this.saveIgnoredGameProcesses(next);
this.ignoredProcessDraft.set('');
}
async removeIgnoredProcess(name: string): Promise<void> {
const next = this.ignoredGameProcesses().filter((entry) => entry !== name);
await this.saveIgnoredGameProcesses(next);
}
private async loadIgnoredGameProcesses(): Promise<void> {
const api = this.electronBridge.getApi();
if (!api?.getIgnoredGameProcesses) {
return;
}
try {
const list = await api.getIgnoredGameProcesses();
this.ignoredGameProcesses.set(list);
} catch {}
}
private async saveIgnoredGameProcesses(list: string[]): Promise<void> {
const api = this.electronBridge.getApi();
if (!api?.setIgnoredGameProcesses) {
return;
}
this.savingIgnoredGameProcesses.set(true);
try {
const normalized = await api.setIgnoredGameProcesses(list);
this.ignoredGameProcesses.set(normalized);
} finally {
this.savingIgnoredGameProcesses.set(false);
}
}
}