mirror of
https://github.com/lhns/steam-frame-nix.git
synced 2026-10-06 03:00:13 +02:00
SteamOS's steamvr.service runs `rm -rf ~/.cache/SteamVR` on every SteamVR start, which deletes vrwebhelper's browser profile and with it the localStorage frame-controls kept its placements in, so they were lost on every SteamVR restart and reboot. steam-ui-patches: per-patch persistent state (`state = true`). The injector keeps ~/.local/state/steam-frame-nix/ui-patches/<name>.json, seeds window.__sfuiStore in a fresh page before the patch runs and writes the file (atomically, on change, name-checked, max 64 KiB) when the page calls the CDP binding __sfuiStoreSave. frame-controls v5: placements saved there (v4 localStorage copy migrated once, then removed). steam-close-button v8: "Steam hidden" saved there; a restored hidden state hides Steam once if SteamVR already shows it when the patch attaches (or first shows it before the patch sees Steam's frame). README: "Changes outside Nix" lists the directory; install.sh uninstall removes it.
199 lines
9.5 KiB
JavaScript
199 lines
9.5 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", // optional
|
|
// "state": true } ] } // optional, see below
|
|
//
|
|
// 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.
|
|
//
|
|
// Persistent state ("state": true): one JSON value per patch, kept in
|
|
// $XDG_STATE_HOME/steam-frame-nix/ui-patches/<name>.json (default
|
|
// ~/.local/state/...), so it survives SteamVR restarts (steamvr.service wipes
|
|
// ~/.cache/SteamVR, the pages' localStorage, on every start) and reboots.
|
|
// Before each evaluation of such a patch a prelude defines, in the page,
|
|
// window.__sfuiStore.get(name) the stored value (undefined: none)
|
|
// window.__sfuiStore.set(name, value) store it (JSON; true if sent)
|
|
// and seeds get(name) from the file only when the page has no value yet (a
|
|
// fresh page after a SteamVR restart or reload): the page's value is
|
|
// authoritative from then on. set() goes through the CDP binding
|
|
// window.__sfuiStoreSave('{"name","value"}'), which this process writes to the
|
|
// file (atomically, only on change, only for "state" patches of that target,
|
|
// at most 64 KiB). The file is user data: nothing deletes it.
|
|
import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
import { join } from 'node:path';
|
|
|
|
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 STATE_DIR = cfg.stateDir ?? join(process.env.XDG_STATE_HOME || join(process.env.HOME ?? '.', '.local/state'), 'steam-frame-nix/ui-patches');
|
|
const STATE_MAX = 64 * 1024;
|
|
const BINDING = '__sfuiStoreSave';
|
|
|
|
// name -> JSON text of the stored value (absent: none), read once at start.
|
|
const stored = new Map();
|
|
const stateFile = (name) => join(STATE_DIR, `${name}.json`);
|
|
function loadState(name) {
|
|
let text;
|
|
try { text = readFileSync(stateFile(name), 'utf8'); } catch { return; } // none yet
|
|
try { stored.set(name, JSON.stringify(JSON.parse(text))); } catch (e) { console.error(`${name}: ignoring ${stateFile(name)}: ${e.message}`); }
|
|
}
|
|
function saveState(name, json) {
|
|
if (stored.get(name) === json) return false;
|
|
mkdirSync(STATE_DIR, { recursive: true });
|
|
const tmp = `${stateFile(name)}.tmp`;
|
|
writeFileSync(tmp, `${json}\n`);
|
|
renameSync(tmp, stateFile(name));
|
|
stored.set(name, json);
|
|
return true;
|
|
}
|
|
// Page side of the store (idempotent), seeding `name` if the page has no value.
|
|
const prelude = (name) => `(() => {
|
|
const s = window.__sfuiStore ??= {
|
|
data: {},
|
|
get(n) { return this.data[n]; },
|
|
set(n, v) {
|
|
this.data[n] = v;
|
|
try { if (typeof window.${BINDING} !== 'function') return false; window.${BINDING}(JSON.stringify({ name: n, value: v })); return true; } catch { return false; }
|
|
},
|
|
};
|
|
const n = ${JSON.stringify(name)};
|
|
if (!(n in s.data) && ${stored.has(name)}) s.data[n] = ${stored.get(name) ?? 'undefined'};
|
|
return 'ok';
|
|
})()`;
|
|
|
|
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,
|
|
state: !!p.state,
|
|
};
|
|
});
|
|
for (const p of patches) if (p.state) loadState(p.name);
|
|
|
|
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) {
|
|
if (p.state) await evaluate(prelude(p.name));
|
|
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 };
|
|
const stateful = new Set(list.filter((p) => p.state).map((p) => p.name));
|
|
const onSave = (payload) => {
|
|
try {
|
|
if (payload.length > STATE_MAX) throw new Error(`${payload.length} bytes, max ${STATE_MAX}`);
|
|
const { name, value: v } = JSON.parse(payload);
|
|
if (!stateful.has(name)) throw new Error(`no state for ${JSON.stringify(name)} here`);
|
|
if (v === undefined) throw new Error('no value');
|
|
if (saveState(name, JSON.stringify(v))) console.log(`${tag} ${name}: state saved`);
|
|
} catch (e) { console.error(`${tag} state save refused: ${e.message}`); }
|
|
};
|
|
sessions.set(key, session);
|
|
|
|
ws.onopen = async () => {
|
|
connected = true;
|
|
console.log(`${tag} connected (${endpoint}): ${list.map((p) => p.name).join(', ')}`);
|
|
await call('Runtime.enable');
|
|
if (stateful.size) await call('Runtime.addBinding', { name: BINDING });
|
|
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();
|
|
else if (m.method === 'Runtime.bindingCalled' && m.params?.name === BINDING && stateful.size) onSave(String(m.params.payload));
|
|
};
|
|
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);
|
|
}
|