From 3f10e8bd88fa678c3121abedfbf801c8167a5bd1 Mon Sep 17 00:00:00 2001 From: Justsnoopy30 Date: Sat, 18 May 2024 16:18:56 -0500 Subject: [PATCH 1/8] 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 dac7d55..ba38b80 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 c89f33c45a73519a00be9c2a1ecd4237b2e8126b Mon Sep 17 00:00:00 2001 From: Justsnoopy30 Date: Thu, 23 May 2024 14:58:15 -0500 Subject: [PATCH 2/8] 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 ba38b80..f69295e 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 51ea53e1faedcb780ab558912ec2ef82c487f44b Mon Sep 17 00:00:00 2001 From: XenHat Date: Thu, 12 Feb 2026 20:13:09 -0500 Subject: [PATCH 3/8] 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 f69295e..1e189c2 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 every 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 e7016004c6c9a7c8bc4a108e1d99f51fcc8da218 Mon Sep 17 00:00:00 2001 From: XenHat Date: Mon, 16 Feb 2026 22:07:18 -0500 Subject: [PATCH 4/8] 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 1e189c2..20f5de4 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 ad805443895d65b1ac53db4e5fa4360b81d770c1 Mon Sep 17 00:00:00 2001 From: XenHat Date: Tue, 17 Feb 2026 13:49:25 -0500 Subject: [PATCH 5/8] 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 20f5de4..5a74ea5 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) { From a251badab900cd351c31cc294f043b76114cce5d Mon Sep 17 00:00:00 2001 From: XenHat Date: Thu, 2 Apr 2026 14:30:00 -0400 Subject: [PATCH 6/8] Add soulframe executable This may or may not be reverted upon request from Digital Extremes as the game is still in development. --- scripts/tests/ignore.test.js | 1 + src/process/index.js | 5 +++++ 2 files changed, 6 insertions(+) diff --git a/scripts/tests/ignore.test.js b/scripts/tests/ignore.test.js index 43852a1..1f6ca90 100644 --- a/scripts/tests/ignore.test.js +++ b/scripts/tests/ignore.test.js @@ -40,6 +40,7 @@ const keeps = { "/home/user/Games/Lutris/arknights-endfield/drive_c/Program Files/GRYPHLINK/games/EndField Game/Endfield.exe", "ZenlessZoneZero.exe", "obs.exe", + "Soulframe.x64.exe", ], }; diff --git a/src/process/index.js b/src/process/index.js index 5a74ea5..54bec8c 100644 --- a/src/process/index.js +++ b/src/process/index.js @@ -18,6 +18,7 @@ DetectableDBTemp.push({ name: "OBS", }); +// Reminder: executable names must be stored as lowercase as they are converted to such before matching against the database const extraExecutablesById = new Map([ [ "356943187589201930", @@ -34,6 +35,10 @@ const extraExecutablesById = new Map([ [ "1158877933042143272", [{ is_launcher: false, name: "linuxsteamrt64/cs2", os: "linux" }], + ], + [ + "1440129553036083341", + [{ is_launcher: false, name: "soulframe.x64.exe", os: "win32" }], ] ]); From 95cf61d2e24ca63111bcf0d45d8338d94743cd31 Mon Sep 17 00:00:00 2001 From: XenHat Date: Wed, 6 May 2026 15:44:24 -0400 Subject: [PATCH 7/8] process: ignore C:\Windows wine processes on Linux --- src/process/index.js | 1 + 1 file changed, 1 insertion(+) diff --git a/src/process/index.js b/src/process/index.js index 54bec8c..f963316 100644 --- a/src/process/index.js +++ b/src/process/index.js @@ -61,6 +61,7 @@ const IGNORE_RULES = { /webhelper/, /^\/tmp\//, /(\/bin\/|)dolphin$/, // KDE, not emulator + /^C:\\Windows/i, ], win32: [ /^C:\\Windows/i, From d2ecb61697f3986149350078dd9853592a9086ea Mon Sep 17 00:00:00 2001 From: XenHat Date: Sun, 24 May 2026 14:32:56 -0400 Subject: [PATCH 8/8] process: match steam ID when available --- src/process/index.js | 55 +++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 52 insertions(+), 3 deletions(-) diff --git a/src/process/index.js b/src/process/index.js index f963316..3f2dea3 100644 --- a/src/process/index.js +++ b/src/process/index.js @@ -49,6 +49,16 @@ for (const entry of DetectableDBTemp) { const DetectableDB = DetectableDBTemp; import * as Natives from "./native/index.js"; +// Build a lookup map for Steam App IDs from third_party_skus +const steamAppIdToGame = new Map(); +for (const entry of DetectableDB) { + if (!entry.third_party_skus) continue; + for (const sku of entry.third_party_skus) { + if (sku.distributor === 'steam' && sku.id) { + steamAppIdToGame.set(sku.id, entry); + } + } +} // eslint-disable-next-line no-undef const Native = Natives[process.platform]; @@ -149,6 +159,14 @@ const matchesKnownExe = (known, candidates, cwdPath, argsStr) => { // Last resort: allow arg-only matches (previous behavior) return needsArgs && hasReqArgs; }; + +/** Extract Steam App ID from command line arguments. */ +const getSteamAppIdFromArgs = (argsStr) => { + if (!argsStr) return null; + // Match steam_app_id= or SteamLaunch AppId= patterns + const match = argsStr.match(/(?:steam_app_id|SteamLaunch\s+AppId)=(\d+)/i); + return match ? match[1] : null; +}; // ------------------------------------------------------------------------- const timestamps = {}, names = {}, pids = {}; @@ -182,13 +200,12 @@ export default class ProcessServer { const toCompare = buildCandidates(path, cwdPath); for (const { executables, id, name } of DetectableDB) { - if (!executables || !Array.isArray(executables)) continue; + if (!executables || !Array.isArray(executables) || executables.length === 0) continue; const matched = executables.some((k) => matchesKnownExe(k, toCompare, cwdPath, argsStr), ); - if (!matched) continue; - { + if (matched) { names[id] = name; pids[id] = pid; @@ -216,6 +233,38 @@ export default class ProcessServer { }); } } + + // Fallback: Check Steam App ID for games with empty executables + for (const [steamId, gameEntry] of steamAppIdToGame) { + if (getSteamAppIdFromArgs(argsStr) === steamId) { + names[gameEntry.id] = gameEntry.name; + pids[gameEntry.id] = pid; + + ids.push(gameEntry.id); + if (!timestamps[gameEntry.id]) { + log('detected game!', gameEntry.name); + timestamps[gameEntry.id] = Date.now(); + } + + // Resending this on every scan is intentional, so that in the case that arRPC scans processes before Discord, existing activities will be sent + this.handlers.message({ + socketId: gameEntry.id + }, { + cmd: 'SET_ACTIVITY', + args: { + activity: { + application_id: gameEntry.id, + name: gameEntry.name, + timestamps: { + start: timestamps[gameEntry.id] + } + }, + pid + } + }); + break; + } + } } for (const id in timestamps) {