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:
@@ -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 |
|
| `main.ts` | Electron app bootstrap and process entry point |
|
||||||
| `preload.ts` | Typed renderer-facing preload bridge |
|
| `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 |
|
| `app/` | App lifecycle and startup composition |
|
||||||
| `ipc/` | Renderer-invoked IPC handlers |
|
| `ipc/` | Renderer-invoked IPC handlers |
|
||||||
| `cqrs/` | Local database command/query handlers and mappings |
|
| `cqrs/` | Local database command/query handlers and mappings |
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export interface DesktopSettings {
|
|||||||
autoStart: boolean;
|
autoStart: boolean;
|
||||||
closeToTray: boolean;
|
closeToTray: boolean;
|
||||||
hardwareAcceleration: boolean;
|
hardwareAcceleration: boolean;
|
||||||
|
ignoredGameProcesses: string[];
|
||||||
localApi: LocalApiSettings;
|
localApi: LocalApiSettings;
|
||||||
manifestUrls: string[];
|
manifestUrls: string[];
|
||||||
preferredVersion: string | null;
|
preferredVersion: string | null;
|
||||||
@@ -42,6 +43,7 @@ const DEFAULT_DESKTOP_SETTINGS: DesktopSettings = {
|
|||||||
autoStart: true,
|
autoStart: true,
|
||||||
closeToTray: true,
|
closeToTray: true,
|
||||||
hardwareAcceleration: true,
|
hardwareAcceleration: true,
|
||||||
|
ignoredGameProcesses: [],
|
||||||
localApi: { ...DEFAULT_LOCAL_API_SETTINGS },
|
localApi: { ...DEFAULT_LOCAL_API_SETTINGS },
|
||||||
manifestUrls: [],
|
manifestUrls: [],
|
||||||
preferredVersion: null,
|
preferredVersion: null,
|
||||||
@@ -80,6 +82,31 @@ function normalizeManifestUrls(value: unknown): string[] {
|
|||||||
return manifestUrls;
|
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 {
|
function normalizePort(value: unknown, fallback: number): number {
|
||||||
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
||||||
return fallback;
|
return fallback;
|
||||||
@@ -171,6 +198,7 @@ export function readDesktopSettings(): DesktopSettings {
|
|||||||
hardwareAcceleration: typeof parsed.hardwareAcceleration === 'boolean'
|
hardwareAcceleration: typeof parsed.hardwareAcceleration === 'boolean'
|
||||||
? parsed.hardwareAcceleration
|
? parsed.hardwareAcceleration
|
||||||
: DEFAULT_DESKTOP_SETTINGS.hardwareAcceleration,
|
: DEFAULT_DESKTOP_SETTINGS.hardwareAcceleration,
|
||||||
|
ignoredGameProcesses: normalizeIgnoredGameProcesses(parsed.ignoredGameProcesses),
|
||||||
localApi: normalizeLocalApiSettings(parsed.localApi),
|
localApi: normalizeLocalApiSettings(parsed.localApi),
|
||||||
manifestUrls: normalizeManifestUrls(parsed.manifestUrls),
|
manifestUrls: normalizeManifestUrls(parsed.manifestUrls),
|
||||||
preferredVersion: normalizePreferredVersion(parsed.preferredVersion)
|
preferredVersion: normalizePreferredVersion(parsed.preferredVersion)
|
||||||
@@ -200,6 +228,7 @@ export function updateDesktopSettings(patch: Partial<DesktopSettings>): DesktopS
|
|||||||
hardwareAcceleration: typeof mergedSettings.hardwareAcceleration === 'boolean'
|
hardwareAcceleration: typeof mergedSettings.hardwareAcceleration === 'boolean'
|
||||||
? mergedSettings.hardwareAcceleration
|
? mergedSettings.hardwareAcceleration
|
||||||
: DEFAULT_DESKTOP_SETTINGS.hardwareAcceleration,
|
: DEFAULT_DESKTOP_SETTINGS.hardwareAcceleration,
|
||||||
|
ignoredGameProcesses: normalizeIgnoredGameProcesses(mergedSettings.ignoredGameProcesses),
|
||||||
localApi: normalizeLocalApiSettings(mergedSettings.localApi),
|
localApi: normalizeLocalApiSettings(mergedSettings.localApi),
|
||||||
manifestUrls: normalizeManifestUrls(mergedSettings.manifestUrls),
|
manifestUrls: normalizeManifestUrls(mergedSettings.manifestUrls),
|
||||||
preferredVersion: normalizePreferredVersion(mergedSettings.preferredVersion),
|
preferredVersion: normalizePreferredVersion(mergedSettings.preferredVersion),
|
||||||
|
|||||||
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';
|
||||||
@@ -15,6 +15,7 @@ import * as path from 'path';
|
|||||||
import { fileURLToPath, pathToFileURL } from 'url';
|
import { fileURLToPath, pathToFileURL } from 'url';
|
||||||
import {
|
import {
|
||||||
getDesktopSettingsSnapshot,
|
getDesktopSettingsSnapshot,
|
||||||
|
readDesktopSettings,
|
||||||
updateDesktopSettings,
|
updateDesktopSettings,
|
||||||
type DesktopSettings
|
type DesktopSettings
|
||||||
} from '../desktop-settings';
|
} from '../desktop-settings';
|
||||||
@@ -58,6 +59,7 @@ import {
|
|||||||
openCurrentDataFolder
|
openCurrentDataFolder
|
||||||
} from '../data-management';
|
} from '../data-management';
|
||||||
import { listRunningProcessNames } from '../process-list';
|
import { listRunningProcessNames } from '../process-list';
|
||||||
|
import { detectActiveGame } from '../game-detection';
|
||||||
|
|
||||||
const DEFAULT_MIME_TYPE = 'application/octet-stream';
|
const DEFAULT_MIME_TYPE = 'application/octet-stream';
|
||||||
const FILE_CLIPBOARD_FORMATS = [
|
const FILE_CLIPBOARD_FORMATS = [
|
||||||
@@ -325,6 +327,18 @@ export function setupSystemHandlers(): void {
|
|||||||
|
|
||||||
ipcMain.handle('get-running-process-names', async () => await listRunningProcessNames());
|
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 () => {
|
ipcMain.handle('prepare-linux-screen-share-audio-routing', async () => {
|
||||||
return await prepareLinuxScreenShareAudioRouting();
|
return await prepareLinuxScreenShareAudioRouting();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 {
|
export interface ElectronAPI {
|
||||||
linuxDisplayServer: string;
|
linuxDisplayServer: string;
|
||||||
minimizeWindow: () => void;
|
minimizeWindow: () => void;
|
||||||
@@ -212,6 +230,9 @@ export interface ElectronAPI {
|
|||||||
openExternal: (url: string) => Promise<boolean>;
|
openExternal: (url: string) => Promise<boolean>;
|
||||||
getSources: () => Promise<{ id: string; name: string; thumbnail: string }[]>;
|
getSources: () => Promise<{ id: string; name: string; thumbnail: string }[]>;
|
||||||
getRunningProcessNames: () => Promise<string[]>;
|
getRunningProcessNames: () => Promise<string[]>;
|
||||||
|
getActiveGameCandidate: () => Promise<ActiveGameCandidateResult>;
|
||||||
|
getIgnoredGameProcesses: () => Promise<string[]>;
|
||||||
|
setIgnoredGameProcesses: (list: string[]) => Promise<string[]>;
|
||||||
prepareLinuxScreenShareAudioRouting: () => Promise<LinuxScreenShareAudioRoutingInfo>;
|
prepareLinuxScreenShareAudioRouting: () => Promise<LinuxScreenShareAudioRoutingInfo>;
|
||||||
activateLinuxScreenShareAudioRouting: () => Promise<LinuxScreenShareAudioRoutingInfo>;
|
activateLinuxScreenShareAudioRouting: () => Promise<LinuxScreenShareAudioRoutingInfo>;
|
||||||
deactivateLinuxScreenShareAudioRouting: () => Promise<boolean>;
|
deactivateLinuxScreenShareAudioRouting: () => Promise<boolean>;
|
||||||
@@ -314,6 +335,9 @@ const electronAPI: ElectronAPI = {
|
|||||||
openExternal: (url) => ipcRenderer.invoke('open-external', url),
|
openExternal: (url) => ipcRenderer.invoke('open-external', url),
|
||||||
getSources: () => ipcRenderer.invoke('get-sources'),
|
getSources: () => ipcRenderer.invoke('get-sources'),
|
||||||
getRunningProcessNames: () => ipcRenderer.invoke('get-running-process-names'),
|
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'),
|
prepareLinuxScreenShareAudioRouting: () => ipcRenderer.invoke('prepare-linux-screen-share-audio-routing'),
|
||||||
activateLinuxScreenShareAudioRouting: () => ipcRenderer.invoke('activate-linux-screen-share-audio-routing'),
|
activateLinuxScreenShareAudioRouting: () => ipcRenderer.invoke('activate-linux-screen-share-audio-routing'),
|
||||||
deactivateLinuxScreenShareAudioRouting: () => ipcRenderer.invoke('deactivate-linux-screen-share-audio-routing'),
|
deactivateLinuxScreenShareAudioRouting: () => ipcRenderer.invoke('deactivate-linux-screen-share-audio-routing'),
|
||||||
|
|||||||
937
package-lock.json
generated
937
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -90,6 +90,7 @@
|
|||||||
"codemirror": "^6.0.2",
|
"codemirror": "^6.0.2",
|
||||||
"cytoscape": "^3.33.1",
|
"cytoscape": "^3.33.1",
|
||||||
"electron-updater": "^6.6.2",
|
"electron-updater": "^6.6.2",
|
||||||
|
"get-windows": "^9.3.0",
|
||||||
"mermaid": "^11.12.3",
|
"mermaid": "^11.12.3",
|
||||||
"ngx-remark": "^0.2.2",
|
"ngx-remark": "^0.2.2",
|
||||||
"prismjs": "^1.30.0",
|
"prismjs": "^1.30.0",
|
||||||
@@ -155,8 +156,13 @@
|
|||||||
"!node_modules",
|
"!node_modules",
|
||||||
"dist/client/**/*",
|
"dist/client/**/*",
|
||||||
"dist/electron/**/*",
|
"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/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/@pkgjs/parseargs/**/*",
|
||||||
"node_modules/@sqltools/formatter/**/*",
|
"node_modules/@sqltools/formatter/**/*",
|
||||||
"!node_modules/**/test/**/*",
|
"!node_modules/**/test/**/*",
|
||||||
|
|||||||
@@ -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 {
|
export interface ElectronApi {
|
||||||
linuxDisplayServer: string;
|
linuxDisplayServer: string;
|
||||||
minimizeWindow: () => void;
|
minimizeWindow: () => void;
|
||||||
@@ -223,6 +241,9 @@ export interface ElectronApi {
|
|||||||
openExternal: (url: string) => Promise<boolean>;
|
openExternal: (url: string) => Promise<boolean>;
|
||||||
getSources: () => Promise<{ id: string; name: string; thumbnail: string }[]>;
|
getSources: () => Promise<{ id: string; name: string; thumbnail: string }[]>;
|
||||||
getRunningProcessNames: () => Promise<string[]>;
|
getRunningProcessNames: () => Promise<string[]>;
|
||||||
|
getActiveGameCandidate?: () => Promise<ActiveGameCandidateResult>;
|
||||||
|
getIgnoredGameProcesses?: () => Promise<string[]>;
|
||||||
|
setIgnoredGameProcesses?: (list: string[]) => Promise<string[]>;
|
||||||
prepareLinuxScreenShareAudioRouting: () => Promise<LinuxScreenShareAudioRoutingInfo>;
|
prepareLinuxScreenShareAudioRouting: () => Promise<LinuxScreenShareAudioRoutingInfo>;
|
||||||
activateLinuxScreenShareAudioRouting: () => Promise<LinuxScreenShareAudioRoutingInfo>;
|
activateLinuxScreenShareAudioRouting: () => Promise<LinuxScreenShareAudioRoutingInfo>;
|
||||||
deactivateLinuxScreenShareAudioRouting: () => Promise<boolean>;
|
deactivateLinuxScreenShareAudioRouting: () => Promise<boolean>;
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ infrastructure adapters and UI.
|
|||||||
| **direct-message** | One-to-one WebRTC messages, offline queueing, delivery state, and friends | `DirectMessageService`, `FriendService` |
|
| **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` |
|
| **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` |
|
| **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` |
|
| **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` |
|
| **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` |
|
| **profile-avatar** | Profile picture upload, crop/zoom editing, processing, local persistence, and P2P avatar sync | `ProfileAvatarFacade` |
|
||||||
|
|||||||
@@ -120,7 +120,7 @@ export class GameActivityService implements OnDestroy {
|
|||||||
|
|
||||||
const api = this.electron.getApi();
|
const api = this.electron.getApi();
|
||||||
|
|
||||||
if (!api?.getRunningProcessNames) {
|
if (!api?.getRunningProcessNames && !api?.getActiveGameCandidate) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -154,14 +154,33 @@ export class GameActivityService implements OnDestroy {
|
|||||||
|
|
||||||
const api = this.electron.getApi();
|
const api = this.electron.getApi();
|
||||||
|
|
||||||
if (!api?.getRunningProcessNames) {
|
if (!api?.getRunningProcessNames && !api?.getActiveGameCandidate) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.scanInFlight = true;
|
this.scanInFlight = true;
|
||||||
|
|
||||||
try {
|
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);
|
const processHash = this.buildProcessHash(processNames);
|
||||||
|
|
||||||
if (processHash === this.lastProcessHash) {
|
if (processHash === this.lastProcessHash) {
|
||||||
@@ -170,6 +189,12 @@ export class GameActivityService implements OnDestroy {
|
|||||||
|
|
||||||
this.lastProcessHash = processHash;
|
this.lastProcessHash = processHash;
|
||||||
|
|
||||||
|
if (processNames.length === 0) {
|
||||||
|
this.ngZone.run(() => this.applyMatchedGame(null));
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const matchedGame = await this.matchRunningGame(processNames);
|
const matchedGame = await this.matchRunningGame(processNames);
|
||||||
|
|
||||||
this.ngZone.run(() => this.applyMatchedGame(matchedGame));
|
this.ngZone.run(() => this.applyMatchedGame(matchedGame));
|
||||||
|
|||||||
@@ -139,4 +139,61 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</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>
|
</div>
|
||||||
|
|||||||
@@ -36,12 +36,16 @@ export class GeneralSettingsComponent {
|
|||||||
closeToTray = signal(true);
|
closeToTray = signal(true);
|
||||||
savingAutoStart = signal(false);
|
savingAutoStart = signal(false);
|
||||||
savingCloseToTray = signal(false);
|
savingCloseToTray = signal(false);
|
||||||
|
ignoredGameProcesses = signal<string[]>([]);
|
||||||
|
ignoredProcessDraft = signal('');
|
||||||
|
savingIgnoredGameProcesses = signal(false);
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.loadGeneralSettings();
|
this.loadGeneralSettings();
|
||||||
|
|
||||||
if (this.isElectron) {
|
if (this.isElectron) {
|
||||||
void this.loadDesktopSettings();
|
void this.loadDesktopSettings();
|
||||||
|
void this.loadIgnoredGameProcesses();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -131,4 +135,61 @@ export class GeneralSettingsComponent {
|
|||||||
this.autoStart.set(snapshot.autoStart);
|
this.autoStart.set(snapshot.autoStart);
|
||||||
this.closeToTray.set(snapshot.closeToTray);
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user