Files
lhns--steam-frame-nix/modules/steam-ui-patches/injector.mjs
T
Pierre Kisters 24d6504f30 Add steam-ui-patches (generic Steam UI runtime patches) and launcher-menu
steamFrame.uiPatches.patches registers JS patches that the
steam-ui-patches user service keeps injected into Steam's (or SteamVR's)
web UI over the local CEF DevTools ports and reverts when it stops.

steamFrame.launcherMenu uses it for the VR "+" menu: sort = true sorts
the programs by name; pinDesktop = "top" / "bottom" pins Desktop above or
below the scrolling list.
2026-09-27 19:36:12 +02:00

133 lines
6.1 KiB
JavaScript

// injector.mjs: keeps runtime patches injected into Steam's UI (CEF) through
// Chrome DevTools, and reverts them when stopped.
// usage: node injector.mjs <config.json>
//
// config.json (generated by steam-ui-patches.nix):
// { "pollMs": 5000, // target discovery / reconnect interval
// "reinjectMs": 15000, // periodic re-injection
// "debounceMs": 3000, // delay after a burst of new JS contexts
// "unpatchTimeoutMs": 2000,
// "patches": [ { "name": "launcher-menu-order",
// "endpoint": "http://127.0.0.1:8080",
// "target": { "title": "SharedJSContext" }, // or titleRegex / urlRegex
// "patch": "/nix/store/...-patch.js",
// "unpatch": "/nix/store/...-unpatch.js" } ] } // unpatch optional
//
// Every target (page of an endpoint's /json/list) matched by at least one
// patch gets one DevTools session, which evaluates all of its patches: right
// away, after new execution contexts (debounced) and every reinjectMs.
// Patches must be idempotent expressions; their (awaited) result value is
// logged when it changes, e.g. "patched" once, then "unchanged" silently.
// On SIGTERM/SIGINT every live session evaluates its patches' unpatch
// expressions (if any) (each with a timeout), then the process exits.
import { readFileSync } from 'node:fs';
const cfg = JSON.parse(readFileSync(process.argv[2], 'utf8'));
const POLL = cfg.pollMs ?? 5000;
const REINJECT = cfg.reinjectMs ?? 15000;
const DEBOUNCE = cfg.debounceMs ?? 3000;
const UNPATCH_TIMEOUT = cfg.unpatchTimeoutMs ?? 2000;
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const patches = cfg.patches.map((p) => {
const t = p.target ?? {};
const title = t.title, titleRe = t.titleRegex && new RegExp(t.titleRegex), urlRe = t.urlRegex && new RegExp(t.urlRegex);
return {
name: p.name,
endpoint: (p.endpoint ?? 'http://127.0.0.1:8080').replace(/\/+$/, ''),
matches: (x) => (title === undefined || x.title === title) &&
(!titleRe || titleRe.test(x.title)) && (!urlRe || urlRe.test(x.url)),
patch: readFileSync(p.patch, 'utf8'),
unpatch: p.unpatch ? readFileSync(p.unpatch, 'utf8') : null,
};
});
const sessions = new Map(); // "<endpoint> <target id>" -> session
let stopping = false;
// Result value of a Runtime.evaluate reply, or the exception text.
const value = (r) => r.error?.message ?? r.result?.exceptionDetails?.exception?.description ??
r.result?.exceptionDetails?.text ?? r.result?.result?.value ?? r.result?.result?.type ?? 'no reply';
function open(endpoint, target, list) {
const key = `${endpoint} ${target.id}`;
const tag = `[${target.title}]`;
const ws = new WebSocket(target.webSocketDebuggerUrl);
let id = 0, soon = null, timer = null, connected = false;
const pending = new Map();
const last = new Map(); // patch name -> last logged result
const call = (method, params = {}) => new Promise((res) => {
if (ws.readyState !== WebSocket.OPEN) return res({ error: { message: 'closed' } });
const i = ++id; pending.set(i, res); ws.send(JSON.stringify({ id: i, method, params }));
});
const evaluate = (expression) => call('Runtime.evaluate', { expression, returnByValue: true, awaitPromise: true });
const inject = async () => {
for (const p of list) {
const v = String(value(await evaluate(p.patch)));
if (last.get(p.name) !== v && v !== 'unchanged') console.log(`${tag} ${p.name}: ${v}`);
last.set(p.name, v);
}
};
const injectSoon = () => { clearTimeout(soon); soon = setTimeout(() => inject().catch(() => {}), DEBOUNCE); };
const unpatch = () => Promise.all(list.filter((p) => p.unpatch).map(async (p) => {
const r = await Promise.race([evaluate(p.unpatch), sleep(UNPATCH_TIMEOUT).then(() => ({ error: { message: 'timeout' } }))]);
console.log(`${tag} ${p.name} unpatch: ${value(r)}`);
}));
const session = { unpatch };
sessions.set(key, session);
ws.onopen = async () => {
connected = true;
console.log(`${tag} connected (${endpoint}): ${list.map((p) => p.name).join(', ')}`);
await call('Runtime.enable');
await inject().catch((e) => console.error(`${tag} inject:`, e.message));
timer = setInterval(() => inject().catch(() => {}), REINJECT);
};
ws.onmessage = (e) => {
const m = JSON.parse(e.data);
if (m.id && pending.has(m.id)) { pending.get(m.id)(m); pending.delete(m.id); }
else if (m.method === 'Runtime.executionContextCreated' && !stopping) injectSoon();
};
ws.onerror = () => {}; // followed by onclose
ws.onclose = () => {
if (sessions.get(key) === session) sessions.delete(key);
clearTimeout(soon); clearInterval(timer);
for (const r of pending.values()) r({ error: { message: 'closed' } });
pending.clear();
if (connected) console.log(`${tag} disconnected`);
};
}
// Discovery: connect to every matching target that has no session yet.
// Sessions of vanished targets end on their own (socket closes).
const down = new Set();
async function poll(endpoint, list) {
let targets;
try {
targets = await (await fetch(`${endpoint}/json/list`, { signal: AbortSignal.timeout(3000) })).json();
if (down.delete(endpoint)) console.log(`${endpoint} up`);
} catch (e) {
if (!down.has(endpoint)) { down.add(endpoint); console.error(`${endpoint} unreachable: ${e.message}`); }
return;
}
for (const t of targets) {
if (stopping || !t.webSocketDebuggerUrl || sessions.has(`${endpoint} ${t.id}`)) continue;
const matching = list.filter((p) => p.matches(t));
if (matching.length) open(endpoint, t, matching);
}
}
for (const sig of ['SIGTERM', 'SIGINT']) process.on(sig, async () => {
if (stopping) return;
stopping = true;
await Promise.all([...sessions.values()].map((s) => s.unpatch()));
setTimeout(() => process.exit(0), 200);
});
const endpoints = [...new Set(patches.map((p) => p.endpoint))];
console.log(`patches: ${patches.map((p) => `${p.name} @ ${p.endpoint}`).join(', ')}`);
while (!stopping) {
await Promise.all(endpoints.map((ep) => poll(ep, patches.filter((p) => p.endpoint === ep))));
await sleep(POLL);
}