From 1a3190128e2e4565f74f0715b6570bfa42da39fe Mon Sep 17 00:00:00 2001 From: Justsnoopy30 Date: Sat, 18 May 2024 16:18:56 -0500 Subject: [PATCH 1/5] process: improve linux game detection Some games are listed in the discord game database with their parent directories but their launched processes on linux don't always have this included in cmdline. This fixes that by checking processes with their working directories as well. For example, this will now detect DOOM Eternal running through proton. --- src/process/index.js | 7 ++++--- src/process/native/linux.js | 10 ++++++++-- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/src/process/index.js b/src/process/index.js index f445024..17e92d3 100644 --- a/src/process/index.js +++ b/src/process/index.js @@ -34,11 +34,12 @@ export default class ProcessServer { // log(`got processed in ${(performance.now() - startTime).toFixed(2)}ms`); - for (const [ pid, _path, args ] of processes) { + for (const [ pid, _path, args, _cwdPath = '' ] of processes) { const path = _path.toLowerCase().replaceAll('\\', '/'); + const cwdPath = _cwdPath.toLowerCase().replaceAll('\\', '/'); const toCompare = []; const splitPath = path.split('/'); - for (let i = 1; i < splitPath.length; i++) { + for (let i = 1; i < splitPath.length || i == 1; i++) { toCompare.push(splitPath.slice(-i).join('/')); } @@ -52,7 +53,7 @@ export default class ProcessServer { for (const { executables, id, name } of DetectableDB) { if (executables?.some(x => { if (x.is_launcher) return false; - if (x.name[0] === '>' ? x.name.substring(1) !== toCompare[0] : !toCompare.some(y => x.name === y)) return false; + if (x.name[0] === '>' ? x.name.substring(1) !== toCompare[0] : !toCompare.some(y => x.name === y || `${cwdPath}/${y}`.includes(x.name))) return false; if (args && x.arguments) return args.join(" ").indexOf(x.arguments) > -1; return true; })) { diff --git a/src/process/native/linux.js b/src/process/native/linux.js index ed45681..0dc7f4d 100644 --- a/src/process/native/linux.js +++ b/src/process/native/linux.js @@ -1,8 +1,14 @@ -import { readdir, readFile } from "fs/promises"; +import { readdir, readFile, readlink } from "fs/promises"; export const getProcesses = async () => (await Promise.all( (await readdir("/proc")).map(pid => (+pid > 0) && readFile(`/proc/${pid}/cmdline`, 'utf8') - .then(path => [+pid, path.split("\0")[0], path.split("\0").slice(1)], () => 0) + .then(async path => { + let cwdPath; + try { + cwdPath = await readlink(`/proc/${pid}/cwd`); + } catch (err) {}; + return [+pid, path.split("\0")[0], path.split("\0").slice(1), cwdPath] + }, () => 0) ) )).filter(x => x); \ No newline at end of file From d49e76acce29bbdfefdcaa3c690f0310f74ce449 Mon Sep 17 00:00:00 2001 From: Justsnoopy30 Date: Thu, 23 May 2024 14:58:15 -0500 Subject: [PATCH 2/5] process: small fix for false positives --- src/process/index.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/process/index.js b/src/process/index.js index 17e92d3..c7e416e 100644 --- a/src/process/index.js +++ b/src/process/index.js @@ -53,7 +53,7 @@ export default class ProcessServer { for (const { executables, id, name } of DetectableDB) { if (executables?.some(x => { if (x.is_launcher) return false; - if (x.name[0] === '>' ? x.name.substring(1) !== toCompare[0] : !toCompare.some(y => x.name === y || `${cwdPath}/${y}`.includes(x.name))) return false; + if (x.name[0] === '>' ? x.name.substring(1) !== toCompare[0] : !toCompare.some(y => x.name === y || `${cwdPath}/${y}`.includes(`/${x.name}`))) return false; if (args && x.arguments) return args.join(" ").indexOf(x.arguments) > -1; return true; })) { From ea121fe2a6739dc21bfe75e7b17ebde14617b0d2 Mon Sep 17 00:00:00 2001 From: XenHat Date: Thu, 12 Feb 2026 20:13:09 -0500 Subject: [PATCH 3/5] process: further improve game detection for Linux changes: - several performance improvements - match against more (malformed?) game database entries - added simple unit tests --- scripts/tests/ignore.test.js | 60 ++++++++++ scripts/tests/index.js | 25 +++++ scripts/tests/process.test.js | 23 ++++ src/process/index.js | 202 +++++++++++++++++++++++++++------- src/process/native/linux.js | 75 +++++++++++-- 5 files changed, 332 insertions(+), 53 deletions(-) create mode 100644 scripts/tests/ignore.test.js create mode 100644 scripts/tests/index.js create mode 100644 scripts/tests/process.test.js diff --git a/scripts/tests/ignore.test.js b/scripts/tests/ignore.test.js new file mode 100644 index 0000000..43852a1 --- /dev/null +++ b/scripts/tests/ignore.test.js @@ -0,0 +1,60 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { shouldIgnoreProcess } from "../../src/process/index.js"; + +const ignores = { + linux: [ + "/proc/123/exe", + // Important: don't confuse KDE file manager with Dolphin emulator (dolphin-emu) + "/usr/bin/dolphin", + ], + win32: [ + "C:\\Windows\\system32\\winedevice.exe", + "C:\\Windows\\System32\\wdhoersvc.exe", + ], +}; + +const keeps = { + // Linux-native binaries (Steam/others) + linux: [ + "/home/user/.local/share/Steam/steamapps/common/7 Days To Die/7DaysToDie.x86_64", + "/home/user/.local/share/Steam/steamapps/common/Hollow Knight/hollow_knight.x86_64", + "/home/user/.local/share/Steam/steamapps/common/Valheim/valheim.x86_64", + "/home/user/.local/share/Steam/steamapps/common/X4 Foundations/testandlaunch", + "/home/user/.local/share/Steam/steamapps/common/Factorio/bin/x64/factorio", + // Wrapper binary (no path) + "hytale-launcher-wrapper", + "/usr/bin/dolphin-emu", + "/usr/bin/obs", + "obs", // flatpak + ], + // Windows executables launched via Wine/Proton/Lutris (paths live on Linux FS, but "platform" is win32) + win32: [ + "/home/user/.local/share/Steam/steamapps/common/7 Days To Die/7dLauncher.exe", + "/home/user/.local/share/Steam/steamapps/common/Hollow Knight/hollow_knight.exe", + "/home/user/.local/share/Steam/steamapps/common/Valheim/valheim.exe", + "/home/user/.local/share/Steam/steamapps/common/X4 Foundations/X4.exe", + "/home/user/.local/share/Steam/steamapps/common/Factorio/bin/x64/factorio.exe", + "/home/user/.local/share/Steam/steamapps/common/FINAL FANTASY XIV Online/game/ffxiv_dx11.exe", + "/home/user/.local/share/Steam/steamapps/common/Wuthering Waves/Wuthering Waves.exe", + "/home/user/Games/Lutris/arknights-endfield/drive_c/Program Files/GRYPHLINK/games/EndField Game/Endfield.exe", + "ZenlessZoneZero.exe", + "obs.exe", + ], +}; + +Object.entries(ignores).forEach(([os, paths]) => { + paths.forEach((path) => { + test(`ignores (${os}): ${path}`, () => { + assert.equal(shouldIgnoreProcess(path, os), true); + }); + }); +}); + +Object.entries(keeps).forEach(([os, paths]) => { + paths.forEach((path) => { + test(`keeps (${os}): ${path}`, () => { + assert.equal(shouldIgnoreProcess(path, os), false); + }); + }); +}); diff --git a/scripts/tests/index.js b/scripts/tests/index.js new file mode 100644 index 0000000..f00a315 --- /dev/null +++ b/scripts/tests/index.js @@ -0,0 +1,25 @@ +#!/usr/bin/env node + +import { glob } from "node:fs"; +import { promisify } from "node:util"; + +const rgb = (r, g, b, msg) => `\x1b[38;2;${r};${g};${b}m${msg}\x1b[0m`; +const log = (...args) => + console.log( + `[${rgb(88, 101, 242, "arRPC")} > ${rgb(255, 165, 0, "tests")}]`, + ...args, + ); + +const globAsync = promisify(glob); + +log("Running test suite...\n"); + +const testFiles = await globAsync( + new URL("./**.test.js", import.meta.url).pathname, +); + +for (const testFile of testFiles) { + await import(testFile); +} + +log("\nTest suite completed"); diff --git a/scripts/tests/process.test.js b/scripts/tests/process.test.js new file mode 100644 index 0000000..5633e45 --- /dev/null +++ b/scripts/tests/process.test.js @@ -0,0 +1,23 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { createGetProcesses } from "../../src/process/native/linux.js"; + +test("parses cmdline and cwd correctly", async () => { + const fakeFS = { + async readdir() { + return ["123"]; + }, + async readFile() { + return "game\0--foo\0"; + }, + async readlink() { + return "/home/user/Games"; + }, + }; + + const getProcesses = createGetProcesses(fakeFS); + const rows = await getProcesses(); + + assert.equal(rows[0].pid, 123); + assert.equal(rows[0].exe, "game"); +}); diff --git a/src/process/index.js b/src/process/index.js index c7e416e..ebffcf4 100644 --- a/src/process/index.js +++ b/src/process/index.js @@ -5,12 +5,137 @@ import fs from 'node:fs'; import { dirname, join } from 'path'; import { fileURLToPath } from 'url'; -const __dirname = dirname(fileURLToPath(import.meta.url)); -const DetectableDB = JSON.parse(fs.readFileSync(join(__dirname, 'detectable.json'), 'utf8')); +import DetectableDBTemp from "./detectable.json" with { type: "json" }; +DetectableDBTemp.push({ + aliases: ["Obs"], + executables: [ + { is_launcher: false, name: "obs", os: "linux" }, + { is_launcher: false, name: "obs.exe", os: "win32" }, + { is_launcher: false, name: "obs.app", os: "darwin" }, + ], + hook: true, + id: "STREAMERMODE", + name: "OBS", +}); + +const extraExecutablesById = new Map([ + [ + "356943187589201930", + [{ is_launcher: false, name: "dolphin-emu", os: "linux" }], + ], + [ + "1257819671114289184", + [{ is_launcher: false, name: "ZenlessZoneZero.exe", os: "win32" }], + ], +]); + +for (const entry of DetectableDBTemp) { + const extras = extraExecutablesById.get(entry.id); + if (extras) entry.executables.push(...extras); +} +const DetectableDB = DetectableDBTemp; -import * as Natives from './native/index.js'; +import * as Natives from "./native/index.js"; +// eslint-disable-next-line no-undef const Native = Natives[process.platform]; +const IGNORE_RULES = { + linux: [ + /^\/proc\//, + /k(worker|softirq)/, // Linux kernel worker threads + /^\/(usr|)\/(bin|sbin|lib)\/(?!(dolphin-emu|obs))/, + /\/crashpad_handler$/, + /webhelper/, + /^\/tmp\//, + /(\/bin\/|)dolphin$/, // KDE, not emulator + ], + win32: [ + /^C:\\Windows/i, + (path) => path.includes("\\") && !path.includes("/"), // pure win path + ], +}; + +/** Check our rules to determine if a process should be ignored **/ +export function shouldIgnoreProcess(path, os) { + const rules = IGNORE_RULES[os] || []; + return rules.some((rule) => + typeof rule === "function" ? rule(path) : rule.test(path), + ); +} + +// ------------------------ Refactor helpers ------------------------------- +/** Normalize a filesystem path for comparisons. */ +const normPath = (p = "") => String(p).toLowerCase().replaceAll("\\", "/"); + +/** Strip common 64-bit suffixes from executable names. */ +const stripBitness = (s = "") => { + const bitness_suffixes = [".x86_64", ".x64", "_64", "64"]; + for (const suf of bitness_suffixes) { + if (s.endsWith(suf)) return s.slice(0, -suf.length); + } + return s; +}; + +/** + * Build a compact set of candidates we will try to match against the DB. + * Examples returned: ['eldenring.exe', 'eldenring', 'steamapps/common/eldenring.exe'] + */ +const buildCandidates = (rawPath, cwdPath) => { + const out = new Set(); + const p = normPath(rawPath); + + // Drop CLI args if present (e.g., "C:/Games/foo/bar.exe --flag ...") + const noArgs = p.includes(" --") ? p.split(" --")[0] : p; + const base = noArgs.slice(noArgs.lastIndexOf("/") + 1); + + out.add(stripBitness(base)); + + // For Windows-style exe paths, include the last 2 segments to catch DB entries + // We also need to match Linux Native executables, since we swap the suffix later for comparison + if (noArgs.includes(".exe") || noArgs.includes(".x86_64")) { + const last2 = noArgs.split("/").slice(-2).join("/"); + out.add(stripBitness(last2)); + } + + // Also include a cwd-anchored variant to help path.includes matches + if (cwdPath) out.add(`${normPath(cwdPath)}/${stripBitness(base)}`); + + // Add exe-less variant if present + if (base.endsWith(".exe")) out.add(base.replace(/\.exe$/, "")); + + return Array.from(out); +}; + +/** + * Decide whether a known executable entry matches the running process. + * Mirrors legacy behavior but is easier to read & extend. + */ +const matchesKnownExe = (known, candidates, cwdPath, argsStr) => { + if (!known || known.is_launcher) return false; + const kname = known.name || ""; + const needsArgs = Boolean(known.arguments); + const hasReqArgs = + !needsArgs || (argsStr && argsStr.includes(known.arguments)); + + // Special '>' syntax: require exact match to the first candidate + if (kname[0] === ">") { + return candidates[0] === kname.slice(1) && hasReqArgs; + } + + // Try direct name and common variants across all candidates + for (const cand of candidates) { + const running = cand; + if (kname === running) return hasReqArgs; + if (kname === `${running}.exe`) return hasReqArgs; + if (kname === running.replace(/\.exe$/, "")) return hasReqArgs; + if (String(running).includes(`/${kname}`)) return hasReqArgs; // handles cwd + filename + if (kname === running.replace(/\.x86_64$/, ".exe")) return hasReqArgs; + } + + // Last resort: allow arg-only matches (previous behavior) + return needsArgs && hasReqArgs; +}; +// ------------------------------------------------------------------------- const timestamps = {}, names = {}, pids = {}; export default class ProcessServer { @@ -34,29 +159,22 @@ export default class ProcessServer { // log(`got processed in ${(performance.now() - startTime).toFixed(2)}ms`); - for (const [ pid, _path, args, _cwdPath = '' ] of processes) { - const path = _path.toLowerCase().replaceAll('\\', '/'); - const cwdPath = _cwdPath.toLowerCase().replaceAll('\\', '/'); - const toCompare = []; - const splitPath = path.split('/'); - for (let i = 1; i < splitPath.length || i == 1; i++) { - toCompare.push(splitPath.slice(-i).join('/')); - } - - for (const p of toCompare.slice()) { // add more possible tweaked paths for less false negatives - toCompare.push(p.replace('64', '')); // remove 64bit identifiers-ish - toCompare.push(p.replace('.x64', '')); - toCompare.push(p.replace('x64', '')); - toCompare.push(p.replace('_64', '')); - } + // TODO: Make sure this works on windows (see in src/process/win32.js) + for (const { pid, exe: _path, args, cwd: _cwdPath = "" } of processes) { + if (shouldIgnoreProcess(_path, process.platform)) continue; + const argsStr = Array.isArray(args) ? args.join(" ") : ""; + const path = _path.toLowerCase().replaceAll("\\", "/"); + const cwdPath = _cwdPath.toLowerCase().replaceAll("\\", "/"); + const toCompare = buildCandidates(path, cwdPath); for (const { executables, id, name } of DetectableDB) { - if (executables?.some(x => { - if (x.is_launcher) return false; - if (x.name[0] === '>' ? x.name.substring(1) !== toCompare[0] : !toCompare.some(y => x.name === y || `${cwdPath}/${y}`.includes(`/${x.name}`))) return false; - if (args && x.arguments) return args.join(" ").indexOf(x.arguments) > -1; - return true; - })) { + if (!executables || !Array.isArray(executables)) continue; + + const matched = executables.some((k) => + matchesKnownExe(k, toCompare, cwdPath, argsStr), + ); + if (!matched) continue; + { names[id] = name; pids[id] = pid; @@ -69,19 +187,19 @@ export default class ProcessServer { // Resending this on evry scan is intentional, so that in the case that arRPC scans processes before Discord, existing activities will be sent this.handlers.message({ socketId: id - }, { - cmd: 'SET_ACTIVITY', - args: { - activity: { - application_id: id, - name, - timestamps: { + }, { + cmd: 'SET_ACTIVITY', + args: { + activity: { + application_id: id, + name, + timestamps: { start: timestamps[id] } - }, + }, pid - } - }); + } + }); } } } @@ -92,14 +210,14 @@ export default class ProcessServer { delete timestamps[id]; this.handlers.message({ - socketId: id - }, { - cmd: 'SET_ACTIVITY', - args: { - activity: null, - pid: pids[id] - } - }); + socketId: id + }, { + cmd: 'SET_ACTIVITY', + args: { + activity: null, + pid: pids[id] + } + }); } } diff --git a/src/process/native/linux.js b/src/process/native/linux.js index 0dc7f4d..806f3d4 100644 --- a/src/process/native/linux.js +++ b/src/process/native/linux.js @@ -1,14 +1,67 @@ import { readdir, readFile, readlink } from "fs/promises"; -export const getProcesses = async () => (await Promise.all( - (await readdir("/proc")).map(pid => - (+pid > 0) && readFile(`/proc/${pid}/cmdline`, 'utf8') - .then(async path => { - let cwdPath; +const CONCURRENCY_LIMIT = 100; + +/** Limit concurrent operations to avoid resource exhaustion. */ +async function mapLimit(items, limit, fn) { + const res = new Array(items.length); + let i = 0; + + const workers = Array.from({ length: limit }, async () => { + while (true) { + const idx = i++; + if (idx >= items.length) return; + res[idx] = await fn(items[idx], idx); + } + }); + + await Promise.all(workers); + return res; +} + +/** Create a getProcesses function with injectable filesystem. */ +export function createGetProcesses(fs = { readdir, readFile, readlink }) { + async function getProcessInfo(pid) { + try { + let exe = ""; + const cmdline = await fs.readFile(`/proc/${pid}/cmdline`, "utf8"); + const parts = cmdline.split("\0").filter(Boolean); + + if (parts.length > 0) { + exe = parts[0]; + } else { + // Fallback to comm if cmdline is empty (kernel threads) try { - cwdPath = await readlink(`/proc/${pid}/cwd`); - } catch (err) {}; - return [+pid, path.split("\0")[0], path.split("\0").slice(1), cwdPath] - }, () => 0) - ) -)).filter(x => x); \ No newline at end of file + exe = (await fs.readFile(`/proc/${pid}/comm`, "utf8")).trim(); + } catch (e) { + return null; + } + } + + let cwd = ""; + try { + cwd = await fs.readlink(`/proc/${pid}/cwd`); + } catch (err) { + // Expected for processes without accessible cwd + } + + return { pid: +pid, exe, args: parts.slice(1), cwd }; + } catch (err) { + // Ignore ENOENT (process exited); log unexpected errors + if (err.code !== "ENOENT" && process.env.ARRPC_DEBUG) { + console.error(`[process/${pid}]`, err.code || err.message); + } + return null; + } + } + + return async () => { + const entries = await fs.readdir("/proc"); + const pids = entries.filter((name) => /^\d+$/.test(name)).map(Number); + + const results = await mapLimit(pids, CONCURRENCY_LIMIT, getProcessInfo); + return results.filter(Boolean); + }; +} + +export const getProcesses = createGetProcesses(); From 3c0e547ac5e5463286b56a7481180bde7918e4a2 Mon Sep 17 00:00:00 2001 From: XenHat Date: Mon, 16 Feb 2026 22:07:18 -0500 Subject: [PATCH 4/5] process: add Black Mesa for Linux --- src/process/index.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/process/index.js b/src/process/index.js index ebffcf4..c853965 100644 --- a/src/process/index.js +++ b/src/process/index.js @@ -27,6 +27,10 @@ const extraExecutablesById = new Map([ "1257819671114289184", [{ is_launcher: false, name: "ZenlessZoneZero.exe", os: "win32" }], ], + [ + "451540911172747284", + [{ is_launcher: false, name: "bms_linux", os: "linux" }], + ], ]); for (const entry of DetectableDBTemp) { From 04ee4942dfde6e2d445a4eb6ca510d715f1b9f1d Mon Sep 17 00:00:00 2001 From: XenHat Date: Tue, 17 Feb 2026 13:49:25 -0500 Subject: [PATCH 5/5] process: add Counter-Strike 2 for Linux --- src/process/index.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/process/index.js b/src/process/index.js index c853965..2296a19 100644 --- a/src/process/index.js +++ b/src/process/index.js @@ -31,6 +31,10 @@ const extraExecutablesById = new Map([ "451540911172747284", [{ is_launcher: false, name: "bms_linux", os: "linux" }], ], + [ + "1158877933042143272", + [{ is_launcher: false, name: "linuxsteamrt64/cs2", os: "linux" }], + ] ]); for (const entry of DetectableDBTemp) {