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 f445024..2296a19 100644 --- a/src/process/index.js +++ b/src/process/index.js @@ -5,12 +5,145 @@ 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" }], + ], + [ + "451540911172747284", + [{ is_launcher: false, name: "bms_linux", os: "linux" }], + ], + [ + "1158877933042143272", + [{ is_launcher: false, name: "linuxsteamrt64/cs2", os: "linux" }], + ] +]); + +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,28 +167,22 @@ export default class ProcessServer { // log(`got processed in ${(performance.now() - startTime).toFixed(2)}ms`); - for (const [ pid, _path, args ] of processes) { - const path = _path.toLowerCase().replaceAll('\\', '/'); - const toCompare = []; - const splitPath = path.split('/'); - for (let i = 1; i < splitPath.length; 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)) 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; @@ -68,19 +195,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 - } - }); + } + }); } } } @@ -91,14 +218,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 ed45681..806f3d4 100644 --- a/src/process/native/linux.js +++ b/src/process/native/linux.js @@ -1,8 +1,67 @@ -import { readdir, readFile } 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) - ) -)).filter(x => x); \ No newline at end of file +import { readdir, readFile, readlink } from "fs/promises"; + +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 { + 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();