UI patches: find Steam internals by signature, offline checker

Runtime patches no longer use webpack module ids or minified export
names, which change with every Steam UI build:

- modules/lib/finders.js: finder library (getWebpackRequire,
  findModule/findExport by source text and shape, resolveAll with a
  per-page cache, findFiberUp/findFiberDown/findInReactTree), installed
  once per page as window.__sfuiFind.
- modules/lib/signatures.json: the signatures, per patch, shared by the
  patches and the checker. lib/default.nix mkPatch wraps a patch
  `(find, sigs, opts) => ...` with the library, its signatures and
  options; exposed as steamFrame.uiPatches.lib.
- Keyboard patch (VERSION 10): layouts module, current/enabled layout
  getters, arrow keys, VR keyboard status and VirtualKeyboardManager are
  found by signature; if one doesn't match it reports which and leaves
  Steam untouched. unpatch.js uses references the patch remembered.
  The helper logs inject results when they change.
- launcher-menu launch/pinned-desktop use the library's fiber helpers;
  launch no longer depends on the bar popup's window name.
- scripts/check-signatures.mjs (+ webpack-modules.mjs): extracts module
  factories from the installed bundles without a browser and checks
  every signature (found / ambiguous / missing, with module ids and
  export names). README: "Finders and signatures", "After a Steam update".
This commit is contained in:
Pierre Kisters committed 2026-09-27 20:04:15 +02:00
1 parent dd6f3602aa
commit 6a8924f219
14 files changed
+1022 -78

No files matched your search

+213
View File
@@ -0,0 +1,213 @@
#!/usr/bin/env node
// check-signatures.mjs: checks, without Steam running and without a browser,
// that every finder signature the runtime UI patches use (modules/lib/
// signatures.json) still matches the installed Steam / SteamVR web UI
// bundles: each module signature must match exactly one webpack module, each
// export signature exactly one export of it, and the strings a patch relies
// on ("expects") should still be there. Run it after a Steam update:
//
// nix shell nixpkgs#nodejs -c node scripts/check-signatures.mjs
//
// options:
// --signatures FILE extra signatures file (same format, merged; e.g. for
// your own patches); repeatable
// --dir BUNDLE=DIR bundle directory override, e.g. --dir steamui=/path
// --patch NAME check only this patch; repeatable
// --strict also fail on "expects" warnings
// --json machine-readable result
// Exit status: 0 all found, 1 something missing/ambiguous (or a warning with
// --strict), 2 usage/IO error.
//
// Modules are extracted by webpack-modules.mjs (factory sources exactly as
// Function.prototype.toString sees them in the page). To check export
// signatures, the matched module's factory is run in a throwaway VM context
// in which every import and unknown global is an inert stub, so top-level
// definitions (objects, functions, classes, singletons) exist and are
// matched with the same code the patches use (modules/lib/finders.js).
import { readFileSync, existsSync } from 'node:fs';
import { dirname, join, resolve } from 'node:path';
import { homedir } from 'node:os';
import { fileURLToPath } from 'node:url';
import vm from 'node:vm';
import { loadBundles, pageFiles } from './webpack-modules.mjs';
const here = dirname(fileURLToPath(import.meta.url));
const libDir = join(here, '..', 'modules', 'lib');
// ---- arguments -----------------------------------------------------------------
const args = process.argv.slice(2);
const sigFiles = [join(libDir, 'signatures.json')];
const dirOverride = {}, onlyPatches = new Set();
let strict = false, asJson = false;
for (let i = 0; i < args.length; i++) {
const a = args[i];
const val = () => { if (i + 1 >= args.length) usage(`${a} needs a value`); return args[++i]; };
if (a === '--signatures') sigFiles.push(resolve(val()));
else if (a === '--dir') { const [b, ...d] = val().split('='); dirOverride[b] = d.join('='); }
else if (a === '--patch') onlyPatches.add(val());
else if (a === '--strict') strict = true;
else if (a === '--json') asJson = true;
else usage(`unknown argument ${a}`);
}
function usage(msg) {
console.error(`${msg}\nusage: check-signatures.mjs [--signatures FILE]... [--dir BUNDLE=DIR]... [--patch NAME]... [--strict] [--json]`);
process.exit(2);
}
const bundles = {}, patches = {};
for (const f of sigFiles) {
let j;
try { j = JSON.parse(readFileSync(f, 'utf8')); } catch (e) { console.error(`${f}: ${e.message}`); process.exit(2); }
Object.assign(bundles, j.bundles);
Object.assign(patches, j.patches);
}
const find = vm.runInNewContext(readFileSync(join(libDir, 'finders.js'), 'utf8'), {});
// ---- offline module execution ------------------------------------------------------
// An inert value: any property, call or construction yields a stub again.
// Calls with a single function argument return it (HOCs, class decorators),
// (target, key, descriptor) calls return the descriptor (member decorators).
function makeStub() {
let stub;
const handler = {
get(t, k) {
if (k === Symbol.toPrimitive) return (hint) => (hint === 'number' ? 0 : '');
if (k === Symbol.iterator) return function* () { for (let i = 0; i < 4; i++) yield stub; };
if (k === 'then') return undefined;
if (k === 'length') return 0;
if (k === 'name') return 'stub';
return stub;
},
set: () => true,
apply(t, self, a) {
if (a.length === 1 && typeof a[0] === 'function') return a[0];
if (a.length === 3 && typeof a[1] === 'string' && a[2] && typeof a[2] === 'object') return a[2];
return stub;
},
construct: () => stub,
};
stub = new Proxy(function stub() {}, handler);
return stub;
}
function webpackRequire(stub) {
const own = Object.prototype.hasOwnProperty;
const helpers = {
d(e, defs) { for (const k in defs) if (own.call(defs, k) && !own.call(e, k)) Object.defineProperty(e, k, { enumerable: true, get: defs[k] }); },
o: (o, k) => own.call(o, k),
r(e) {
if (typeof Symbol !== 'undefined' && Symbol.toStringTag) Object.defineProperty(e, Symbol.toStringTag, { value: 'Module' });
Object.defineProperty(e, '__esModule', { value: true });
},
n(m) { const g = m && m.__esModule ? () => m.default : () => m; helpers.d(g, { a: g }); return g; },
nmd: (m) => m, hmd: (m) => m,
};
const req = function () { return stub; };
return new Proxy(req, { get: (t, k) => (k in helpers ? helpers[k] : k === 'prototype' ? t.prototype : stub) });
}
// Runs a module factory in a fresh context: { exports, error }.
function runModule(mod) {
const stub = makeStub();
const ctx = vm.createContext({});
vm.runInContext('var window = globalThis, self = globalThis;', ctx);
for (const g of ['document', 'navigator', 'location', 'localStorage', 'sessionStorage', 'SteamClient', 'history'])
ctx[g] = stub;
const factory = vm.runInContext('(' + mod.source + ')', ctx);
let module;
for (let tries = 0; tries < 200; tries++) {
module = { id: mod.id, loaded: false, exports: {} };
ctx.__sfui = { factory, module, req: webpackRequire(stub) };
try {
vm.runInContext('__sfui.factory.call(__sfui.module.exports, __sfui.module, __sfui.module.exports, __sfui.req)', ctx, { timeout: 5000 });
return { exports: module.exports };
} catch (e) {
const m = e?.name === 'ReferenceError' && /^([\w$]+) is not defined$/.exec(e.message);
if (m && !(m[1] in ctx)) { ctx[m[1]] = stub; continue; }
return { exports: module.exports, error: `${e?.name}: ${e?.message}` };
}
}
return { exports: module.exports, error: 'too many undefined globals' };
}
// ---- checking ------------------------------------------------------------------------
const expand = (p) => (p.startsWith('~/') ? join(homedir(), p.slice(2)) : p);
const loaded = {};
function bundle(name) {
if (name in loaded) return loaded[name];
const b = bundles[name];
if (!b) return (loaded[name] = { error: `unknown bundle ${name}` });
const dir = expand(dirOverride[name] ?? b.dir);
if (!existsSync(dir)) return (loaded[name] = { error: `${dir} missing` });
const files = b.html ? pageFiles(dir, b.html) : undefined;
const mods = loadBundles(dir, { files, exclude: b.exclude && new RegExp(b.exclude) });
const req = { m: Object.fromEntries([...mods].map(([id, m]) => [id, m.factory])) };
let version = null;
for (const f of ['changelist.txt']) if (existsSync(join(dir, f))) version = readFileSync(join(dir, f), 'utf8').trim();
for (const f of files ?? []) {
if (version) break;
try { version = /\bCLSTAMP="(\d+)"/.exec(readFileSync(join(dir, f), 'utf8'))?.[1] ?? null; } catch { /* missing file */ }
}
if (!version) {
for (const m of mods.values()) {
const v = /CLSTAMP="(\d+)"/.exec(m.source) ?? /\b[\w$]="(\d{7,9})"/.exec(m.source);
if (v) { version = v[1]; break; }
}
}
return (loaded[name] = { dir, mods, req, version });
}
const report = { ok: true, warnings: 0, patches: {} };
for (const [pname, p] of Object.entries(patches)) {
if (onlyPatches.size && !onlyPatches.has(pname)) continue;
const b = bundle(p.bundle);
const pr = report.patches[pname] = { bundle: p.bundle, modules: {} };
if (b.error) { pr.skipped = b.error; continue; }
for (const [mname, sig] of Object.entries(p.modules ?? {})) {
const r = pr.modules[mname] = { checkOnly: !!sig.checkOnly, signature: sig.module };
const ids = find.findAllModules(b.req, sig.module);
r.ids = ids;
r.files = ids.map((id) => b.mods.get(id).file);
r.status = ids.length === 1 ? 'found' : ids.length ? 'ambiguous' : 'missing';
if (r.status !== 'found') { report.ok = false; continue; }
const mod = b.mods.get(ids[0]);
r.missingExpects = (sig.expects ?? []).filter((s) => !mod.source.includes(s));
if (r.missingExpects.length) report.warnings++;
if (!sig.exports) continue;
const run = runModule(mod);
if (run.error) r.runError = run.error;
r.exports = {};
for (const [ename, esig] of Object.entries(sig.exports)) {
const hits = find.findAllExports(run.exports, esig).map(([k]) => k);
const status = hits.length === 1 ? 'found' : hits.length ? 'ambiguous' : run.error ? 'unverified' : 'missing';
r.exports[ename] = { status, keys: hits, signature: esig };
if (status === 'ambiguous' || status === 'missing') report.ok = false;
if (status === 'unverified') report.warnings++;
}
}
}
if (strict && report.warnings) report.ok = false;
if (asJson) {
console.log(JSON.stringify({ ...report, bundles: Object.fromEntries(Object.entries(loaded).map(([k, v]) =>
[k, v.error ? { error: v.error } : { dir: v.dir, modules: v.mods.size, version: v.version }])) }, null, 2));
} else {
for (const [k, v] of Object.entries(loaded)) {
if (v.error) console.log(`bundle ${k}: skipped (${v.error})`);
else console.log(`bundle ${k}: ${v.mods.size} modules in ${v.dir}${v.version ? ` (build ${v.version})` : ''}`);
}
for (const [pname, pr] of Object.entries(report.patches)) {
console.log(`\n${pname} (${pr.bundle})${pr.skipped ? `: skipped, ${pr.skipped}` : ''}`);
for (const [mname, r] of Object.entries(pr.modules)) {
const where = r.ids.map((id, i) => `${id} (${r.files[i]})`).join(', ');
console.log(` ${mname.padEnd(22)} ${r.status.padEnd(10)} ${r.status === 'missing' ? 'no module matches ' + JSON.stringify(r.signature) : 'module ' + where}${r.checkOnly ? ' [check only]' : ''}`);
for (const [ename, e] of Object.entries(r.exports ?? {}))
console.log(` .${ename.padEnd(20)} ${e.status.padEnd(10)} ${e.keys.length ? 'export ' + e.keys.map((k) => k || '(module.exports)').join(', ') : 'no export matches ' + JSON.stringify(e.signature)}`);
if (r.runError) console.log(` note: module factory threw offline (${r.runError}); unresolved exports are "unverified"`);
if (r.missingExpects?.length) console.log(` WARNING: module no longer contains ${r.missingExpects.map((s) => JSON.stringify(s)).join(', ')}`);
}
}
console.log(`\n${report.ok ? 'OK' : 'FAILED'}: ${report.ok ? 'all signatures match exactly once' : 'some signatures are missing or ambiguous'}` +
(report.warnings ? `, ${report.warnings} warning(s)` : ''));
}
process.exit(report.ok ? 0 : 1);
+165
View File
@@ -0,0 +1,165 @@
// webpack-modules.mjs: extracts webpack module factories from built bundles
// without a browser and without executing the bundles (no dependencies).
//
// import { loadBundles } from './webpack-modules.mjs';
// const mods = loadBundles('/home/deck/.local/share/Steam/steamui');
// // Map "<id>" -> { id, file, factory, source }
//
// Module maps are object literals of `<id>: <factory>` entries, found at
// - `.push([[<chunk ids>],{…}` (chunk files: webpackChunk<name>.push), and
// - `<x>={<id>:…` (the runtime's own modules, e.g. library.js).
// A small tokenizer (strings, templates, comments, regex literals) finds the
// literal's closing brace; the literal alone is then evaluated in a fresh VM
// context, which only creates the factory functions (nothing runs), so
// `source` is exactly Function.prototype.toString of the factory, as a
// finder sees it in the running page (require.m[id]).
import { readFileSync, readdirSync, statSync } from 'node:fs';
import { join } from 'node:path';
import vm from 'node:vm';
const KEYWORDS_BEFORE_EXPR = new Set(['return', 'typeof', 'instanceof', 'in', 'of', 'new', 'delete',
'void', 'throw', 'case', 'do', 'else', 'yield', 'await']);
// Index just past the bracket matching src[start] ('{', '(' or '[').
export function matchBracket(src, start) {
const stack = []; // '{', '(', '[', or '`' (inside ${ } of a template)
let i = start, prev = '('; // prev: last significant token ('w' word, ')' etc.)
const n = src.length;
const skipString = (q) => {
for (i++; i < n; i++) {
const c = src[i];
if (c === '\\') { i++; continue; }
if (c === q) { i++; return; }
}
throw new Error('unterminated string');
};
// Template body from i (just after ` or }); stops after closing ` or at ${.
const skipTemplate = () => {
for (; i < n; i++) {
const c = src[i];
if (c === '\\') { i++; continue; }
if (c === '`') { i++; return false; }
if (c === '$' && src[i + 1] === '{') { i += 2; return true; }
}
throw new Error('unterminated template');
};
const skipRegex = () => {
let cls = false;
for (i++; i < n; i++) {
const c = src[i];
if (c === '\\') { i++; continue; }
if (c === '\n') throw new Error('unterminated regex');
if (cls) { if (c === ']') cls = false; continue; }
if (c === '[') cls = true;
else if (c === '/') { i++; while (i < n && /[a-z]/i.test(src[i])) i++; return; }
}
};
while (i < n) {
const c = src[i];
if (c === ' ' || c === '\n' || c === '\t' || c === '\r') { i++; continue; }
if (c === '/' && src[i + 1] === '/') { const e = src.indexOf('\n', i); i = e < 0 ? n : e; continue; }
if (c === '/' && src[i + 1] === '*') { const e = src.indexOf('*/', i + 2); i = e < 0 ? n : e + 2; continue; }
if (c === '"' || c === "'") { skipString(c); prev = 'v'; continue; }
if (c === '`') { i++; if (skipTemplate()) stack.push('`'); prev = 'v'; continue; }
if (c === '/') {
const regexOk = prev !== 'v' && prev !== ')' && prev !== ']' && prev !== '}' &&
!(prev.startsWith('w') && !KEYWORDS_BEFORE_EXPR.has(prev.slice(1)));
if (regexOk) { skipRegex(); prev = 'v'; continue; }
i++; prev = '/'; continue;
}
if (/[A-Za-z0-9_$\u0080-\uffff]/.test(c)) {
const s = i;
while (i < n && /[A-Za-z0-9_$\u0080-\uffff.]/.test(src[i])) {
if (src[i] === '.' && !/[0-9]/.test(src[s])) break; // number like 1.5, not a.b
i++;
}
prev = /[0-9]/.test(src[s]) ? 'v' : 'w' + src.slice(s, i);
continue;
}
if (c === '{' || c === '(' || c === '[') { stack.push(c); i++; prev = c; continue; }
if (c === '}' || c === ')' || c === ']') {
const open = stack.pop();
i++;
if (open === '`') { if (skipTemplate()) stack.push('`'); prev = 'v'; continue; }
if (stack.length === 0) return i;
prev = c;
continue;
}
i++; prev = c;
}
throw new Error('unbalanced');
}
// Factories of one bundle file: [{ id, factory, source }].
export function extractModules(src) {
const out = [];
const starts = [];
for (const m of src.matchAll(/\.push\(\[\[[^\]]*\],\{/g)) starts.push(m.index + m[0].length - 1);
for (const m of src.matchAll(/[\w$]=\{\d+:/g)) starts.push(m.index + 2);
starts.sort((a, b) => a - b);
let end = -1;
for (const s of starts) {
if (s < end) continue; // inside a map already extracted
let e;
try { e = matchBracket(src, s); } catch { continue; }
let obj;
try { obj = vm.runInNewContext('(' + src.slice(s, e) + ')'); } catch { continue; }
const entries = Object.entries(obj ?? {});
if (!entries.length || !entries.every(([k, v]) => /^\d+$/.test(k) && typeof v === 'function')) continue;
end = e;
for (const [id, factory] of entries) out.push({ id, factory, source: Function.prototype.toString.call(factory) });
}
return out;
}
// The bundle files a page loads, relative to dir: the <script src> files of
// its HTML and, if a script is a webpack runtime with lazily loaded chunks
// (`__webpack_require__.u = id => ({id: name}[id] || id) + ".js?contenthash=" +
// {id: hash}[id]`), every chunk file it can load. Stale files an update left
// behind are thus ignored.
export function pageFiles(dir, html) {
const page = readFileSync(join(dir, html), 'utf8');
const scripts = [...page.matchAll(/<script[^>]*\bsrc="\/?([^"?#]+)/g)].map((m) => m[1]);
const files = [...scripts];
const obj = (src, i) => vm.runInNewContext('(' + src.slice(i, matchBracket(src, i)) + ')');
for (const f of scripts) {
let src;
try { src = readFileSync(join(dir, f), 'utf8'); } catch { continue; }
const u = /\.u=([\w$]+)=>""\+\(\{/.exec(src);
if (!u) continue;
const names = obj(src, u.index + u[0].length - 1);
const h = src.indexOf('contenthash="+{', u.index);
const ids = h >= 0 && h - u.index < 200000 ? Object.keys(obj(src, h + 'contenthash="+'.length)) : Object.keys(names);
for (const id of ids) {
const file = (names[id] ?? id) + '.js';
if (!files.includes(file)) files.push(file);
}
}
return files;
}
// Factories of the bundle files in dir: Map id -> { id, file, factory, source }
// (file relative to dir). opts.files: the files to read, in this order
// (default: every *.js under dir, recursively); opts.exclude: RegExp of
// relative paths to skip. The first definition of an id wins (a module
// bundled into several chunks is minified separately in each, so their
// sources can differ in local names); such ids are listed in the map's
// `conflicts` property.
export function loadBundles(dir, { files, exclude } = {}) {
const mods = new Map();
mods.conflicts = [];
files ??= readdirSync(dir, { recursive: true }).map(String).filter((f) => f.endsWith('.js')).sort();
for (const f of files) {
if (exclude?.test(f)) continue;
const p = join(dir, f);
if (!statSync(p).isFile()) continue;
const src = readFileSync(p, 'utf8');
if (!/webpackChunk|[\w$]=\{\d+:/.test(src)) continue;
for (const m of extractModules(src)) {
const had = mods.get(m.id);
if (!had) mods.set(m.id, { ...m, file: f });
else if (had.source !== m.source) mods.conflicts.push(m.id);
}
}
return mods;
}