mirror of
https://github.com/lhns/steam-frame-nix.git
synced 2026-10-06 03:00:13 +02:00
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:
1 parent
dd6f3602aa
commit
6a8924f219
14 files changed
+1022
-78
No files matched your search
@@ -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;
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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})
|
||||
'';
|
||||
}
|
||||
@@ -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;
|
||||
})()
|
||||
@@ -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\""] }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
];
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
})()
|
||||
})
|
||||
@@ -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';
|
||||
})()
|
||||
@@ -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 = [ ];
|
||||
|
||||
Reference in new issue
Block a user