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

+15 -8
View File
@@ -13,10 +13,13 @@
# the menu stays open until the program's window appears, inviting double
# launches). Options are passed by calling the patch's function.
# All are reverted when turned off (next switch).
# Depends on Steam UI internals; tested with Steam client 1790377368.
# Depends on Steam UI internals (React props, popup names), not on webpack
# module ids; scripts/check-signatures.mjs checks them after a Steam update
# (lib/signatures.json). Tested with Steam client 1790377368.
{ config, lib, pkgs, ... }:
let
cfg = config.steamFrame.launcherMenu;
inherit (import ./lib { inherit pkgs; }) mkPatch;
sharedJSContext = { title = "SharedJSContext"; };
in {
imports = [ ./steam-ui-patches.nix ];
@@ -78,20 +81,24 @@ in {
++ lib.optional (cfg.pinDesktop != null) {
name = "launcher-menu-pinned-desktop";
target = sharedJSContext;
patch = pkgs.writeText "launcher-menu-pinned-desktop.js" ''
(${builtins.readFile ./launcher-menu/pinned-desktop/patch.js})(${builtins.toJSON { position = cfg.pinDesktop; }})
'';
patch = mkPatch {
name = "launcher-menu-pinned-desktop";
src = ./launcher-menu/pinned-desktop/patch.js;
opts.position = cfg.pinDesktop;
};
unpatch = ./launcher-menu/pinned-desktop/unpatch.js;
}
++ lib.optional (cfg.closeOnLaunch || cfg.launchDebounce > 0) {
name = "launcher-menu-launch";
target = sharedJSContext;
patch = pkgs.writeText "launcher-menu-launch.js" ''
(${builtins.readFile ./launcher-menu/launch/patch.js})(${builtins.toJSON {
patch = mkPatch {
name = "launcher-menu-launch";
src = ./launcher-menu/launch/patch.js;
opts = {
inherit (cfg) closeOnLaunch;
debounceSeconds = cfg.launchDebounce;
}})
'';
};
};
unpatch = ./launcher-menu/launch/unpatch.js;
};
}
+17 -16
View File
@@ -1,8 +1,10 @@
// launch: what happens when a program of the VR dashboard's "+" menu
// (#VRDashboard_LaunchNonSteamApp) is activated.
//
// This file is a function expression; launcher-menu.nix calls it with the
// options: (<this file>)({ closeOnLaunch: true, debounceSeconds: 10 }).
// This file is a function expression, called by the file lib/default.nix
// (mkPatch) generates: (<this file>)(find, sigs, opts), with find the finder
// library (lib/finders.js) and opts the options from launcher-menu.nix, e.g.
// { closeOnLaunch: true, debounceSeconds: 10 }.
//
// Stock, an item's onActivate only calls
// SteamClient.Apps.LaunchNonSteamApp(strCmdline) (plus the nav sound); the
@@ -15,15 +17,18 @@
// in the steam-ui-patches journal on the next re-injection);
// - closeOnLaunch: after a launch (ignored or not), the "+" popup is closed
// through the bar button's own popup handle (closePopup(), what stock does
// after adding a desktop window). The handle is found in the dashboard bar
// document: the button's React fiber ancestors include the "+" component
// (prop allowLaunchProgram) and the bar button (prop refBarPopopHandle).
// after adding a desktop window). The handle is found through React props,
// not minified names: in any popup document (the dashboard bar's), a
// .VRDashboardBarSmallButton element whose fiber ancestors include the bar
// button (prop refBarPopopHandle) and, above it, the "+" component (prop
// allowLaunchProgram). scripts/check-signatures.mjs checks these names
// ("launcher-menu-launch" in lib/signatures.json).
// Works for every activation path (pointer, controller, the pinned Desktop
// copy, which clicks the stock item). The original is kept as __sfuiOrig
// (unpatch.js restores it). Idempotent; bump VERSION when changing the
// wrapper.
((opts) => {
const VERSION = 1;
((find, sigs, opts) => {
const VERSION = 2;
const NAME = 'launcher-menu-launch';
const CLOSE = opts.closeOnLaunch === true;
const DEBOUNCE_MS = Math.max(0, Number(opts.debounceSeconds) || 0) * 1000;
@@ -39,22 +44,18 @@
}
const orig = cur.__sfuiPatch === NAME ? cur.__sfuiOrig : cur;
const fiberOf = (el) => { const k = Object.keys(el).find((k) => k.startsWith('__reactFiber$')); return k && el[k]; };
// Popup handles of the "+" bar button(s) (normally one).
const hasProp = (f, k) => f.memoizedProps && typeof f.memoizedProps === 'object' && k in f.memoizedProps;
const plusHandles = () => {
const hs = [];
for (const p of window.g_PopupManager?.GetPopups() ?? []) {
if (!/gamepadui\.bar\./.test(p.m_strName ?? '')) continue;
let d;
try { d = p.window?.document; } catch { continue; }
for (const el of d?.querySelectorAll('.VRDashboardBarSmallButton') ?? []) {
let h = null;
for (let f = fiberOf(el), i = 0; f && i < 20; f = f.return, i++) {
const mp = f.memoizedProps;
if (!mp || typeof mp !== 'object') continue;
if ('refBarPopopHandle' in mp) h = mp.refBarPopopHandle?.current ?? null;
if ('allowLaunchProgram' in mp) { if (h && !hs.includes(h)) hs.push(h); break; }
}
const button = find.findFiberUp(el, (f) => hasProp(f, 'refBarPopopHandle'), 20);
if (!button || !find.findFiberUp(button, (f) => hasProp(f, 'allowLaunchProgram'), 20)) continue;
const h = button.memoizedProps.refBarPopopHandle?.current ?? null;
if (h && !hs.includes(h)) hs.push(h);
}
}
return hs;
+17 -17
View File
@@ -3,8 +3,10 @@
// pinned above or below the scrolling program list instead of scrolling with
// it.
//
// This file is a function expression; launcher-menu.nix calls it with the
// options: (<this file>)({ position: "top" }) or ({ position: "bottom" }).
// This file is a function expression, called by the file lib/default.nix
// (mkPatch) generates: (<this file>)(find, sigs, opts), with find the finder
// library (lib/finders.js) and opts { position: "top" } or
// { position: "bottom" } from launcher-menu.nix.
//
// DOM patch, evaluated in SharedJSContext (the bar popups share its realm):
// a timer attaches a MutationObserver to every dashboard bar popup document
@@ -18,10 +20,14 @@
// own handler runs (nav sound + SteamClient.Apps.LaunchNonSteamApp). The
// list container is a flex column with a max-height, so the scroll region
// shrinks to make room and the menu keeps its size.
// Anchors: React props and keys (the list key, the section's `header` prop)
// and the bar popups' window names ("valve.steam.gamepadui.barpopup...");
// scripts/check-signatures.mjs checks that Steam's UI still has them
// ("launcher-menu-pinned-desktop" in lib/signatures.json).
// unpatch.js (or a new VERSION/position) calls __sfuiPinnedDesktop.stop(),
// which removes the pinned blocks, CSS, markers, observers and the timer.
((opts) => {
const VERSION = 1;
((find, sigs, opts) => {
const VERSION = 2;
const POS = opts.position === 'top' ? 'top' : 'bottom';
const KEY = 'steamos-nested-desktop';
const HIDDEN = 'data-sfui-desktop-hidden';
@@ -40,25 +46,19 @@
if (!window.g_PopupManager) return 'g_PopupManager missing';
const docs = new Map(); // popup document -> MutationObserver
const fiberOf = (el) => { const k = Object.keys(el).find((k) => k.startsWith('__reactFiber$')); return k && el[k]; };
// The stock Desktop item: role=button element whose fiber ancestors include
// the list entry keyed by the program's exe path.
const isDesktop = (el) => {
for (let f = fiberOf(el), i = 0; f && i < 12; f = f.return, i++)
if (f.key != null) return f.key === KEY || String(f.key).endsWith('/' + KEY);
return false;
const f = find.findFiberUp(el, (x) => x.key != null, 12);
return !!f && (f.key === KEY || String(f.key).endsWith('/' + KEY));
};
// The list container (host div of the section component, which has a `header` prop).
const listOf = (el) => {
for (let f = fiberOf(el), i = 0; f && i < 60; f = f.return, i++) {
if (f.memoizedProps && 'header' in f.memoizedProps && typeof f.type === 'function') {
let c = f.child;
while (c && typeof c.type !== 'string') c = c.child;
return c?.stateNode ?? null;
}
}
return null;
const f = find.findFiberUp(el, (x) => x.memoizedProps && typeof x.memoizedProps === 'object' &&
'header' in x.memoizedProps && typeof x.type === 'function', 60);
let c = f?.child;
while (c && typeof c.type !== 'string') c = c.child;
return c?.stateNode ?? null;
};
// Child of the list container that holds the (scrolling) items.
const scrollerOf = (orig, list) => {
+26
View File
@@ -0,0 +1,26 @@
# Shared helpers of the runtime Steam UI patches.
# - finders.js: signature-based lookup of webpack modules/exports and React
# fibers (no module ids or minified names);
# - signatures.json: the signatures the patches use, per patch, also read by
# scripts/check-signatures.mjs (run it after a Steam update);
# - mkPatch: turns a patch written as a function expression
# `(find, sigs, opts) => …` into the single expression the injectors
# evaluate: (<patch>)(<finders.js>, <signatures>, <opts>).
{ pkgs }:
let
sigFile = builtins.fromJSON (builtins.readFile ./signatures.json);
in {
signatures = sigFile;
finders = ./finders.js;
# name: file name (and default signatures entry); src: the patch file;
# signatures: module signatures passed as `sigs` (default: the entry `name`
# of signatures.json, else none); opts: JSON-serialisable options.
mkPatch = { name, src, signatures ? sigFile.patches.${name}.modules or { }, opts ? { } }:
pkgs.writeText "${name}.js" ''
(${builtins.readFile src}
)(${builtins.readFile ./finders.js}
, ${builtins.toJSON signatures}
, ${builtins.toJSON opts})
'';
}
+268
View File
@@ -0,0 +1,268 @@
// finders.js: signature-based lookup of webpack modules, their exports and
// React fibers in Steam's (and SteamVR's) web UIs, so runtime patches don't
// depend on webpack module ids or minified export names, which change with
// every Steam update. In the spirit of Decky Loader's @decky/ui
// (findModule, findModuleChild, findInReactTree) and Vencord's `find`.
//
// This file is a single expression. Evaluated in a page it installs the
// library as window.__sfuiFind (unless an equal or newer VERSION is already
// there) and evaluates to it; lib/default.nix passes it to the patches that
// need it, together with their signatures from signatures.json. The same
// file is evaluated by scripts/check-signatures.mjs in Node, so patches and
// the offline check match signatures with the same code.
//
// Signatures (plain JSON, see signatures.json):
// text signature, matched against a source text (a module factory's or a
// function's Function.prototype.toString):
// { "includes": ["s1", ...], all must occur
// "excludes": ["s2", ...], none may occur
// "regex": ["/re/flags" or "re", ...] } all must match
// module signature: { "module": <text signature>,
// "exports": { "<name>": <value signature>, ... } }
// value signature (an export of the module), all given fields must hold:
// { "type": "function" | "class" | "object" | "string" | "number" | ...,
// "includes"/"excludes"/"regex": text signature on the source of a
// function/class (String(v) for strings),
// "length": n function arity,
// "props": { "k": value } data properties (own or inherited) equal to
// the given JSON primitives; getters never run,
// "has": ["k", ...] properties present (`in`, incl. getters),
// "protoMethods": ["m", ...] (class) methods on its prototype chain,
// "methods": ["m", ...] (instance) methods on its prototype chain,
// "getters": ["g", ...] getters on its prototype chain }
// Fields that only scripts/check-signatures.mjs uses: "expects" (strings the
// module source should contain, i.e. property names the patch relies on;
// reported as warnings) and "checkOnly": true (not resolved by the patch).
//
// Every lookup must match exactly once; otherwise a FinderError names the
// signature, the part that failed and the candidates.
(() => {
const VERSION = 1;
const G = globalThis;
const have = G.__sfuiFind;
if (have && have.version >= VERSION) return have;
class FinderError extends Error {
constructor(signature, message, candidates) {
super(`${signature}: ${message}`);
this.name = 'FinderError';
this.signature = signature;
this.candidates = candidates;
}
}
const fnSource = (f) => { try { return Function.prototype.toString.call(f); } catch { return ''; } };
const regexes = new Map();
const toRegExp = (r) => {
let re = regexes.get(r);
if (!re) {
const m = /^\/(.*)\/([a-z]*)$/s.exec(r);
re = m ? new RegExp(m[1], m[2]) : new RegExp(r);
regexes.set(r, re);
}
return re;
};
const isTextSig = (sig) => !!(sig.includes || sig.excludes || sig.regex);
// Short description of a signature for error messages.
const describe = (sig) => JSON.stringify(sig, (k, v) => (k === 'expects' || k === 'checkOnly' ? undefined : v));
function matchText(src, sig) {
if (typeof src !== 'string') return false;
for (const s of sig.includes ?? []) if (!src.includes(s)) return false;
for (const s of sig.excludes ?? []) if (src.includes(s)) return false;
for (const r of sig.regex ?? []) if (!toRegExp(r).test(src)) return false;
return true;
}
// Property descriptor along the prototype chain (no getter is run).
const descriptor = (o, k) => {
for (let p = o, i = 0; p && i < 50; p = Object.getPrototypeOf(p), i++) {
const d = Object.getOwnPropertyDescriptor(p, k);
if (d) return d;
}
return undefined;
};
const isClass = (v) => typeof v === 'function' && (/^class\b/.test(fnSource(v)) ||
(!!v.prototype && Object.getOwnPropertyNames(v.prototype).some((k) => k !== 'constructor')));
const kindOf = (v) => (v === null ? 'null' : typeof v === 'function' ? (isClass(v) ? 'class' : 'function') : typeof v);
function matchValue(v, sig) {
if (sig.type) {
const kind = kindOf(v);
if (!(kind === sig.type || (sig.type === 'function' && kind === 'class'))) return false;
}
if (isTextSig(sig)) {
const src = typeof v === 'function' ? fnSource(v) : typeof v === 'string' ? v : null;
if (!matchText(src, sig)) return false;
}
if (sig.length !== undefined && (typeof v !== 'function' || v.length !== sig.length)) return false;
const obj = v !== null && (typeof v === 'object' || typeof v === 'function');
if ((sig.props || sig.has || sig.methods || sig.getters) && !obj) return false;
for (const [k, want] of Object.entries(sig.props ?? {})) {
const d = descriptor(v, k);
if (!d || !('value' in d) || d.value !== want) return false;
}
for (const k of sig.has ?? []) if (!(k in v)) return false;
for (const k of sig.methods ?? []) if (typeof descriptor(v, k)?.value !== 'function') return false;
for (const k of sig.getters ?? []) if (typeof descriptor(v, k)?.get !== 'function') return false;
if (sig.protoMethods) {
const proto = typeof v === 'function' ? v.prototype : null;
if (!proto) return false;
for (const k of sig.protoMethods) if (typeof descriptor(proto, k)?.value !== 'function') return false;
}
return true;
}
// ---- webpack ----------------------------------------------------------------
const requires = new Map(); // chunk global name -> require
// webpack's require function of the page, obtained by pushing an empty
// chunk with a runtime callback onto its chunk array (once per page).
// chunkGlobal: e.g. "webpackChunksteamui" (Steam), "webpackChunkvrwebui"
// (SteamVR dashboard); default: the page's only webpackChunk* array.
function getWebpackRequire(chunkGlobal) {
let name = chunkGlobal;
if (!name) {
const names = Object.keys(G).filter((k) => k.startsWith('webpackChunk') && Array.isArray(G[k]));
if (names.length !== 1) throw new FinderError('webpack', `expected one webpackChunk* global, found [${names}]`, names);
name = names[0];
}
const cached = requires.get(name);
if (cached && typeof cached === 'function' && cached.m) return cached;
const chunks = G[name];
if (!chunks || typeof chunks.push !== 'function') throw new FinderError('webpack', `${name} missing`);
let req;
chunks.push([[Symbol('sfui')], {}, (r) => { req = r; }]);
if (typeof req !== 'function' || !req.m) throw new FinderError('webpack', `no require from ${name}`);
requires.set(name, req);
return req;
}
// Ids of all module factories whose source matches the text signature.
function findAllModules(req, textSig) {
const ids = [];
for (const id of Object.keys(req.m)) if (matchText(fnSource(req.m[id]), textSig)) ids.push(id);
return ids;
}
// The single module whose factory source matches: { id, exports }.
// Requiring it initializes it if needed (it is normally loaded already).
function findModule(req, textSig, name = 'module') {
const ids = findAllModules(req, textSig);
if (ids.length !== 1) {
throw new FinderError(name, ids.length ? `ambiguous module, candidates ${ids.join(', ')}` :
`no module matches ${describe(textSig)}`, ids);
}
return { id: ids[0], exports: req(ids[0]) };
}
// Candidate values of a module's exports: [[key, value]]. A module whose
// exports object is itself a function (CommonJS) is included as "".
const exportEntries = (exports) => {
const out = [];
if (exports == null) return out;
if (typeof exports === 'function') out.push(['', exports]);
if (typeof exports === 'object' || typeof exports === 'function') {
for (const k of Object.keys(exports)) {
let v;
try { v = exports[k]; } catch { continue; } // webpack getters; a TDZ one may throw
out.push([k, v]);
}
}
return out;
};
function findAllExports(exports, valueSig) {
return exportEntries(exports).filter(([, v]) => { try { return matchValue(v, valueSig); } catch { return false; } });
}
// The single export matching: { key, value }.
function findExport(exports, valueSig, name = 'export') {
const hits = findAllExports(exports, valueSig);
if (hits.length !== 1) {
throw new FinderError(name, hits.length ? `ambiguous export, candidates ${hits.map(([k]) => k || '(module)').join(', ')}` :
`no export matches ${describe(valueSig)}`, hits.map(([k]) => k));
}
return { key: hits[0][0], value: hits[0][1] };
}
// Module signature -> { id, module (exports object), exports: { name: value },
// keys: { name: export key } }. Cached per page (this library lives on the
// page's window); a cached result is reused while the module factory is
// still registered and every export still has the same value.
const cache = new Map();
function resolve(req, sig, name = 'module') {
const ck = name + '\n' + JSON.stringify(sig);
const c = cache.get(ck);
if (c && c.req === req && req.m[c.id] === c.factory &&
Object.entries(c.keys).every(([n, k]) => (k === '' ? c.module : c.module?.[k]) === c.exports[n])) return c;
cache.delete(ck);
const { id, exports: module } = findModule(req, sig.module, `${name}.module`);
const exports = {}, keys = {};
for (const [n, vs] of Object.entries(sig.exports ?? {})) {
const { key, value } = findExport(module, vs, `${name}.${n} (module ${id})`);
exports[n] = value; keys[n] = key;
}
const r = { id, module, exports, keys, req, factory: req.m[id] };
cache.set(ck, r);
return r;
}
// All module signatures of a patch (except checkOnly ones):
// { name: resolve(...) }. Throws on the first failure, before the patch
// changes anything.
function resolveAll(req, sigs, prefix = '') {
const out = {};
for (const [n, sig] of Object.entries(sigs ?? {})) if (!sig.checkOnly) out[n] = resolve(req, sig, prefix + n);
return out;
}
// ---- React ------------------------------------------------------------------
const fiberOf = (el) => {
if (!el || typeof el !== 'object') return null;
const k = Object.keys(el).find((x) => x.startsWith('__reactFiber$'));
return k ? el[k] : null;
};
const propsOf = (el) => {
const k = el && Object.keys(el).find((x) => x.startsWith('__reactProps$'));
return k ? el[k] : null;
};
// First fiber from `start` (a fiber or DOM element) upwards (itself, then
// .return) for which pred(fiber) holds, within max steps; else null.
function findFiberUp(start, pred, max = 100) {
let f = start?.nodeType ? fiberOf(start) : start;
for (let i = 0; f && i < max; f = f.return, i++) if (pred(f)) return f;
return null;
}
// First fiber in the subtree of `start` (depth first, child/sibling) for
// which pred(fiber) holds, visiting at most max fibers; else null.
function findFiberDown(start, pred, max = 20000) {
const root = start?.nodeType ? fiberOf(start) : start;
const stack = root ? [root] : [];
for (let n = 0; stack.length && n < max; n++) {
const f = stack.pop();
if (pred(f)) return f;
if (f.sibling && f !== root) stack.push(f.sibling);
if (f.child) stack.push(f.child);
}
return null;
}
// Decky-style search through a React element / props tree: follows the
// given keys (and array items), returns the first node with pred(node).
function findInReactTree(node, pred, { walkable = ['props', 'children', 'child', 'sibling'], max = 20000 } = {}) {
const seen = new Set();
const stack = [node];
for (let n = 0; stack.length && n < max; n++) {
const x = stack.pop();
if (!x || typeof x !== 'object' || seen.has(x)) continue;
seen.add(x);
try { if (pred(x)) return x; } catch { /* keep looking */ }
if (Array.isArray(x)) { for (let i = x.length - 1; i >= 0; i--) stack.push(x[i]); continue; }
for (const k of walkable) if (x[k] && typeof x[k] === 'object') stack.push(x[k]);
}
return null;
}
const lib = {
version: VERSION, FinderError, fnSource, matchText, matchValue, kindOf,
getWebpackRequire, findAllModules, findModule, findAllExports, findExport, resolve, resolveAll,
fiberOf, propsOf, findFiberUp, findFiberDown, findInReactTree, cache, requires,
};
G.__sfuiFind = lib;
return lib;
})()
+104
View File
@@ -0,0 +1,104 @@
{
"bundles": {
"steamui": {
"description": "Steam client UI (SharedJSContext and its popups, e.g. the VR keyboard and dashboard bar)",
"chunkGlobal": "webpackChunksteamui",
"dir": "~/.local/share/Steam/steamui",
"html": "index.html",
"exclude": "^localization/"
},
"vrwebui-systemui": {
"description": "SteamVR dashboard (vrwebhelper page systemui)",
"chunkGlobal": "webpackChunkvrwebui",
"dir": "/opt/steamvr/resources/webinterface/dashboard",
"html": "systemui.html"
}
},
"patches": {
"steam-keyboard-patch": {
"bundle": "steamui",
"modules": {
"layouts": {
"module": { "includes": ["name:\"qwerty\"", "rgLayout:", "GetKeyboardLayoutSettings"] },
"exports": {
"currentLayout": {
"type": "function", "length": 0,
"includes": ["GetKeyboardLayoutSettings", "currentLayout"],
"excludes": ["selectedLayouts", "SetKeyboardLayout"]
},
"enabledLayouts": {
"type": "function", "length": 0,
"includes": ["GetKeyboardLayoutSettings", "selectedLayouts"],
"excludes": ["useState", "SetKeyboardLayout", "SetValidKeyboardLayouts"]
},
"arrowLeft": { "type": "object", "props": { "key": "ArrowLeft" } },
"arrowUp": { "type": "object", "props": { "key": "ArrowUp" } },
"arrowDown": { "type": "object", "props": { "key": "ArrowDown" } },
"arrowRight": { "type": "object", "props": { "key": "ArrowRight" } }
},
"expects": ["Half", "rgLayout:", "leftActionButton"]
},
"vrStatus": {
"module": { "includes": ["get VRKeyboardStatus()"] },
"exports": {
"holder": { "type": "object", "getters": ["VRKeyboardStatus"] }
},
"expects": ["bIsOpen:", "sOverlayKey", "unAppID"]
},
"keyboardManager": {
"module": { "includes": ["SendClientPasteCommand(){", "HandleVirtualKeyDown(", "GetEnterKeyLabel("] },
"exports": {
"VirtualKeyboardManager": {
"type": "class",
"protoMethods": ["HandleVirtualKeyDown", "GetEnterKeyLabel", "SendClientPasteCommand"]
}
},
"expects": ["m_ActiveElementProps", "m_bDismissOnEnter", "m_Instance", "GetVROverlayKey()",
"MainRunningAppID", "ControllerKeyboardSendText"]
},
"keyboardComponent": {
"checkOnly": true,
"module": { "includes": ["TypeKeyInternal(", "toggleStates", "standardLayout"] },
"expects": ["\"data-key\"", "\"data-key-row\"", "HandleVirtualKeyDown"]
},
"popupManager": {
"checkOnly": true,
"module": { "includes": ["g_PopupManager", "GetPopups()"] }
}
}
},
"launcher-menu-order": {
"bundle": "steamui",
"modules": {
"programList": {
"checkOnly": true,
"module": { "includes": ["ScanForInstalledNonSteamApps("] }
}
}
},
"launcher-menu-launch": {
"bundle": "steamui",
"modules": {
"dashboardBar": {
"checkOnly": true,
"module": { "includes": ["allowLaunchProgram", "refBarPopopHandle", "#VRDashboard_LaunchNonSteamApp"] },
"expects": ["LaunchNonSteamApp(", "\"VRDashboardBarSmallButton\"", "BPopupOpen()", "closePopup()"]
}
}
},
"launcher-menu-pinned-desktop": {
"bundle": "steamui",
"modules": {
"dashboardBar": {
"checkOnly": true,
"module": { "includes": ["allowLaunchProgram", "refBarPopopHandle", "#VRDashboard_LaunchNonSteamApp"] },
"expects": ["header:", "strExePath", "rctContents"]
},
"popupNames": {
"checkOnly": true,
"module": { "includes": ["\"valve.steam.gamepadui.barpopup\""] }
}
}
}
}
}
+12 -3
View File
@@ -11,8 +11,17 @@
# performs those requests with xdotool on :0 (X focus follows the window
# selected in VR) and reverts the patch when stopped (unpatch.js). Its
# allowlist can't type ASCII text or press Enter.
# Depends on Steam UI internals; tested with Steam client 1790377368.
{ config, pkgs, lib, ... }: {
# Depends on Steam UI internals, found by signature (lib/finders.js, entry
# "steam-keyboard-patch" in lib/signatures.json) rather than webpack module
# ids; after a Steam update, scripts/check-signatures.mjs tells whether they
# still match. Tested with Steam client 1790377368 (UI build 11041156).
{ config, pkgs, lib, ... }:
let
patch = (import ./lib { inherit pkgs; }).mkPatch {
name = "steam-keyboard-patch";
src = ./steam-keyboard-patch/patch.js;
};
in {
imports = [ ./session.nix ];
options.steamFrame.steamKeyboardPatch.enable = lib.mkEnableOption ''
@@ -31,7 +40,7 @@
ExecStart = lib.escapeShellArgs [
"${pkgs.nodejs}/bin/node"
"${./steam-keyboard-patch/helper.mjs}"
"${./steam-keyboard-patch/patch.js}"
"${patch}"
"${./steam-keyboard-patch/unpatch.js}"
"${pkgs.xdotool}/bin/xdotool"
];
+5 -2
View File
@@ -1,4 +1,5 @@
// helper.mjs: injects patch.js into Steam's UI via CEF DevTools and
// helper.mjs: injects the patch (patch.js wrapped with the finder library by
// lib/default.nix mkPatch) into Steam's UI via CEF DevTools and
// performs the key requests of the patched VR keyboard with xdotool on :0.
// On SIGTERM/SIGINT it reverts the patch (unpatch.js), so stopping the service
// restores Steam's stock keyboard without restarting Steam.
@@ -72,10 +73,12 @@ async function session() {
const call = (method, params = {}) => new Promise((res) => {
const i = ++id; pending.set(i, res); ws.send(JSON.stringify({ id: i, method, params }));
});
let last; // logged when it changes (e.g. a signature error once, not every 15 s)
const inject = async () => {
const r = await call('Runtime.evaluate', { expression: PATCH, returnByValue: true });
const v = r.result?.result?.value ?? r.result?.exceptionDetails?.exception?.description;
if (v !== 'patched' || process.env.VRKBD_VERBOSE) console.log('inject:', v);
if (v !== last || process.env.VRKBD_VERBOSE) console.log('inject:', v);
last = v;
};
// New JS contexts (UI reload, popups) come in bursts: inject once after them.
let soon = null;
+32 -18
View File
@@ -14,15 +14,30 @@
// before (Steam then labels it "Search" and closes the keyboard instead)
// Everything goes through the CDP binding window.__vrkbdKey("<op>:<arg>"),
// executed by helper.mjs with xdotool on :0. Every replaced function keeps its
// original as __vrkbdOrig (unpatch.js restores them).
// original as __vrkbdOrig (unpatch.js restores them, through the references
// this patch remembers in window.__vrkbdRefs).
//
// This file is a function expression, called by the file lib/default.nix
// (mkPatch) generates: (<this file>)(find, sigs, opts), with find the finder
// library (lib/finders.js) and sigs this patch's module signatures
// (lib/signatures.json, "steam-keyboard-patch"). Steam's webpack modules are
// located by those signatures, not by module id or minified export name; if
// one doesn't match, the patch returns an error and changes nothing.
// Idempotent: safe to evaluate repeatedly.
(() => {
const VERSION = 9;
((find, sigs) => {
const VERSION = 10;
const send = (msg) => window.__vrkbdKey && window.__vrkbdKey(msg);
const wr = window.__vrkbdWr ||
(webpackChunksteamui.push([[Symbol('vrkbd')], {}, (r) => { window.__vrkbdWr = r; }]), window.__vrkbdWr);
const Layouts = wr(40222); // keyboard layouts (exports G$: enabled, r_: current)
const Status = wr(58508).qL; // .VRKeyboardStatus
let mods;
try {
mods = find.resolveAll(find.getWebpackRequire('webpackChunksteamui'), sigs);
} catch (e) {
return `signature not found, Steam left unpatched: ${e.message}`;
}
const Layouts = mods.layouts.exports; // currentLayout(), enabledLayouts(), arrow keys
const Status = mods.vrStatus.exports.holder; // .VRKeyboardStatus
const Manager = mods.keyboardManager.exports.VirtualKeyboardManager.prototype;
// For unpatch.js, which runs without the finder library.
window.__vrkbdRefs = { Manager, currentLayout: Layouts.currentLayout };
let changed = false;
// Replace obj[name] with make(original), once per VERSION.
@@ -48,7 +63,7 @@
const same = (x) => [x, null, x];
const MODS = [k('VKX_Escape', 'Esc'), k('Control', 'Ctrl'), k('Alt', 'Alt')];
const ALTGR_ARROWS = [k('VKX_Home', 'Pos1'), k('VKX_Prior', 'Bild↑'), k('VKX_Next', 'Bild↓'), k('VKX_End', 'Ende')];
const ARROWS = [Layouts.Md, Layouts.GO, Layouts.xl, Layouts.B6] // Steam's ArrowLeft/Up/Down/Right keys
const ARROWS = [Layouts.arrowLeft, Layouts.arrowUp, Layouts.arrowDown, Layouts.arrowRight] // Steam's own keys
.map((a, i) => [{ ...a, type: HALF }, null, ALTGR_ARROWS[i]]);
const first = (x) => (Array.isArray(x) ? x.find((y) => y) : x);
const isArrow = (x) => (Array.isArray(x) ? x : [x]).some((y) => y?.key?.startsWith?.('Arrow'));
@@ -64,7 +79,7 @@
return out;
};
window.__vrkbdLayouts ??= new Set(); // for unpatch.js, incl. layouts disabled since
for (const l of [...(Layouts.G$() || []), Layouts.r_()]) {
for (const l of [...(Layouts.enabledLayouts() || []), Layouts.currentLayout()]) {
if (typeof l?.rgLayout !== 'function') continue;
window.__vrkbdLayouts.add(l);
wrap(l, 'rgLayout', (orig) => (opts) => {
@@ -100,9 +115,9 @@
// onEnterKeyPress returning "VKClose", so when the keyboard is then opened
// for an app window, Enter shows "Suchen", runs the Steam search and hides
// the keyboard instead of typing Return. While the keyboard serves
// something other than this Steam UI (Steam's own test, L() in module
// 5363), ignore those props and the dismiss-on-Enter flag.
const Manager = wr(5363).PE.prototype;
// something other than this Steam UI (the same test Steam uses for its
// text dispatch, in the VirtualKeyboardManager's module), ignore those
// props and the dismiss-on-Enter flag.
const forOther = (m) => {
const s = Status.VRKeyboardStatus, ui = m.m_Instance;
return !!s?.bIsOpen && !(s.sOverlayKey && s.sOverlayKey === ui?.GetVROverlayKey?.()) &&
@@ -126,10 +141,9 @@
.find((p) => p.window?.document.querySelector('[data-key]'));
if (!kbPopup) return 'no keyboard popup yet';
const doc = kbPopup.window.document;
const el = doc.querySelector('[data-key]');
let fiber = el[Object.keys(el).find((x) => x.startsWith('__reactFiber'))];
while (fiber && !fiber.stateNode?.TypeKeyInternal) fiber = fiber.return;
const inst = fiber?.stateNode;
// The keyboard component: nearest fiber above a key whose instance has TypeKeyInternal.
const inst = find.findFiberUp(doc.querySelector('[data-key]'),
(f) => typeof f.stateNode?.TypeKeyInternal === 'function', 200)?.stateNode;
if (!inst) return 'no keyboard component';
let proto = Object.getPrototypeOf(inst);
while (!Object.prototype.hasOwnProperty.call(proto, 'TypeKeyInternal')) proto = Object.getPrototypeOf(proto);
@@ -206,8 +220,8 @@
// Re-render with the new row (only when something changed or it's a new instance).
if (changed || inst.__vrkbd !== VERSION) {
inst.__vrkbd = VERSION;
inst.setState({ standardLayout: Layouts.r_() });
inst.setState({ standardLayout: Layouts.currentLayout() });
inst.forceUpdate();
}
return 'patched';
})()
})
+9 -7
View File
@@ -1,8 +1,11 @@
// Reverts patch.js in Steam's SharedJSContext; evaluated by helper.mjs
// when it stops (service stopped, module disabled). Every patched function
// keeps its original as __vrkbdOrig. Safe to run when nothing is patched.
// keeps its original as __vrkbdOrig; the patched objects Steam doesn't expose
// globally were remembered by patch.js (window.__vrkbdRefs, __vrkbdLayouts,
// __vrkbdProto, __vrkbdInst). Safe to run when nothing is patched.
(() => {
if (!window.__vrkbdWr) return 'not patched';
const refs = window.__vrkbdRefs;
if (!refs && !window.__vrkbdProto && !window.__vrkbdLayouts) return 'not patched';
const unwrap = (obj, name) => {
const f = obj?.[name];
if (f?.__vrkbdOrig) obj[name] = f.__vrkbdOrig;
@@ -10,18 +13,17 @@
for (const l of window.__vrkbdLayouts || []) unwrap(l, 'rgLayout');
unwrap(SteamClient.Input, 'ControllerKeyboardSendText');
unwrap(window.__vrkbdProto, 'TypeKeyInternal');
const Manager = window.__vrkbdWr(5363).PE.prototype;
unwrap(Manager, 'GetEnterKeyLabel');
unwrap(Manager, 'HandleVirtualKeyDown');
unwrap(refs?.Manager, 'GetEnterKeyLabel');
unwrap(refs?.Manager, 'HandleVirtualKeyDown');
clearInterval(window.__vrkbdHoldTimer);
const inst = window.__vrkbdInst;
if (inst) {
delete inst.__vrkbd;
inst.setState({ standardLayout: window.__vrkbdWr(40222).r_() });
if (refs?.currentLayout) inst.setState({ standardLayout: refs.currentLayout() });
inst.forceUpdate();
}
for (const p of g_PopupManager.GetPopups?.() || []) p.window?.document.getElementById('vrkbd-style')?.remove();
for (const k of ['__vrkbdHoldTimer', '__vrkbdHeld', '__vrkbdInst', '__vrkbdProto', '__vrkbdLayouts']) delete window[k];
for (const k of ['__vrkbdHoldTimer', '__vrkbdHeld', '__vrkbdInst', '__vrkbdProto', '__vrkbdLayouts', '__vrkbdRefs']) delete window[k];
return 'unpatched';
})()
+26 -1
View File
@@ -12,6 +12,12 @@
# non-empty. It is restarted on every switch (changed patches are re-injected,
# removed ones reverted by the old instance) and stopped once the list is
# empty. The keyboard patch (steam-keyboard-patch.nix) has its own helper.
#
# Patches that need Steam's webpack modules should find them by signature
# rather than by module id or minified export name, which change with Steam
# updates: `steamFrame.uiPatches.lib.mkPatch` wraps a patch written as
# `(find, sigs, opts) => …` with the finder library (lib/finders.js) and its
# signatures; scripts/check-signatures.mjs checks signatures offline.
{ config, pkgs, lib, ... }:
let
cfg = config.steamFrame.uiPatches;
@@ -58,7 +64,8 @@ let
Runtime.evaluate, awaited). Must be idempotent: it is re-evaluated
after page reloads and every 15 s. Its result value is logged when it
changes; return e.g. "patched", and "unchanged" (never logged) when
already applied.
already applied. To use the finder library, build it with
`config.steamFrame.uiPatches.lib.mkPatch`.
'';
};
unpatch = mkOption {
@@ -88,6 +95,24 @@ let
in {
imports = [ ./session.nix ];
options.steamFrame.uiPatches.lib = mkOption {
type = types.attrsOf types.raw;
readOnly = true;
default = import ./lib { inherit pkgs; };
defaultText = lib.literalMD "the helpers of `modules/lib`";
description = ''
Helpers for writing patches (see modules/lib/default.nix):
`mkPatch { name, src, signatures ? …, opts ? { } }` returns a patch
file that calls `src`, a JavaScript function expression
`(find, sigs, opts) => …`, with the finder library `find`
(modules/lib/finders.js: getWebpackRequire, resolveAll, findModule,
findExport, findFiberUp, findInReactTree, …), the module signatures
`sigs` (format: modules/lib/signatures.json; default: its entry
`name`, if any) and `opts`. `finders` is
the library's path, `signatures` the parsed signatures.json.
'';
};
options.steamFrame.uiPatches.patches = mkOption {
type = types.listOf patchType;
default = [ ];